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
47 changes: 36 additions & 11 deletions libs/openant-core/parsers/python/call_graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,13 @@ def _extract_calls_from_code(self, code: str, caller_id: str) -> Set[str]:
# Local variable -> constructor-type map, so that `v = ClassName(); v.method()`
# dispatches to the bound type's method.
local_types = self._collect_local_types(tree)
# Receiver names that refer to the current instance/class: self/cls plus any
# local name single-bound to them (obj = self; obj.method()).
self_aliases = {'self', 'cls'} | self._collect_self_aliases(tree)

for node in ast.walk(tree):
if isinstance(node, ast.Call):
resolved = self._resolve_call_node(node, caller_file, caller_class, local_types)
resolved = self._resolve_call_node(node, caller_file, caller_class, local_types, self_aliases)
if resolved:
calls.add(resolved)
# Higher-order-function callbacks: a function reference passed as an
Expand Down Expand Up @@ -248,11 +251,38 @@ def _resolve_callback_args(self, node: ast.Call, caller_file: str) -> List[str]:
callees.append(resolved)
return callees

def _collect_self_aliases(self, tree: ast.AST) -> Set[str]:
"""Local names single-bound to ``self``/``cls`` (e.g. ``obj = self``).

Only single, unconditional bindings count: a name assigned more than
once — or ever rebound to something other than self/cls — is NOT an
alias, so no spurious self-method edge is created.
"""
assign_count: dict = {}
self_bound: Set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name):
assign_count[tgt.id] = assign_count.get(tgt.id, 0) + 1
if isinstance(node.value, ast.Name) and node.value.id in ('self', 'cls'):
self_bound.add(tgt.id)
return {name for name in self_bound if assign_count.get(name) == 1}

def _resolve_call_node(self, node: ast.Call, caller_file: str, caller_class: Optional[str],
local_types: Optional[Dict[str, str]] = None) -> Optional[str]:
"""Resolve an AST Call node to a function ID."""
local_types: Optional[Dict[str, str]] = None,
self_aliases: Optional[Set[str]] = None) -> Optional[str]:
"""Resolve an AST Call node to a function ID.

``self_aliases`` is the set of receiver names that refer to the current
instance/class — always includes ``self``/``cls`` plus any local name
single-bound to them (``obj = self; obj.method()``). Defaults to the
bare ``{'self', 'cls'}`` so direct callers keep working.
"""
func = node.func
local_types = local_types or {}
if self_aliases is None:
self_aliases = {'self', 'cls'}

# Simple function call: func_name(...)
if isinstance(func, ast.Name):
Expand All @@ -266,14 +296,9 @@ def _resolve_call_node(self, node: ast.Call, caller_file: str, caller_class: Opt
method_name = func.attr
obj = func.value

# self.method(...) - same class
if isinstance(obj, ast.Name) and obj.id == 'self':
if caller_class:
return self._resolve_self_call(method_name, caller_file, caller_class)
return None

# cls.method(...) - classmethod
if isinstance(obj, ast.Name) and obj.id == 'cls':
# self.method(...) / cls.method(...) — same class, including a local
# alias single-bound to self/cls (obj = self; obj.method()).
if isinstance(obj, ast.Name) and obj.id in self_aliases:
if caller_class:
return self._resolve_self_call(method_name, caller_file, caller_class)
return None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,65 @@ def test_callee_is_not_isolated_in_statistics():
f" call_graph: {builder.call_graph}\n"
f" reverse_call_graph: {builder.reverse_call_graph}"
)


# --- self / cls alias edges (byproduct-deepcheck 2026-06-12) ---
# `obj = self; obj.method()` and `alias = cls; alias.method()` are real
# self/class-method calls, but _resolve_call_node only matched a receiver
# literally named `self`/`cls`, so the edge was DROPPED (under-resolution ->
# the callee can look unreachable). Fixing ADDS the missing edge; it never
# removes one (raises reachability, never lowers it).

def _alias_extractor_output(body: str, decorator: str = "") -> dict:
"""Two methods on one class; `caller` reaches `target` via a self/cls alias."""
file_path = "m.py"
dec = f" {decorator}\n" if decorator else ""
return {
"repository": "/tmp/fake",
"imports": {file_path: {}},
"classes": {f"{file_path}:C": {"name": "C", "file_path": file_path}},
"functions": {
f"{file_path}:C.target": {
"name": "target", "qualified_name": "C.target", "file_path": file_path,
"class_name": "C", "unit_type": "method",
"code": f"{dec} def target(self):\n return 1\n",
},
f"{file_path}:C.caller": {
"name": "caller", "qualified_name": "C.caller", "file_path": file_path,
"class_name": "C", "unit_type": "method", "code": body,
},
},
}


def test_self_alias_method_call_edge():
"""`obj = self; obj.target()` must produce the C.caller -> C.target edge."""
body = " def caller(self):\n obj = self\n return obj.target()\n"
builder = CallGraphBuilder(_alias_extractor_output(body))
builder.build_call_graph()
assert "m.py:C.target" in builder.call_graph["m.py:C.caller"], (
f"self-alias edge missing; got {builder.call_graph['m.py:C.caller']}"
)


def test_cls_alias_method_call_edge():
"""`alias = cls; alias.target()` in a classmethod must produce the edge."""
body = (" @classmethod\n def caller(cls):\n"
" alias = cls\n return alias.target()\n")
builder = CallGraphBuilder(_alias_extractor_output(body, decorator="@classmethod"))
builder.build_call_graph()
assert "m.py:C.target" in builder.call_graph["m.py:C.caller"], (
f"cls-alias edge missing; got {builder.call_graph['m.py:C.caller']}"
)


def test_reassigned_alias_does_not_force_self_edge():
"""Guard: a name reassigned away from self must NOT be treated as a self-alias
(single-unconditional binding only) — keeps the fix sound, no spurious edges."""
body = (" def caller(self, other):\n obj = self\n"
" obj = other\n return obj.target()\n")
builder = CallGraphBuilder(_alias_extractor_output(body))
builder.build_call_graph()
assert "m.py:C.target" not in builder.call_graph.get("m.py:C.caller", []), (
"reassigned alias wrongly resolved to a self-method edge"
)
Loading