Repository navigation
indirect_call callable guard admits classes → false edges for select(Model), except tuples, and getattr literals #2137
Description
Version: graphify 0.9.25 (uv tool install), Python 3.11, macOS
Summary
The indirect_call callable guard admits any node marked _callable, which includes class definitions. In Python a class is callable (Foo() constructs), so the guard passes for every class — but the overwhelmingly common way a class name appears as an argument is as a descriptor/metadata value that is never invoked: select(Model), db.get(Model, id), except (ErrA, ErrB).
The result is a large volume of INFERRED indirect_call edges asserting a call relationship that does not exist.
The code comment already states the correct semantics:
# ... Stays INFERRED even with import evidence: the name is referenced as a value here, not invoked.
The intent is genuine indirect dispatch — a callback passed by name that will be invoked later (pool.submit(fn), dispatch tables). That intent is right; the callable check just can't separate "passed to be called later" from "passed as a value that is never called."
Where
graphify/extract.py
# L4969 — callable_nids includes classes, not just functions
callable_nids = {n["id"] for n in all_nodes if n.get("_callable")}
# L5148 — the guard
if tgt != caller and (caller, tgt) not in call_like_pairs and tgt in callable_nids:
all_edges.append({..., "relation": "indirect_call", "confidence_score": 0.8, ...})Three false-positive shapes
Observed on a ~5,500-node Python/TypeScript codebase. All three contexts (argument, collection, getattr) produce them.
1. context="argument" — ORM model classes (highest volume)
def list_articles(db: Session, ...):
query = select(KbArticle) # KbArticle is never calleddef get_article_analysis(cls, db: Session, article_id: int, ...):
article = db.get(KbArticle, article_id) # nor hereBoth emit indirect_call → KbArticle. KbArticle is a SQLAlchemy declarative model; select()/db.get()/db.query() take the class as a table descriptor. It is never invoked at these sites.
That repo has 602 occurrences of select(X / db.get(X / db.query(X with a capitalized X. Any ORM-heavy project (SQLAlchemy, Django, SQLModel, Peewee) hits this on essentially every query.
2. context="collection" — exception classes in except tuples
except (KbOrphanPurgeNotConfirmedError, KbOrphanPurgeNotInTrashError, KbOrphanPurgeSourceEmptyError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from excThe handler emits indirect_call to each exception class. These are matched by isinstance, never called by this function.
3. context="getattr" — string literal bound to an unrelated same-named symbol
This one is the most clearly wrong, and is not really about classes at all:
# kb_sync_run_service.py — `run` is a KbSyncRun ORM instance
"product_count": int(getattr(run, "product_count", 0) or 0),emits indirect_call → _ScopeSelection.product_count(), a @property on a different class in a different file (kb_sync_service.py:120). The string literal "product_count" was resolved by name against the global symbol table. There is no relationship between these two symbols.
Impact
Of 689 indirect_call edges in that graph, 287 (41%) target a class (node has method or inherits out-edges). Not all 287 are wrong — passing a class as a factory callback is legitimate indirect dispatch — but the ORM and exception-tuple shapes above are, and they dominate.
Two knock-on effects:
confidence_score: 0.8is not discriminating. It is a constant attached to a deterministic AST pattern, so it reads as a graded confidence but carries no per-edge signal. Anything weighting traversals byconfidence_scoreis misled.- The relation name overstates the claim.
indirect_callreads as "A transitively calls B." For all three shapes above, B is named but never invoked — which is exactly what the code comment says. Something likereferences_by_namewould match the comment's own wording and stay honest for the legitimate dispatch case too, where "will be called later" is still an inference rather than an observed call.
Suggested fix
Roughly in order of value:
- Split the marker. Distinguish
_callable_functionfrom_callable_classwhen buildingcallable_nids, and gate theargument/collectionpaths on function targets only. Class-as-callback is real but much rarer than class-as-descriptor. - Suppress known descriptor sinks. When the enclosing call is a query/ORM builder (
select,query,get,delete,update,insert,aliased,joinedload,selectinload), skip the edge — or emit it under a distinct relation such asqueries. - Drop the
getattrpath, or require a non-literal. Resolving a string literal against the global symbol table produces edges with no basis.getattrwith a literal name should resolve within the receiver's own type or not at all. - Exclude exception classes in
excepthandlers — thecollectioncontext inside anexceptclause isisinstancematching, not dispatch.
Happy to test a patch against the same corpus if useful.
Found while auditing a graph built with /graphify — the node in question showed as a top god node, and the inflated degree traced back to this rule.
Activity
Rishet11 commentedon Jul 23, 2026
Opened #2142 with a fix.
Root-caused it to the callable guard not distinguishing class defs from function defs — one fix (
callable_class_nidsparallel tocallable_def_nids, class targets excluded fromindirect_callin both the intra-file and cross-file paths) covers all three symptoms you listed:except (ErrorA, ErrorB)collectiongetattr(run, "KbArticle", 0)getattrselect(Model)/db.get(Model, id)argumentVerified before/after on the same input: 4 class-targeted
indirect_calledges → 0, function callbacks preserved. This makes the separate except-clause / getattr proposals unnecessary.One intentional tradeoff noted in the PR: the suppression is context-blind, so a genuine higher-order class callback that is invoked (e.g.
map(Point, coords)) also loses its edge. Rare, and consistent with the "descriptor, not invocation" framing — flagging it so it's a conscious decision.test(extract): cover cross-file class-ref indirect_call suppression (G…
fix(extract): exclude class refs from indirect_call edges (#2137)
test(extract): cover cross-file class-ref indirect_call suppression (#…
chore: bump to 0.9.26; changelog for #2137/#2126/#2148/#2141/#2079/#2163
safishamsi commentedon Jul 24, 2026
Fixed in v0.9.26 (#2142, thanks @Rishet11). The callable guard admitted classes, so a class passed as a value (
select(Model),db.get(Model, id),except (A, B),getattr(obj, "Name", 0)) produced a falseindirect_calledge. Classes are now excluded fromindirect_callin both the intra-file and cross-file paths; direct instantiation still emits itscallsedge. https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.26Squashed commit of the following:
fix(extract): exclude class refs from indirect_call edges (Graphify-L…
test(extract): cover cross-file class-ref indirect_call suppression (G…
chore: bump to 0.9.26; changelog for Graphify-Labs#2137/Graphify-Labs…
merge: backport upstream JS/TS indirect_call shadow fixes (Graphify-L…
Mark ObjC declarations with _callable / _callable_class (Graphify-Lab…
Mark ObjC declarations with _callable / _callable_class (Graphify-Lab…
Mark ObjC declarations with _callable / _callable_class (Graphify-Lab…
Mark ObjC declarations with _callable / _callable_class (Graphify-Lab…
Mark ObjC declarations with _callable / _callable_class (Graphify-Lab…
Mark ObjC declarations with _callable / _callable_class (#3228)