fix(graph): treat class instantiation as a caller/callee edge (#774) (#804)

`callers <Class>` returned "No callers found" (or only the importing
file) even when a class's constructor was called from many sites, and
the instantiation sites were invisible — the opposite of what "what
breaks if I change this class?" should answer.

The `instantiates` edges already existed in the graph, correctly
attributed to the constructing function; they were simply excluded from
the caller/callee traversal, which queried only calls/references/imports.
Constructing a class is calling its constructor, so add `instantiates`
to the edge-kind set in both getCallers and getCallees (kept symmetric so
they stay inverses and `trace` can cross the instantiation boundary,
function -> class -> its methods). impact already traversed all edge
kinds, so it was unaffected.

Query-layer only — existing indexes benefit on upgrade with no re-index.
Verified on a Python fixture: `callers Supervisor` now returns the
construction sites (main/work/test_it), and a new graph test asserts
main() <-> DerivedClass via the instantiation. Full suite green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-11 11:25:13 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 9a0f144770
commit d0e649969a
3 changed files with 31 additions and 2 deletions
+11 -2
View File
@@ -248,7 +248,12 @@ export class GraphTraverser {
}
visited.add(nodeId);
const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports']);
// `instantiates` counts as a caller: constructing a class (`Foo(...)` /
// `new Foo()`) is calling its constructor, so the instantiation site is a
// caller of the class. Without it, `callers <Class>` surfaced only the
// importing file (via `imports`) and missed every construction site —
// the opposite of "what breaks if I change this class?" (#774).
const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates']);
if (incomingEdges.length === 0) return;
// Batch-fetch all caller nodes in one round-trip instead of one
@@ -293,7 +298,11 @@ export class GraphTraverser {
}
visited.add(nodeId);
const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports']);
// Symmetric with getCallers: a function that constructs a class
// (`Foo(...)` / `new Foo()`) has that class as a callee, so callers and
// callees stay inverses of each other and `trace` can cross the
// instantiation boundary (function → class → its methods) (#774).
const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates']);
if (outgoingEdges.length === 0) return;
// Batch-fetch callee nodes (was N+1 — see getCallersRecursive note).