feat(extract): capture assignment/return indirect_call edges (#1566 slice 2)

A function bound to a name (cb = handler) or returned from a factory
(def make(): return handler) is a real reference, but indirect_call only
covered call arguments and dispatch tables, so `affected` still dropped these
callers.

Emit indirect_call (context "assignment"/"return", INFERRED) for the value-side
identifiers of a Python assignment RHS and a return, at function scope (owner =
enclosing function) and module scope (owner = file node). Reuses the shared
_emit_indirect_ref guard. Scans the VALUE side only -- the assignment target is
a new local binding, not a reference -- so the existing param/local shadow guard
still rejects the false edges #1565 fixed.

Negatives covered: param-shadow, local-shadow, non-callable emit nothing.
Full suite green; ruff clean.
This commit is contained in:
Sheik Ershad
2026-06-30 23:26:13 +01:00
committed by safishamsi
parent 47033c8b75
commit 311e63a7fc
2 changed files with 166 additions and 0 deletions
+33
View File
@@ -4397,6 +4397,20 @@ def _extract_generic(
if el.type == "identifier":
yield el
def _python_ref_value_idents(value_node):
"""Identifiers on the VALUE side of an assignment RHS or a return: a bare name
(`cb = handler`, `return handler`) or the elements of a bare unpack
(`a, b = f, g`). A collection LITERAL on the RHS (`cb = [f]`, `cb = (f, g)`) is a
dispatch table reached by the normal recursion, so it is not handled here."""
if value_node is None:
return
if value_node.type == "identifier":
yield value_node
elif value_node.type == "expression_list":
for ch in value_node.children:
if ch.type == "identifier":
yield ch
def _php_class_const_scope(n) -> str | None:
scope = n.child_by_field_name("scope")
if scope is None:
@@ -4828,6 +4842,21 @@ def _extract_generic(
for ident in _js_dispatch_value_idents(node):
_emit_indirect_ref(ident, caller_nid, enclosing_locals, "collection")
# Assignment / return references (#1566 slice 2): a function bound to a name
# (cb = handler) or returned from a factory (return handler) is an indirect
# dependency of the enclosing function. The VALUE side only -- the assignment
# TARGET is a new local binding, not a reference -- so the shared shadow guard
# still holds (a param/local named on the RHS is the local, not the module fn).
if config.ts_module == "tree_sitter_python" and node.type == "assignment":
enclosing_locals = local_bound_names.get(caller_nid, frozenset())
for ident in _python_ref_value_idents(node.child_by_field_name("right")):
_emit_indirect_ref(ident, caller_nid, enclosing_locals, "assignment")
elif config.ts_module == "tree_sitter_python" and node.type == "return_statement":
enclosing_locals = local_bound_names.get(caller_nid, frozenset())
value = next((c for c in node.children if c.is_named), None)
for ident in _python_ref_value_idents(value):
_emit_indirect_ref(ident, caller_nid, enclosing_locals, "return")
for child in node.children:
walk_calls(child, caller_nid)
@@ -4889,6 +4918,10 @@ def _extract_generic(
if n.type in ("dictionary", "list", "set", "tuple"):
for ident in _python_dispatch_value_idents(n):
_emit_indirect_ref(ident, file_nid, module_bound, "collection")
elif n.type == "assignment":
# Module-level alias / re-export: CALLBACK = handler
for ident in _python_ref_value_idents(n.child_by_field_name("right")):
_emit_indirect_ref(ident, file_nid, module_bound, "assignment")
for c in n.children:
_scan_module_dispatch(c)
@@ -0,0 +1,133 @@
"""Indirect dispatch via assignment + return references — #1566 slice 2.
A function bound to a name (`cb = handler`) or returned from a factory
(`def make(): return handler`) is a real reference. It is emitted under `indirect_call`
via the shared resolve-and-emit guard. The VALUE side only -- the assignment TARGET is a
new local binding, not a reference -- so the shadow guard still holds: a param or local
named on the RHS is the local, not the module fn. The negatives pin that the false edges
#1565 fixed do not come back.
"""
import networkx as nx
from graphify.affected import affected_nodes
from graphify.extract import extract_python
def _extract(tmp_path, src):
(tmp_path / "m.py").write_text(src)
r = extract_python(tmp_path / "m.py")
nid = {n["label"].rstrip("()"): n["id"] for n in r["nodes"]}
return r, nid
def _ind(r):
return {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "indirect_call"}
ASSIGN_RETURN = '''\
def handler(): ...
def other(): ...
def bind():
cb = handler # assignment reference
return cb
def make():
return other # return reference
'''
def test_assignment_and_return_emit_indirect_call(tmp_path):
r, nid = _extract(tmp_path, ASSIGN_RETURN)
ind = _ind(r)
assert (nid["bind"], nid["handler"]) in ind
assert (nid["make"], nid["other"]) in ind
calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
assert (nid["bind"], nid["handler"]) not in calls # not in the precise relation
for e in r["edges"]:
if e["relation"] == "indirect_call":
assert e["context"] in ("assignment", "return") and e["confidence"] == "INFERRED"
MULTI = '''\
def f(): ...
def g(): ...
def via():
a, b = f, g # tuple-unpack assignment (expression_list RHS)
return a
'''
def test_multiple_assignment_emits_for_each(tmp_path):
r, nid = _extract(tmp_path, MULTI)
ind = _ind(r)
assert (nid["via"], nid["f"]) in ind and (nid["via"], nid["g"]) in ind
MODULE_ALIAS = '''\
def handler(): ...
CALLBACK = handler # module-level alias / re-export
'''
def test_module_level_assignment_emits_indirect_call(tmp_path):
r, nid = _extract(tmp_path, MODULE_ALIAS)
assert (nid["m.py"], nid["handler"]) in _ind(r)
def test_assignment_feeds_affected(tmp_path):
r, nid = _extract(tmp_path, ASSIGN_RETURN)
g = nx.DiGraph()
for n in r["nodes"]:
g.add_node(n["id"], **n)
for e in r["edges"]:
g.add_edge(e["source"], e["target"], **e)
affected = {h.node_id for h in affected_nodes(g, nid["handler"])}
assert nid["bind"] in affected
# ── negatives: the inverted-shadow trap (must not reintroduce #1565's false edges) ──
PARAM_SHADOW = '''\
def handler(): ...
def via(handler):
cb = handler # `handler` is a PARAMETER, not the module fn
return handler
'''
def test_param_shadow_emits_nothing(tmp_path):
r, nid = _extract(tmp_path, PARAM_SHADOW)
assert all(t != nid["handler"] for _s, t in _ind(r))
LOCAL_SHADOW = '''\
def handler(): ...
def via():
handler = object() # local DATA binding shadows the module fn
cb = handler # `handler` here is the local, not the module fn
return handler
'''
def test_local_shadow_emits_nothing(tmp_path):
r, nid = _extract(tmp_path, LOCAL_SHADOW)
assert all(t != nid["handler"] for _s, t in _ind(r))
NON_CALLABLE = '''\
def handler(): ...
def via():
cb = TIMEOUT # TIMEOUT is not a callable def
return cb
'''
def test_non_callable_value_emits_nothing(tmp_path):
r, _nid = _extract(tmp_path, NON_CALLABLE)
assert _ind(r) == set()