From 8fdbf501972dc34b0d097b096b9bf87d2938e460 Mon Sep 17 00:00:00 2001 From: Sheik Ershad Date: Wed, 1 Jul 2026 10:28:18 +0400 Subject: [PATCH] feat(extract): capture getattr(obj, "name") indirect_call edges (#1566 slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- graphify/extract.py | 94 ++++++++++++--- tests/test_indirect_dispatch_getattr.py | 153 ++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 17 deletions(-) create mode 100644 tests/test_indirect_dispatch_getattr.py diff --git a/graphify/extract.py b/graphify/extract.py index 1c30d377..2c7b26f9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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) diff --git a/tests/test_indirect_dispatch_getattr.py b/tests/test_indirect_dispatch_getattr.py new file mode 100644 index 00000000..60deacf1 --- /dev/null +++ b/tests/test_indirect_dispatch_getattr.py @@ -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))