Skip to content

Commit 2f005fa

Browse files
gadievronclaude
andcommitted
fix(parsers/python): resolve self/cls-aliased method calls in the call graph
`_resolve_call_node` matched a method-call receiver only when it was literally named `self`/`cls`, so an aliased receiver — `obj = self; obj.method()` or `alias = cls; alias.method()` in a classmethod — produced NO call-graph edge. The inherited/self method then looked unreachable (a false negative in the reachable set, the dangerous direction for reachability-based analysis). Fix: in `_extract_calls_from_code`, collect local names single-bound to `self`/`cls` (`_collect_self_aliases`) and pass them to `_resolve_call_node`, which now routes any `<alias>.method()` through `_resolve_self_call`. Only single, unconditional bindings count — a name reassigned to anything else is not treated as an alias, so no spurious edge is created. The change only ADDS previously-dropped edges; it never removes one (raises reachability, never lowers it) and is local to the receiver-resolution branch (no architecture change). Tests (tests/parsers/python/test_call_graph_self_calls.py): RED before / GREEN after — `test_self_alias_method_call_edge`, `test_cls_alias_method_call_edge`, plus `test_reassigned_alias_does_not_force_self_edge` (soundness guard). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ad17f3f commit 2f005fa

2 files changed

Lines changed: 98 additions & 11 deletions

File tree

libs/openant-core/parsers/python/call_graph_builder.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,13 @@ def _extract_calls_from_code(self, code: str, caller_id: str) -> Set[str]:
186186
# Local variable -> constructor-type map, so that `v = ClassName(); v.method()`
187187
# dispatches to the bound type's method.
188188
local_types = self._collect_local_types(tree)
189+
# Receiver names that refer to the current instance/class: self/cls plus any
190+
# local name single-bound to them (obj = self; obj.method()).
191+
self_aliases = {'self', 'cls'} | self._collect_self_aliases(tree)
189192

190193
for node in ast.walk(tree):
191194
if isinstance(node, ast.Call):
192-
resolved = self._resolve_call_node(node, caller_file, caller_class, local_types)
195+
resolved = self._resolve_call_node(node, caller_file, caller_class, local_types, self_aliases)
193196
if resolved:
194197
calls.add(resolved)
195198
# Higher-order-function callbacks: a function reference passed as an
@@ -248,11 +251,38 @@ def _resolve_callback_args(self, node: ast.Call, caller_file: str) -> List[str]:
248251
callees.append(resolved)
249252
return callees
250253

254+
def _collect_self_aliases(self, tree: ast.AST) -> Set[str]:
255+
"""Local names single-bound to ``self``/``cls`` (e.g. ``obj = self``).
256+
257+
Only single, unconditional bindings count: a name assigned more than
258+
once — or ever rebound to something other than self/cls — is NOT an
259+
alias, so no spurious self-method edge is created.
260+
"""
261+
assign_count: dict = {}
262+
self_bound: Set[str] = set()
263+
for node in ast.walk(tree):
264+
if isinstance(node, ast.Assign):
265+
for tgt in node.targets:
266+
if isinstance(tgt, ast.Name):
267+
assign_count[tgt.id] = assign_count.get(tgt.id, 0) + 1
268+
if isinstance(node.value, ast.Name) and node.value.id in ('self', 'cls'):
269+
self_bound.add(tgt.id)
270+
return {name for name in self_bound if assign_count.get(name) == 1}
271+
251272
def _resolve_call_node(self, node: ast.Call, caller_file: str, caller_class: Optional[str],
252-
local_types: Optional[Dict[str, str]] = None) -> Optional[str]:
253-
"""Resolve an AST Call node to a function ID."""
273+
local_types: Optional[Dict[str, str]] = None,
274+
self_aliases: Optional[Set[str]] = None) -> Optional[str]:
275+
"""Resolve an AST Call node to a function ID.
276+
277+
``self_aliases`` is the set of receiver names that refer to the current
278+
instance/class — always includes ``self``/``cls`` plus any local name
279+
single-bound to them (``obj = self; obj.method()``). Defaults to the
280+
bare ``{'self', 'cls'}`` so direct callers keep working.
281+
"""
254282
func = node.func
255283
local_types = local_types or {}
284+
if self_aliases is None:
285+
self_aliases = {'self', 'cls'}
256286

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

269-
# self.method(...) - same class
270-
if isinstance(obj, ast.Name) and obj.id == 'self':
271-
if caller_class:
272-
return self._resolve_self_call(method_name, caller_file, caller_class)
273-
return None
274-
275-
# cls.method(...) - classmethod
276-
if isinstance(obj, ast.Name) and obj.id == 'cls':
299+
# self.method(...) / cls.method(...) — same class, including a local
300+
# alias single-bound to self/cls (obj = self; obj.method()).
301+
if isinstance(obj, ast.Name) and obj.id in self_aliases:
277302
if caller_class:
278303
return self._resolve_self_call(method_name, caller_file, caller_class)
279304
return None

libs/openant-core/tests/parsers/python/test_call_graph_self_calls.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,65 @@ def test_callee_is_not_isolated_in_statistics():
138138
f" call_graph: {builder.call_graph}\n"
139139
f" reverse_call_graph: {builder.reverse_call_graph}"
140140
)
141+
142+
143+
# --- self / cls alias edges (byproduct-deepcheck 2026-06-12) ---
144+
# `obj = self; obj.method()` and `alias = cls; alias.method()` are real
145+
# self/class-method calls, but _resolve_call_node only matched a receiver
146+
# literally named `self`/`cls`, so the edge was DROPPED (under-resolution ->
147+
# the callee can look unreachable). Fixing ADDS the missing edge; it never
148+
# removes one (raises reachability, never lowers it).
149+
150+
def _alias_extractor_output(body: str, decorator: str = "") -> dict:
151+
"""Two methods on one class; `caller` reaches `target` via a self/cls alias."""
152+
file_path = "m.py"
153+
dec = f" {decorator}\n" if decorator else ""
154+
return {
155+
"repository": "/tmp/fake",
156+
"imports": {file_path: {}},
157+
"classes": {f"{file_path}:C": {"name": "C", "file_path": file_path}},
158+
"functions": {
159+
f"{file_path}:C.target": {
160+
"name": "target", "qualified_name": "C.target", "file_path": file_path,
161+
"class_name": "C", "unit_type": "method",
162+
"code": f"{dec} def target(self):\n return 1\n",
163+
},
164+
f"{file_path}:C.caller": {
165+
"name": "caller", "qualified_name": "C.caller", "file_path": file_path,
166+
"class_name": "C", "unit_type": "method", "code": body,
167+
},
168+
},
169+
}
170+
171+
172+
def test_self_alias_method_call_edge():
173+
"""`obj = self; obj.target()` must produce the C.caller -> C.target edge."""
174+
body = " def caller(self):\n obj = self\n return obj.target()\n"
175+
builder = CallGraphBuilder(_alias_extractor_output(body))
176+
builder.build_call_graph()
177+
assert "m.py:C.target" in builder.call_graph["m.py:C.caller"], (
178+
f"self-alias edge missing; got {builder.call_graph['m.py:C.caller']}"
179+
)
180+
181+
182+
def test_cls_alias_method_call_edge():
183+
"""`alias = cls; alias.target()` in a classmethod must produce the edge."""
184+
body = (" @classmethod\n def caller(cls):\n"
185+
" alias = cls\n return alias.target()\n")
186+
builder = CallGraphBuilder(_alias_extractor_output(body, decorator="@classmethod"))
187+
builder.build_call_graph()
188+
assert "m.py:C.target" in builder.call_graph["m.py:C.caller"], (
189+
f"cls-alias edge missing; got {builder.call_graph['m.py:C.caller']}"
190+
)
191+
192+
193+
def test_reassigned_alias_does_not_force_self_edge():
194+
"""Guard: a name reassigned away from self must NOT be treated as a self-alias
195+
(single-unconditional binding only) — keeps the fix sound, no spurious edges."""
196+
body = (" def caller(self, other):\n obj = self\n"
197+
" obj = other\n return obj.target()\n")
198+
builder = CallGraphBuilder(_alias_extractor_output(body))
199+
builder.build_call_graph()
200+
assert "m.py:C.target" not in builder.call_graph.get("m.py:C.caller", []), (
201+
"reassigned alias wrongly resolved to a self-method edge"
202+
)

0 commit comments

Comments
 (0)