feat(extract): capture getattr(obj, "name") indirect_call edges (#1566 slice 3)

Reflective dispatch by string literal — getattr(obj, "handler") — resolves the
attribute name to a callable def and emits it under indirect_call (context
"getattr", INFERRED) at both function and module scope, so `graphify affected
handler` now covers getattr call sites.

The name is a STRING, not an identifier: it names an attribute and is never
shadowed by a param/local, so it resolves without the identifier shadow guard —
the inverse of the #1565/#1566 identifier paths. A dynamic name (a variable,
f-string, concatenation, or any expression) is not statically resolvable and emits
nothing; obj.getattr(...) (a method, not the builtin) and the 1-arg form are ignored.

Refactors the shared resolve-and-emit core out of _emit_indirect_ref into
_emit_indirect_by_name so the getattr path reuses it (callable-target-only,
cross-file deferral, dedup) without duplicating the guard; the identifier wrapper
is behavior-preserving. Full suite green.
This commit is contained in:
Sheik Ershad
2026-07-01 10:28:18 +04:00
committed by safishamsi
parent 4f40967e8d
commit 8fdbf50197
2 changed files with 230 additions and 17 deletions
+77 -17
View File
@@ -4328,22 +4328,17 @@ def _extract_generic(
# receiver_type so the cross-file pass resolves `var.method` by type (#ruby).
ruby_var_types: dict[str, dict[str, str | None]] = {}
def _emit_indirect_ref(ident, scope_nid: str, enclosing_locals, context: str) -> None:
"""A function referenced BY NAME — passed as a call argument, or listed as a
value in a dispatch table is an indirect dependency of ``scope_nid``. Emit
it as a distinct INFERRED ``indirect_call`` (kept out of the precise ``calls``
relation) only when the name resolves to a real callable and is NOT shadowed
by a parameter / local binding. A callback defined in another file is deferred
to the cross-file resolver via an ``indirect`` raw_call carrying its context.
Language-agnostic; shared by the call-argument and dispatch-table capture
paths for Python and JS/TS (#1565, #1566).
def _emit_indirect_by_name(ident_name: str, loc_node, scope_nid: str,
context: str) -> None:
"""Resolve a name that is referenced AS A VALUE to a real callable def and emit
one INFERRED ``indirect_call`` edge deferring an unknown / foreign name to the
cross-file resolver, which applies the single-definition god-node guard and the
GLOBAL callable-target check. The name is already extracted; scope filtering is
the CALLER's job: an identifier reference must reject param/local shadows (a bare
name IS a binding see ``_emit_indirect_ref``), whereas a ``getattr(obj, "x")``
string names an ATTRIBUTE and is never shadowed by a local, so that path passes
the name straight through. ``loc_node`` supplies the source line.
"""
if ident is None or ident.type not in ("identifier", "shorthand_property_identifier"):
return
ident_name = _read_text(ident, source)
# shadowing: a param / local binding names a local value, not the module fn
if ident_name in enclosing_locals or ident_name in ("self", "cls"):
return
ref_nid = label_to_nid.get(ident_name)
# Defer to the cross-file resolver when the name is not defined in this file
# (`from .h import fn`), or resolves to an import-surfaced FOREIGN symbol whose
@@ -4361,7 +4356,7 @@ def _extract_generic(
"indirect": True,
"context": context,
"source_file": str_path,
"source_location": f"L{ident.start_point[0] + 1}",
"source_location": f"L{loc_node.start_point[0] + 1}",
})
return
if ref_nid == scope_nid or ref_nid not in callable_def_nids:
@@ -4378,10 +4373,28 @@ def _extract_generic(
"context": context,
"confidence": "INFERRED",
"source_file": str_path,
"source_location": f"L{ident.start_point[0] + 1}",
"source_location": f"L{loc_node.start_point[0] + 1}",
"weight": 1.0,
})
def _emit_indirect_ref(ident, scope_nid: str, enclosing_locals, context: str) -> None:
"""A function referenced BY NAME — passed as a call argument, or listed as a
value in a dispatch table is an indirect dependency of ``scope_nid``. Emit
it as a distinct INFERRED ``indirect_call`` (kept out of the precise ``calls``
relation) only when the name resolves to a real callable and is NOT shadowed
by a parameter / local binding. A callback defined in another file is deferred
to the cross-file resolver via an ``indirect`` raw_call carrying its context.
Language-agnostic; shared by the call-argument and dispatch-table capture
paths for Python and JS/TS (#1565, #1566).
"""
if ident is None or ident.type not in ("identifier", "shorthand_property_identifier"):
return
ident_name = _read_text(ident, source)
# shadowing: a param / local binding names a local value, not the module fn
if ident_name in enclosing_locals or ident_name in ("self", "cls"):
return
_emit_indirect_by_name(ident_name, ident, scope_nid, context)
def _python_dispatch_value_idents(coll_node):
"""Yield the identifier value-nodes of a dict/list/set/tuple literal that are
function-reference candidates: dict VALUES (never keys), and the elements of a
@@ -4411,6 +4424,37 @@ def _extract_generic(
if ch.type == "identifier":
yield ch
def _getattr_ref_name(call_node):
"""If ``call_node`` is a builtin ``getattr(obj, "name"[, default])`` whose name
argument is a PLAIN string literal, return ``(name, string_node)``: the string
names an attribute looked up by that exact name, so it resolves to a callable
def of the same label. A dynamic name a variable, an f-string, a concatenation,
any expression is not statically resolvable and yields ``None`` (no edge is
manufactured), as do the 1-arg form and ``obj.getattr(...)`` (a method, not the
builtin). Unlike an identifier, a string is an attribute name and is never
shadowed by a param/local, so callers resolve it without the shadow guard.
"""
fn = call_node.child_by_field_name("function")
if fn is None or fn.type != "identifier" or _read_text(fn, source) != "getattr":
return None
args = call_node.child_by_field_name("arguments")
if args is None:
return None
positional = [c for c in args.children
if c.is_named and c.type not in ("keyword_argument", "comment")]
if len(positional) < 2:
return None
name_node = positional[1]
if name_node.type != "string" or any(
ch.type == "interpolation" for ch in name_node.children
):
return None # variable, f-string, concatenation, or expression — dynamic
content = next(
(ch for ch in name_node.children if ch.type == "string_content"), None)
if content is None:
return None # empty string "" — no attribute name
return _read_text(content, source), name_node
def _php_class_const_scope(n) -> str | None:
scope = n.child_by_field_name("scope")
if scope is None:
@@ -4689,6 +4733,15 @@ def _extract_generic(
_emit_indirect_ref(
arg.child_by_field_name("value"),
caller_nid, enclosing_locals, "argument")
# Reflective dispatch: getattr(obj, "handler") names a callable by
# string literal (#1566 slice 3). The string is an ATTRIBUTE name, not
# an identifier binding, so it is never shadowed by a param/local — it
# resolves straight to the callable, bypassing the identifier shadow
# guard. A dynamic name (getattr(obj, name)) is unresolvable → no edge.
getattr_ref = _getattr_ref_name(node)
if getattr_ref is not None:
ref_name, loc = getattr_ref
_emit_indirect_by_name(ref_name, loc, caller_nid, "getattr")
elif config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"):
# JS/TS: a callback passed by name (`arr.map(fn)`, `setTimeout(fn)`,
# `el.addEventListener("x", fn)`). Positional identifier args only —
@@ -4922,6 +4975,13 @@ def _extract_generic(
# 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")
elif n.type == "call":
# Module-level reflective dispatch: HANDLER = getattr(mod, "handler")
# (#1566 slice 3). Attributed to the file node, like a module table.
getattr_ref = _getattr_ref_name(n)
if getattr_ref is not None:
ref_name, loc = getattr_ref
_emit_indirect_by_name(ref_name, loc, file_nid, "getattr")
for c in n.children:
_scan_module_dispatch(c)
+153
View File
@@ -0,0 +1,153 @@
"""Reflective dispatch via getattr string literals — #1566 slice 3.
``getattr(obj, "handler")`` names a callable by a string literal; the attribute is
looked up by that exact name, so it resolves to a callable def of the same label and is
emitted under ``indirect_call`` (context ``"getattr"``, INFERRED). Only a PLAIN string
literal is resolvable — a variable, f-string, concatenation, or any expression is dynamic
and emits nothing.
The scope rule is the INVERSE of the identifier paths (#1565 args, #1566 assignment /
return): a string is an attribute name, never shadowed by a param/local, so a getattr
whose name collides with a same-named parameter STILL emits. ``test_..._not_shadowed_by_param``
pins that — reusing the identifier shadow guard here would be a false NEGATIVE.
"""
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"}
BASIC = '''\
def handler(): ...
def other(): ...
def dispatch(obj):
fn = getattr(obj, "handler") # string-literal attribute name
return fn()
'''
def test_getattr_string_literal_emits_indirect_call(tmp_path):
r, nid = _extract(tmp_path, BASIC)
assert (nid["dispatch"], nid["handler"]) in _ind(r)
calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
assert (nid["dispatch"], nid["handler"]) not in calls # not in the precise relation
for e in r["edges"]:
if e["relation"] == "indirect_call" and e["target"] == nid["handler"]:
assert e["context"] == "getattr" and e["confidence"] == "INFERRED"
DEFAULT_ARG = '''\
def handler(): ...
def dispatch(obj):
return getattr(obj, "handler", None)() # 3-arg form, called inline
'''
def test_getattr_with_default_emits(tmp_path):
r, nid = _extract(tmp_path, DEFAULT_ARG)
assert (nid["dispatch"], nid["handler"]) in _ind(r)
MODULE_LEVEL = '''\
import sys
def handler(): ...
HANDLER = getattr(sys.modules[__name__], "handler") # module-level reflective alias
'''
def test_module_level_getattr_emits(tmp_path):
r, nid = _extract(tmp_path, MODULE_LEVEL)
assert (nid["m.py"], nid["handler"]) in _ind(r)
def test_getattr_feeds_affected(tmp_path):
r, nid = _extract(tmp_path, BASIC)
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["dispatch"] in affected
# ── the scope rule: a string is an attribute name, NOT shadowed by a local ──
PARAM_COLLISION = '''\
def handler(): ...
def via(handler): # param `handler` shadows the IDENTIFIER
return getattr(handler, "handler") # but the STRING "handler" -> module fn regardless
'''
def test_getattr_string_not_shadowed_by_param(tmp_path):
# The identifier arg `handler` is correctly skipped (it is the shadowing param), but
# the string "handler" names an attribute and must still resolve to the module fn.
# Applying the identifier shadow guard to the string would be a false NEGATIVE.
r, nid = _extract(tmp_path, PARAM_COLLISION)
got = [e for e in r["edges"]
if e["relation"] == "indirect_call"
and (e["source"], e["target"]) == (nid["via"], nid["handler"])]
assert got and all(e["context"] == "getattr" for e in got)
# ── negatives: a dynamic name is unresolvable → no edge ──
DYNAMIC = '''\
def handler(): ...
def via(obj, name, evt):
a = getattr(obj, name) # variable name -- unresolvable
b = getattr(obj, f"on_{evt}") # f-string -- dynamic
c = getattr(obj, "on_" + evt) # concatenation -- dynamic
return a, b, c
'''
def test_dynamic_getattr_names_emit_nothing(tmp_path):
r, nid = _extract(tmp_path, DYNAMIC)
assert all(s != nid["via"] for s, _t in _ind(r))
NON_CALLABLE = '''\
TIMEOUT = 30
def via(obj):
return getattr(obj, "TIMEOUT") # resolves to a data name, not a callable
'''
def test_getattr_non_callable_name_emits_nothing(tmp_path):
r, _nid = _extract(tmp_path, NON_CALLABLE)
assert _ind(r) == set()
METHOD_NOT_BUILTIN = '''\
def handler(): ...
class Registry:
def getattr(self, name): ... # a METHOD named getattr, not the builtin
def via(reg):
return reg.getattr("handler") # reg.getattr(...) is the method, not the builtin
'''
def test_method_named_getattr_is_not_the_builtin(tmp_path):
r, nid = _extract(tmp_path, METHOD_NOT_BUILTIN)
assert all(t != nid["handler"] for _s, t in _ind(r))