From f6742bb6f00879063ef4e7b0731fea7e5839cd7c Mon Sep 17 00:00:00 2001 From: Rishet Mehra Date: Fri, 24 Jul 2026 00:00:46 +0530 Subject: [PATCH] fix(extract): exclude class refs from indirect_call edges (#2137) Classes are callable via their constructor but are frequently referenced as descriptive values, not invoked: ORM args (select(Model), db.get(Model, id)), exception tuples (except (ErrorA, ErrorB)), and string-literal getattr resolving to a same-named class. The indirect_call guard treated any callable-def target identically, so these produced false edges (~41% of indirect_call edges in the reported sample targeted classes), inflating centrality and traversals. Track class defs in a callable_class_nids set parallel to callable_def_nids, mark class nodes with a _callable_class attribute, and exclude class targets from indirect_call emission in both the intra-file (_emit_indirect_by_name) and cross-file resolver paths. Marker is stripped before output like _callable. Covers all languages: both class-node creation sites (the generic config.class_types branch and the Ruby Struct.new/Class.new/Data.define synthesis) register into the new set. Tradeoff: suppression is context-blind, so a genuine higher-order class callback (e.g. map(Point, coords)) also loses its indirect_call edge. This is far rarer than the false-positive noise removed and matches the issue framing. Verified before/after on the same input: 4 class-targeted indirect_call edges -> 0, function callbacks preserved. --- graphify/extract.py | 7 +++++- graphify/extractors/engine.py | 19 ++++++++++++++-- tests/test_indirect_dispatch.py | 39 +++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index e5a48dc0..e49c22d8 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4967,6 +4967,10 @@ def extract( # function/method/class, never a same-named data symbol, and the guard never goes # stale when node ids were relativized/disambiguated above (#1566). callable_nids = {n["id"] for n in all_nodes if n.get("_callable")} + # Class defs are callable only via their constructor; they are frequently passed + # as descriptive values (`select(Model)`, exception tuples), not invoked. Exclude + # them from the indirect_call guard below to avoid false edges (#2137). + class_nids = {n["id"] for n in all_nodes if n.get("_callable_class")} # Build evidence index from import edges so cross-file calls backed by an # explicit import statement can be promoted from INFERRED to EXTRACTED. @@ -5145,7 +5149,7 @@ def extract( # evidence: the name is referenced as a value here, not invoked. Dedup # is call-aware (an existing direct `calls` edge pre-empts it; a benign # `imports` edge to the same symbol does NOT suppress it). - if tgt != caller and (caller, tgt) not in call_like_pairs and tgt in callable_nids: + if tgt != caller and (caller, tgt) not in call_like_pairs and tgt in callable_nids and tgt not in class_nids: call_like_pairs.add((caller, tgt)) all_edges.append({ "source": caller, @@ -5264,6 +5268,7 @@ def extract( for n in all_nodes: n.pop("origin_file", None) n.pop("_callable", None) # internal indirect_call marker — never ships to graph.json + n.pop("_callable_class", None) # internal #2137 marker — never ships to graph.json # local_alias is a transient import-resolution hint (#2082), same shape as # target_file (#1814): it exists only so the module arm of diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 723606e6..361aa581 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2085,7 +2085,7 @@ _RUBY_CLASS_FACTORIES = frozenset({("Struct", "new"), ("Class", "new"), ("Data", def _ruby_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node, add_edge, walk, - callable_def_nids: set) -> bool: + callable_def_nids: set, callable_class_nids: set) -> bool: """Ruby: a constant assignment whose RHS is ``Struct.new(...)``, ``Class.new(Super)`` or ``Data.define(...)`` defines a class named after the constant (#1640). Synthesize the class node, attach block-defined methods via @@ -2112,6 +2112,7 @@ def _ruby_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: st class_nid = _make_id(stem, const_name) add_node(class_nid, const_name, line) callable_def_nids.add(class_nid) # a class is callable (its constructor) + callable_class_nids.add(class_nid) # ...but only via its constructor (#2137) # Mirror the generic class branch: containment always hangs off the file node. add_edge(file_nid, class_nid, "contains", line) @@ -2208,6 +2209,11 @@ def _extract_generic( # when it names one of these callable defs — never an arbitrary same-named # node — so `process(config)` can't manufacture an edge to a non-callable. callable_def_nids: set[str] = set() + # Subset of callable_def_nids that are CLASS defs (callable only via their + # constructor). Classes are frequently passed as descriptive values, not for + # invocation (`select(Model)`, exception tuples), so the cross-file indirect_call + # guard excludes them to avoid false edges (#2137). + callable_class_nids: set[str] = set() # Python only: per-function set of locally-bound names (params + local # assignment / for / with-as / comprehension targets). The indirect-dispatch # guard skips any call-argument identifier in the enclosing function's set, @@ -2371,6 +2377,7 @@ def _extract_generic( metadata = {"is_nested_type": True} add_node(class_nid, class_name, line, metadata=metadata) callable_def_nids.add(class_nid) # a class is callable (constructor) + callable_class_nids.add(class_nid) # ...but only via its constructor (#2137) # A nested class/object/trait is contained by its ENCLOSING type, not # the file (#2040). parent_class_nid is threaded down the walk for # every language and is always a real class-like node (never a @@ -3561,7 +3568,7 @@ def _extract_generic( if _ruby_extra_walk(node, source, file_nid, stem, str_path, nodes, edges, seen_ids, function_bodies, parent_class_nid, add_node, add_edge, walk, - callable_def_nids): + callable_def_nids, callable_class_nids): return # Python's `@property` / `@staticmethod` / `@classmethod` wrap the @@ -3654,6 +3661,10 @@ def _extract_generic( return if ref_nid == scope_nid or ref_nid not in callable_def_nids: return # self-ref, or a same-named LOCAL non-callable data node — no edge + if ref_nid in callable_class_nids: + # A class referenced as a value (`select(Model)`, `db.get(Model, id)`, + # an exception tuple) is a descriptor, not an invocation — no edge (#2137). + return if (scope_nid, ref_nid) in seen_call_pairs: return # already a direct call to this target if (scope_nid, ref_nid) in seen_indirect_pairs: @@ -4440,6 +4451,10 @@ def _extract_generic( for n in nodes: if n["id"] in callable_def_nids: n["_callable"] = True + if n["id"] in callable_class_nids: + # Class def: callable only via constructor. The indirect_call + # guard excludes these to avoid false edges (#2137). + n["_callable_class"] = True if swift_extensions: result["swift_extensions"] = swift_extensions # TS/JS: augment the constructor-injection type table with local `new` diff --git a/tests/test_indirect_dispatch.py b/tests/test_indirect_dispatch.py index ee3d36bb..b96eefd3 100644 --- a/tests/test_indirect_dispatch.py +++ b/tests/test_indirect_dispatch.py @@ -504,3 +504,42 @@ def test_typescript_typed_params_and_arrow_consts(tmp_path): assert (nid["via"], nid["handler"]) in indirect assert (file_nid, nid["handler"]) in indirect assert (file_nid, nid["cb"]) in indirect + + +def test_class_ref_is_not_indirect_call(tmp_path): + """A class referenced BY NAME as a value is a descriptor, not an invocation, so it + must NOT emit an indirect_call edge — across all three symptom families of #2137: + ORM args (`select(Model)`, `db.get(Model, id)`), exception tuples + (`except (ErrorA, ErrorB)`), and string-literal getattr resolving to a same-named + class. A real function callback in the same file still emits its edge.""" + src = ( + "class ErrorA(Exception):\n pass\n" + "class ErrorB(Exception):\n pass\n" + "class KbArticle:\n pass\n" + "\n" + "def handler(x):\n return x\n" + "\n" + "def use_except():\n" + " try:\n pass\n" + " except (ErrorA, ErrorB) as e: # exception tuple -> classes, not calls\n" + " return e\n" + "\n" + "def use_getattr(run):\n" + ' return getattr(run, "KbArticle", 0) # literal resolving to a class\n' + "\n" + "def use_orm(db, i):\n" + " db.get(KbArticle, i) # class as descriptor\n" + " return select(KbArticle) # class as descriptor\n" + "\n" + "def register(pool):\n" + " pool.submit(handler) # real fn callback -> indirect_call\n" + ) + (tmp_path / "orm.py").write_text(src) + r = extract_python(tmp_path / "orm.py") + nid = {n["label"].rstrip("()"): n["id"] for n in r["nodes"]} + indirect = _rels(r, "indirect_call") + class_ids = {nid["ErrorA"], nid["ErrorB"], nid["KbArticle"]} + # no class is ever an indirect_call target (except-tuple, getattr, or ORM arg) + assert not any(t in class_ids for _, t in indirect) + # genuine function callback preserved + assert (nid["register"], nid["handler"]) in indirect