Skip to content

fix(parsers/python): resolve self/cls-aliased method calls in the call graph#115

Open
gadievron wants to merge 1 commit into
masterfrom
fix/python-self-cls-alias-edge
Open

fix(parsers/python): resolve self/cls-aliased method calls in the call graph#115
gadievron wants to merge 1 commit into
masterfrom
fix/python-self-cls-alias-edge

Conversation

@gadievron

@gadievron gadievron commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Root cause

CallGraphBuilder._resolve_call_node (libs/openant-core/parsers/python/call_graph_builder.py) matched a method-call receiver only when it was the literal name self or cls. A receiver that is a local alias of self/cls — obj = self; obj.method(), or alias = cls; alias.method() inside a classmethod — fell through to _resolve_module_call, which finds no import/class for a plain local variable and returns None. The self/class-method call produced no call-graph edge, so the callee can look unreachable (a false negative in the reachable set).

Reproduction

class C:
    def target(self):
        return 1
    def caller(self):
        obj = self
        return obj.target()

Before: call_graph["m.py:C.caller"] is [] (the C.caller -> C.target edge is missing).

How

  • New _collect_self_aliases(tree): scans the function body for local names single-bound to self/cls (a name assigned more than once, or ever rebound to anything else, is not treated as an alias — so no spurious edge).
  • _extract_calls_from_code builds self_aliases = {'self','cls'} | _collect_self_aliases(tree) and passes it to _resolve_call_node.
  • _resolve_call_node takes self_aliases (defaulting to {'self','cls'} so existing callers are unchanged) and resolves any <alias>.method() through the existing _resolve_self_call. The two prior self/cls branches collapse into one obj.id in self_aliases check.

This only adds edges that were previously dropped; it never removes one — so it raises reachability, never lowers it. The change is confined to the receiver-resolution branch (no new subsystem / data model).

Regression test

libs/openant-core/tests/parsers/python/test_call_graph_self_calls.py (+62 lines):

  • test_self_alias_method_call_edgeobj = self; obj.target() edge present.
  • test_cls_alias_method_call_edgealias = cls; alias.target() edge present.
  • test_reassigned_alias_does_not_force_self_edge — soundness guard: a name reassigned away from self is NOT aliased.

RED (before fix):

FAILED tests/parsers/python/test_call_graph_self_calls.py::test_self_alias_method_call_edge
FAILED tests/parsers/python/test_call_graph_self_calls.py::test_cls_alias_method_call_edge
2 failed, 5 passed in 157.48s

GREEN (after fix), full tests/parsers/python/:

7 passed in 50.40s

Compatibility

None. _resolve_call_node's new parameter is optional and defaults to the previous behavior. No public API change.

Notes

The canonical per-parser tests/parsers/python/test_callgraph_symmetry.py / test_python_schema_completeness.py do not yet exist in this repo (a pre-existing gap across all parsers, not introduced here); this fix extends the existing test_call_graph_self_calls.py.

Author notes

  • Implementing lines: _collect_self_aliases (new), the obj.id in self_aliases receiver branch, and the self_aliases thread-through in _extract_calls_from_code.
  • Input fixed: obj = self; obj.target() — previously no C.caller -> C.target edge; now resolved.
  • Likely pushback: "wrong edge if the alias is reassigned?" — no; only single-unconditional = self/= cls bindings count, pinned by test_reassigned_alias_does_not_force_self_edge.

Coordination with open PRs

#112 adds a _build_alias_map to the same resolution path, but for function-value aliases (fn = helper; fn()) — its docstring notes class/method targets are out of scope. This PR handles the distinct self/cls alias case (obj = self; obj.method()), which #112 leaves unresolved. The two are complementary but touch the same code; they should be unified rather than stacked. Happy to rebase onto #112 and fold the self/cls aliasing into its alias map.

…l 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>
@gadievron

Copy link
Copy Markdown
Collaborator Author

Merge-order note (not a defect — flagging for landing order)

This is an explicit follow-up to #112 (same parsers/python/call_graph_builder.py) — consider adding Depends-on: #112. Union: _resolve_call_node must thread all three — local_types + aliases (#112) and self_aliases (this PR). Whichever lands second will conflict; keep all three params.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant