Repository: colbymchenry/codegraph (2026-06-11T16:23:52Z)

Issue:
Python: class instantiation ClassName() is not a caller edge; impact <Class> returns containment, not dependents
## Summary

In Python, **instantiating a class is not recorded as a caller edge**. `callers <ClassName>` returns "No callers found" even when the constructor is called from many sites, and `impact <ClassName>` returns the class's **own members (containment)** rather than the code that **depends on** it. For a class, that's the opposite of what "what breaks if I change this?" should answer.

This is a real limitation on a *freshly rebuilt* index (verified after `index --force`), so it's distinct from the silent edge-corruption I filed separately — this one is a modeling gap, not data loss.

## Environment

- Package: `@colbymchenry/codegraph`
- Version: **0.9.5** (CLI + daemon). Latest on npm is 0.9.9 — not yet re-verified there.
- OS: macOS (Darwin), Apple Silicon. Index freshly rebuilt with `index --force` immediately before these queries.

## Reproduction

Class `Supervisor` defined in `mira-bots/shared/engine.py`, instantiated as `Supervisor(...)` from app entry points and tests.

```
$ codegraph index --force        # fresh index
$ codegraph callers Supervisor
ℹ No callers found               # ACTUAL

# but the constructor is called from 26 sites across 21 files, e.g.:
#   mira-bots/ask_api/app.py:42:   engine = Supervisor(
#   mira-bots/tests/test_engine.py:38:  sup = Supervisor(
#   mira-bots/tests/test_decision_trace.py:151:  sup = Supervisor(
```

And `impact`:

```
$ codegraph impact Supervisor
Impact of changing "Supervisor" — 409 affected symbols:
mira-bots/shared/engine.py
  class   Supervisor:699
  method  __init__:702
  method  ...            # <-- these are Supervisor's OWN methods (containment),
  ...                    #     not the call sites that would break.
```

## Expected vs actual

| Query | Expected | Actual (v0.9.5) |
|---|---|---|
| `callers Supervisor` | the ~26 `Supervisor(...)` instantiation sites | "No callers found" |
| `impact Supervisor` | dependents (instantiators + downstream) | the class's own member methods (containment) |

## Why it matters

The canonical use of `impact`/`callers` on a class is "what will break if I change this class's constructor or contract?" Today, for a Python class, the answer silently excludes every instantiation site, so a class-level change looks safe when it isn't. (`grep 'ClassName('` is currently the only reliable answer for instantiation sites.)

## Suggestion

1. Treat a call expression whose callee resolves to a class name as a **caller edge into the class (constructor)** — so `callers <Class>` returns instantiation sites, and `impact <Class>` includes dependents.
2. For `impact <Class>`, prefer **dependents** (callers of the constructor + things that reference the class) over **containment** (its own members), or at minimum label the two so users can tell them apart.

TypeScript with unique class/function names didn't exhibit this in our testing; the gap was specifically Python class instantiation. Thanks!



Discussion:
Filed alongside #773 — the silent FK-drop edge corruption (different root cause).
Fixed in #804 (now merged to `main`), @Mikecranesync — thanks for the precise report and the modeling suggestion, which is essentially what landed.

You were right that this is a modeling gap, not data loss. What was actually happening: the `instantiates` edges already existed in the graph and were correctly attributed to the constructing function (e.g. `main → Supervisor` at the `Supervisor(...)` line) — `impact` already traversed them, which is why `impact <Class>` showed dependents. But `callers` / `callees` queried only `calls` / `references` / `imports` edges and **excluded `instantiates`**, so `callers <Class>` surfaced only the importing file (or nothing) and missed every construction site.

The fix adds `instantiates` to the caller/callee traversal, so:
- `callers Supervisor` now returns the construction sites (the enclosing `main` / `work` / `test_it` functions), exactly as you laid out in your suggestion #1.
- `codegraph_callees` and `trace` cross the instantiation the same way (function → class → its methods).

Because this is a query-layer change — the edges were always indexed — **it works on your existing index with no re-index needed** once you're on the next release.

This was still present on the latest `main`, so it's a genuine fix (unlike your sibling report #773, which had already been fixed back in 0.9.6). It'll go out in the next release; please give it a try then and reopen if `callers` on a class still comes back short. Thanks again.




Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f790c7c3..4aca757b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -58,6 +58,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - `codegraph affected` now reports the tests and files that actually depend on your changes. It used to follow only `import` statements — but those never cross file boundaries in CodeGraph's graph — so it returned **no affected tests for any change, in every language**. It now traces the real cross-file usage graph (calls, references, instantiations, and class `extends` / `implements`), so `git diff --name-only | codegraph affected` surfaces the test files that exercise the changed code. Circular-dependency detection, which had the same blind spot, now works too.
 - Blast radius, callers, and `codegraph affected` now recognize far more of the dependencies that were already in your code. A symbol now counts as a dependency whether it's called, used only in a type annotation inside a function body (`const items: Foo[] = []`), imported and placed in a registry array or passed as an argument, used as a JSX component, simply re-exported from a barrel (`export { X } from './x'`), or pulled in as a namespace (`import * as ns from '@/x'`) — including through tsconfig path aliases like `@/`. Previously only called, instantiated, or signature-typed symbols created a cross-file link, so a file that used a dependency in any other way could look like it depended on nothing — and the file that defined a widely-used symbol could look like nothing depended on it. The graph still indexes exactly the same symbols; it just connects the ones that were already there. (TypeScript/JavaScript)
 - The same completeness fix now applies to **Python**: a name brought in with `from module import X` is recorded as a dependency on that module even when `X` is only stored in a list/dict, passed as an argument, used as a decorator, or re-exported through an `__init__.py`. Previously Python linked only imports that were called or instantiated, so a module consumed purely by value — or only re-exported — looked like nothing depended on it.
+- `codegraph_callers` (and the `callers` command) now lists the places a class is **instantiated**, not just where it's imported. Constructing a class — `Foo(...)` in Python, `new Foo()` elsewhere — is calling its constructor, so asking who calls a class now returns the construction sites, and `codegraph_callees` / trace cross the instantiation the same way. Previously a class's instantiation sites were invisible to `callers`, so "what breaks if I change this class?" could come back empty even when the constructor was called from many places. Works on your existing index — no re-index needed. (#774)
 - Rust impact and `codegraph affected` now connect far more of the module graph. Struct literals (`Widget { n: 1 }`) are recorded as instantiations; a `use` / `pub use` brings its item into the dependency graph — so a `pub use` re-export hub (a `mod.rs` re-exporting its submodules) depends on the modules it re-exports — resolved by Rust module path (`crate::`/`self::`/`super::`), so a re-export of a common name like `read` links to the right module instead of a same-named symbol elsewhere; and trait dispatch reaches implementations — a struct whose methods cover a trait's is treated as implementing it, and a call through `&dyn Trait` resolves to the concrete method. Previously a Rust type linked only when called or used in a type position, so structs built by literal, modules surfaced only through `pub use`, and trait-only implementations looked like they had no dependents. (#584 for Rust traits)
 - Rust cross-module function calls now resolve to the right file. A call to a sibling submodule's function — `users::router()`, the common router-assembly / handler-registration pattern where `mod users;` makes `users` a child of the current module — is now resolved relative to the current module, not only the crate root. Deeper module-path calls (`database::profiles::find()` — the `db.run(|c| …)` data-access shape) now resolve too; these were being discarded before resolution even ran, because the path's leaf function name was never checked. Previously such a call linked to nothing, so a module reached only as `module::path::function()` looked like it had no dependents; a web app wired this way (Axum, Rocket, and similar) now surfaces its handler and data-access modules' real callers. (Rust)
 - Rocket route handlers now connect to where they're mounted. A handler registered in a `routes![a::b::handler, …]` or `catchers![…]` macro used to be invisible — the macro body is a raw token tree, so the handler looked like it had no caller (Rocket mounts it at runtime) and its file showed no dependents. The handler paths are now read out of the macro and linked to the `mount`/`register` call, so editing a Rocket handler surfaces its route registration and a routes module is no longer reported as unused. (Rust, Rocket)
diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts
index 82fc208d3..c50b877fe 100644
--- a/src/graph/traversal.ts
+++ b/src/graph/traversal.ts
@@ -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).


Test patch:
diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts
index 5ddbd028f..bc25942ac 100644
--- a/__tests__/graph.test.ts
+++ b/__tests__/graph.test.ts
@@ -293,6 +293,25 @@ export { main };
 
       expect(Array.isArray(callees)).toBe(true);
     });
+
+    it('treats class instantiation as a caller/callee of the class (#774)', () => {
+      // main() does `new DerivedClass(10, 'test')`. Constructing a class is
+      // calling its constructor, so main is a caller of DerivedClass and
+      // DerivedClass is a callee of main. Before #774 the `instantiates` edge
+      // was excluded from the caller/callee traversal, so `callers <Class>`
+      // returned the importing file (or nothing) and missed every
+      // construction site.
+      const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass');
+      const main = cg.getNodesByKind('function').find((n) => n.name === 'main');
+      expect(derived).toBeDefined();
+      expect(main).toBeDefined();
+
+      const callerNames = cg.getCallers(derived!.id).map((c) => c.node.name);
+      expect(callerNames).toContain('main');
+
+      const calleeNames = cg.getCallees(main!.id).map((c) => c.node.name);
+      expect(calleeNames).toContain('DerivedClass');
+    });
   });
 
   describe('getImpactRadius()', () => {