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.
This commit is contained in:
Rishet Mehra
2026-07-24 23:37:14 +01:00
committed by safishamsi
parent 2fa6cd3d55
commit f6742bb6f0
3 changed files with 62 additions and 3 deletions
+39
View File
@@ -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