mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 19:07:10 +00:00
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:
+77
-17
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user