Skip to content

fix(extract): exclude class refs from indirect_call edges (#2137)#2142

Open
Rishet11 wants to merge 2 commits into
Graphify-Labs:v8from
Rishet11:fix/2137-class-indirect-call
Open

fix(extract): exclude class refs from indirect_call edges (#2137)#2142
Rishet11 wants to merge 2 commits into
Graphify-Labs:v8from
Rishet11:fix/2137-class-indirect-call

Conversation

@Rishet11

Copy link
Copy Markdown

Fixes #2137.

Problem

indirect_call detection treats any callable-def target identically, but classes are callable only via their constructor and are frequently referenced as descriptive values, not invoked. The guard therefore emitted false edges for:

  • ORM args: select(KbArticle), db.get(KbArticle, id)
  • Exception tuples: except (ErrorA, ErrorB) as exc:
  • String-literal getattr resolving to a same-named class: getattr(run, "KbArticle", 0)

In the reported sample, ~287/689 (41%) of indirect_call edges targeted classes, inflating node centrality and creating misleading traversals.

Fix

One root cause covers all three symptoms: distinguish class defs from function defs.

  • Add a callable_class_nids set parallel to the existing callable_def_nids.
  • Mark class-def nodes with a _callable_class attribute (stripped before output, like _callable).
  • Exclude class targets from indirect_call emission in both paths: intra-file _emit_indirect_by_name and the cross-file resolver guard.

Both class-node creation sites register into the new set, so coverage is language-agnostic:

  • the generic config.class_types branch (Python/JS/TS/Java/C#/Swift/…)
  • the Ruby Struct.new / Class.new / Data.define synthesis

Tradeoff (intentional)

Suppression is context-blind: a genuine higher-order class callback that is invoked per element (e.g. map(Point, coords), sorted(items, key=SomeClass)) also loses its indirect_call edge. This false-negative is far rarer than the false-positive noise removed and matches the issue's framing ("classes are passed as descriptors, not invoked"). Flagging it so this isn't mistaken for a zero-tradeoff fix.

Verification

  • New regression test test_class_ref_is_not_indirect_call covers all three contexts (except-tuple, getattr, ORM arg) plus a real function callback that must still emit.
  • Before/after on identical input (via git stash): 4 class-targeted indirect_call edges → 0, function callbacks preserved.
  • tests/test_indirect_dispatch* + tests/test_extract.py: all pass; no regressions in the surrounding suites.

…abs#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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reduces false-positive indirect_call edges by distinguishing class definitions from function/method definitions during extraction, and suppressing indirect_call edges that would otherwise target classes.

Changes:

  • Track class-definition node IDs separately (callable_class_nids) and tag class nodes with _callable_class in the extractor engine.
  • Suppress indirect_call emission when the resolved target is a class in both the intra-file resolver (_emit_indirect_by_name) and the cross-file resolver guard in extract.py.
  • Add a regression test asserting class references in ORM args / exception tuples / getattr-literals do not become indirect_call targets, while a real function callback still does.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
tests/test_indirect_dispatch.py Adds a regression test for suppressing class-target indirect_call edges while preserving real function-callback edges.
graphify/extractors/engine.py Introduces callable_class_nids and marks class defs with _callable_class; suppresses intra-file indirect edges to classes.
graphify/extract.py Excludes _callable_class targets from cross-file indirect_call emission and strips _callable_class before output.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_indirect_dispatch.py
…raphify-Labs#2137)

Copilot flagged that the Graphify-Labs#2137 regression test only exercised the
intra-file suppression path; a regression in the cross-file resolver
guard in extract.py would still pass. Add a cross-file test: class and
function imported from another module, asserting the imported class is
never an indirect_call target while a genuine imported callback still
emits its edge.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants