Skip to content

indirect_call callable guard admits classes → false edges for select(Model), except tuples, and getattr literals #2137

Description

@marciolprado

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 called
def get_article_analysis(cls, db: Session, article_id: int, ...):
    article = db.get(KbArticle, article_id)   # nor here

Both 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 exc

The 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.8 is 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 by confidence_score is misled.
  • The relation name overstates the claim. indirect_call reads 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 like references_by_name would 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:

  1. Split the marker. Distinguish _callable_function from _callable_class when building callable_nids, and gate the argument/collection paths on function targets only. Class-as-callback is real but much rarer than class-as-descriptor.
  2. 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 as queries.
  3. Drop the getattr path, or require a non-literal. Resolving a string literal against the global symbol table produces edges with no basis. getattr with a literal name should resolve within the receiver's own type or not at all.
  4. Exclude exception classes in except handlers — the collection context inside an except clause is isinstance matching, 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions