diff --git a/libs/openant-core/parsers/python/call_graph_builder.py b/libs/openant-core/parsers/python/call_graph_builder.py index 9cd73984..5d2c0db4 100644 --- a/libs/openant-core/parsers/python/call_graph_builder.py +++ b/libs/openant-core/parsers/python/call_graph_builder.py @@ -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 @@ -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): @@ -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 diff --git a/libs/openant-core/tests/parsers/python/test_call_graph_self_calls.py b/libs/openant-core/tests/parsers/python/test_call_graph_self_calls.py index a92c23cc..8b98c0a9 100644 --- a/libs/openant-core/tests/parsers/python/test_call_graph_self_calls.py +++ b/libs/openant-core/tests/parsers/python/test_call_graph_self_calls.py @@ -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" + )