From 311e63a7fcd32ef3143b1f1b8b1cbb79cbba834b Mon Sep 17 00:00:00 2001 From: Sheik Ershad Date: Wed, 1 Jul 2026 01:55:42 +0400 Subject: [PATCH] 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. --- graphify/extract.py | 33 +++++ tests/test_indirect_dispatch_assign_return.py | 133 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 tests/test_indirect_dispatch_assign_return.py diff --git a/graphify/extract.py b/graphify/extract.py index ce857495a..1c30d3770 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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) diff --git a/tests/test_indirect_dispatch_assign_return.py b/tests/test_indirect_dispatch_assign_return.py new file mode 100644 index 000000000..886fd9cb0 --- /dev/null +++ b/tests/test_indirect_dispatch_assign_return.py @@ -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()