Skip to content

Commit 55a6db0

Browse files
gadievronclaude
andcommitted
fix(parsers/python): guard alias resolution to single unconditional binding (BUG-NEW 12)
Python hunk of the alias-guard (ruby hunk ships in the ruby PR). Local-only; not pushed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c474ced commit 55a6db0

2 files changed

Lines changed: 102 additions & 11 deletions

File tree

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

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -203,23 +203,76 @@ def _build_alias_map(self, tree: ast.AST, caller_file: str) -> Dict[str, str]:
203203
(same-file or via the global single-name index) to a user function. This
204204
lets `fn = helper; fn()` recover the `helper` edge. Class/method targets
205205
are out of scope (only ast.Name = ast.Name single-target assigns).
206+
207+
SINGLE-UNCONDITIONAL-ASSIGNMENT GUARD: an alias is kept ONLY when the
208+
name is bound exactly once AND at the function/module top level (a direct
209+
statement of the body, not nested inside an If/For/While/Try/With or any
210+
other block). A name bound 2+ times (last-write-wins) or bound inside a
211+
conditional/loop/try/with is a "maybe" binding; resolving it would assert
212+
a maybe as definite, so we drop it and let `x()` fall through to normal
213+
resolution (no edge). Precision over recall.
206214
"""
207-
aliases: Dict[str, str] = {}
215+
# Count EVERY assignment to each name anywhere in the body (any nesting,
216+
# both Assign targets and AnnAssign), so a reassignment or a conditional
217+
# rebinding disqualifies the name even if one binding is top-level.
218+
assign_counts: Dict[str, int] = {}
208219
for node in ast.walk(tree):
209-
if not isinstance(node, ast.Assign):
220+
if isinstance(node, ast.Assign):
221+
for target in node.targets:
222+
for name in self._assigned_names(target):
223+
assign_counts[name] = assign_counts.get(name, 0) + 1
224+
elif isinstance(node, ast.AnnAssign) and node.value is not None:
225+
for name in self._assigned_names(node.target):
226+
assign_counts[name] = assign_counts.get(name, 0) + 1
227+
228+
# Only consider candidate aliases declared at the body's TOP LEVEL.
229+
top_level = self._function_body_statements(tree)
230+
aliases: Dict[str, str] = {}
231+
for stmt in top_level:
232+
if not isinstance(stmt, ast.Assign):
210233
continue
211-
if not isinstance(node.value, ast.Name):
234+
if not isinstance(stmt.value, ast.Name):
212235
continue
213-
for target in node.targets:
214-
if not isinstance(target, ast.Name):
215-
continue
216-
if target.id == node.value.id:
217-
continue
218-
resolved = self._resolve_simple_call(node.value.id, caller_file)
219-
if resolved:
220-
aliases[target.id] = resolved
236+
if len(stmt.targets) != 1:
237+
continue
238+
target = stmt.targets[0]
239+
if not isinstance(target, ast.Name):
240+
continue
241+
if target.id == stmt.value.id:
242+
continue
243+
# GUARD: bound exactly once across the whole body (single +
244+
# unconditional, because a conditional binding would push the
245+
# top-level count higher OR not appear at top level at all).
246+
if assign_counts.get(target.id, 0) != 1:
247+
continue
248+
resolved = self._resolve_simple_call(stmt.value.id, caller_file)
249+
if resolved:
250+
aliases[target.id] = resolved
221251
return aliases
222252

253+
@staticmethod
254+
def _assigned_names(target: ast.AST):
255+
"""Yield the simple Name ids bound by an assignment target (recursively
256+
through tuple/list unpacking)."""
257+
if isinstance(target, ast.Name):
258+
yield target.id
259+
elif isinstance(target, (ast.Tuple, ast.List)):
260+
for elt in target.elts:
261+
yield from CallGraphBuilder._assigned_names(elt)
262+
263+
@staticmethod
264+
def _function_body_statements(tree: ast.AST) -> List[ast.stmt]:
265+
"""Return the top-level statements of the analysed function body.
266+
267+
The extractor's `code` includes the `def` line, so a single function
268+
parses to Module(body=[FunctionDef]); the real body is that def's body.
269+
Fall back to the module body for bare module-level code.
270+
"""
271+
body = getattr(tree, 'body', [])
272+
if len(body) == 1 and isinstance(body[0], (ast.FunctionDef, ast.AsyncFunctionDef)):
273+
return list(body[0].body)
274+
return list(body)
275+
223276
def _resolve_local_function(self, func_name: str, caller_file: str) -> Optional[str]:
224277
"""Resolve a bare name to a same-file, non-method user function.
225278

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,44 @@ def test_function_value_alias_resolves():
8181
)
8282

8383

84+
# ---------------------------------------------------- [12] single-assign GUARD
85+
def test_alias_reassignment_not_resolved():
86+
"""GUARD (reassignment): `fn = a; fn = b; fn()` is last-write-wins, so the
87+
binding is a "maybe". The guard must NOT assert a definite edge to EITHER
88+
target — pinned behavior: no alias edge at all (fall through to no edge)."""
89+
builder = _build({
90+
"m.py": (
91+
"def a():\n return 1\n\n"
92+
"def b():\n return 2\n\n"
93+
"def main():\n fn = a\n fn = b\n fn()\n"
94+
),
95+
})
96+
caller = "m.py:main"
97+
edges = builder.call_graph.get(caller, [])
98+
assert "m.py:a" not in edges and "m.py:b" not in edges, (
99+
f"reassigned alias asserted a maybe-binding as definite: {edges}"
100+
)
101+
102+
103+
def test_alias_conditional_binding_not_resolved():
104+
"""GUARD (conditional): an alias bound inside if/else is not unconditional,
105+
so it must NOT be resolved (no edge to either branch's target)."""
106+
builder = _build({
107+
"m.py": (
108+
"def a():\n return 1\n\n"
109+
"def b():\n return 2\n\n"
110+
"def main(cond):\n"
111+
" if cond:\n fn = a\n else:\n fn = b\n"
112+
" fn()\n"
113+
),
114+
})
115+
caller = "m.py:main"
116+
edges = builder.call_graph.get(caller, [])
117+
assert "m.py:a" not in edges and "m.py:b" not in edges, (
118+
f"conditional alias resolved despite non-unconditional binding: {edges}"
119+
)
120+
121+
84122
# ---------------------------------------------------------------- [27]
85123
def test_import_module_call_does_not_false_link_to_samename_free_fn():
86124
"""`import alpha; alpha.run()` must NOT link caller -> free fn `alpha`."""

0 commit comments

Comments
 (0)