Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions src/custodian/audit_kit/passes/call_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ def _collect_usages_only(tree: ast.Module, cg: CallGraph) -> None:
cg.constructed_names.add(base.id)
elif isinstance(base, ast.Attribute):
cg.constructed_names.add(base.attr)
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name):
Expand All @@ -121,23 +120,19 @@ def _collect_usages_only(tree: ast.Module, cg: CallGraph) -> None:
cg.constructed_names.add(kw.value.id)
if kw.arg: # keyword arg name — Model(field=value) records "field"
cg.kw_arg_names.add(kw.arg)
if (
isinstance(func, ast.Attribute)
and func.attr.startswith("model_validate")
and isinstance(func.value, ast.Name)
):
cg.model_validate_classes.add(func.value.id)
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load):
cg.accessed_attrs.add(node.attr)
# ClassName.method(...) or EnumClass.MEMBER — treat as "class is in active use"
if isinstance(node.value, ast.Name):
cg.constructed_names.add(node.value.id)
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
cg.called_names.add(node.id)
# ClassName.model_validate*(...) — deserialized from external data; all fields are schema fields
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
if (
isinstance(func, ast.Attribute)
and func.attr.startswith("model_validate")
and isinstance(func.value, ast.Name)
):
cg.model_validate_classes.add(func.value.id)


def _collect_from_module(tree: ast.Module, cg: CallGraph) -> None:
Expand Down
36 changes: 35 additions & 1 deletion tests/test_call_graph_detectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@

import textwrap
from pathlib import Path
from unittest.mock import patch

from custodian.audit_kit.detector import AnalysisGraph, AuditContext
from custodian.audit_kit.detectors.dead_code import detect_d1, detect_f1
from custodian.audit_kit.passes.call_graph import build_call_graph
from custodian.audit_kit.passes.call_graph import _collect_usages_only, build_call_graph


# ── helpers ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -86,6 +87,39 @@ def foo(): pass
cg = build_call_graph(tmp_path / "src")
assert "my_decorator" in cg.decorated_names

def test_collect_usages_only_walks_tree_once(self):
tree = __import__("ast").parse(
textwrap.dedent(
"""
class Child(Base):
pass

Foo.model_validate(payload)
obj.run()
value = obj.field
"""
)
)
walk_calls = 0
import ast

real_walk = ast.walk

def _counting_walk(node):
nonlocal walk_calls
walk_calls += 1
return real_walk(node)

with patch("custodian.audit_kit.passes.call_graph.ast.walk", side_effect=_counting_walk):
cg = build_call_graph(Path("/tmp/does-not-matter"))
_collect_usages_only(tree, cg)

assert walk_calls == 1
assert "Base" in cg.constructed_names
assert "run" in cg.called_attrs
assert "field" in cg.accessed_attrs
assert "Foo" in cg.model_validate_classes


# ── D1 tests ──────────────────────────────────────────────────────────────────

Expand Down
Loading