mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-24 06:26:11 +00:00
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:
@@ -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()
|
||||
Reference in New Issue
Block a user