fix(graph): the type hierarchy returns a type's subtypes, not only its ancestors

getTypeHierarchy() walked a type's ancestors and then its descendants
with one visited set. The ancestor walk marks the focal node first, so
the descendant walk returned at its visited guard before reading a
single incoming edge: no type ever had subtypes or implementors, in any
language. Each direction now keeps its own visited set.

The same walk feeds the hierarchy step of findRelevantContext, which
codegraph_explore and codegraph context build on. That step promised
subclasses within a budget of a quarter of maxNodes, but checked the
budget only between entry points, which was harmless while the walk
returned ancestors alone. With descendants coming back, one widely
implemented interface brought its whole tree: vscode's IDisposable, in
src/vs/base and src/vs/editor, has 613 types in its hierarchy, and
codegraph context IDisposable then traded the interface's own dispose
for an arbitrary implementor. Added on top of the contributor's fix:
the budget now holds per node, and only edges between kept nodes are
added.

Validation: graph.test.ts gains a test that getTypeHierarchy() returns
a base class's subclass and an interface's implementor; it fails on
main. context.test.ts gains one that the hierarchy step adds an
interface's implementors within its budget (12 nodes, no traversal:
1 to 3); main gives 0, and the traversal fix without the cap gives 10.
On vscode's base and editor subtree, codegraph context and
codegraph_explore keep main's size and speed (0.8 to 1.3 s), and the
hierarchy they show is now the queried type's own: StandaloneCodeEditor
and EmbeddedCodeEditorWidget for "ICodeEditor CodeEditorWidget". On
prometheus, 3 of 4 explore answers are identical, and the fourth names
more of storage.Appender's implementors. The contributor's codegraph
implementations command and its test-code helper are not part of this
change. This is the getTypeHierarchy() half of issue #2833.

Original PR #2839 by @danusha2345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-10-23 20:28:22 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent a278c2ee12
commit 43a4f5b9f9
5 changed files with 53 additions and 4 deletions
+1
View File
@@ -98,6 +98,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Indexing JavaScript and TypeScript projects is a little faster when their code calls methods the project doesn't define, like `list.push(…)`, `fs.writeFileSync(…)` or a test library's matchers: CodeGraph now sees from the names it has indexed that nothing in the project carries such a method, and no longer reads the calling code to work out what the call is made on. The graph it builds is unchanged. Thanks @luojiyin1987 for the report and the analysis. (#2829)
- The time `codegraph init` and `codegraph index` print beside the node and edge counts now covers the whole run, resolving references and linking included, and `codegraph sync` reports its whole run the same way. Before, it counted only reading and parsing the files, which can be a small part of an index, so a slow index looked fast. Thanks @bompus for the report. (#2334)
- The call paths `codegraph context` prints now say what bridges each dynamic-dispatch hop, in the same words `codegraph_explore` uses: a call through an interface reads `interface → impl`, and a C++ override, a Redux thunk or a Gin middleware chain is named as one. Before, every hop of a kind it didn't know was labeled an "event", so a call through an interface looked like an event being emitted.
- `codegraph_explore` and `codegraph context` now include some of the subtypes and implementors of a class or interface you ask about, and the type hierarchy the library returns for one (`getTypeHierarchy`) now lists all of them. Before, a bug in the hierarchy walk left every type without subtypes, so only what it extends or implements came back. Thanks @jocax for the report and @danusha2345 for the fix. (#2833)
- In Rust, code that uses an enum only through its variants, like `mode::Mode::A` in an expression, a `Mode::B =>` match arm or `Self::A` inside the enum's own `impl`, now shows up in that enum's callers and impact, linked to the enum that actually declares the variant rather than to a same-named type elsewhere. Standard-library variants like `Ordering::Less` or `Option::Some` and associated items like `Foo::new()` or `Foo::MAX` don't count as using a project enum. Re-index Rust projects after upgrading. Thanks @mg-mg-mg for the report and @danusha2345. (#2328)
- Axum routes now link to their handler when rustfmt wraps the `.route(` call so the handler sits on a later line, and when the handler's name also appears elsewhere on the line, as in `delete(handlers::user::delete)` or `.route("/login", post(handlers::user::login))`; Actix's `web::resource(…)` and `.route(…)` registrations get the same fix. A call inside a closure handler is no longer mistaken for a route, and an Actix resource no longer takes the next route's handler as its own. Thanks @mg-mg-mg for the report and @danusha2345. (#2326)
- In JavaScript and TypeScript, the functions written inside a named object literal are now symbols of their own even when the object isn't exported: a plain `const api = { load() {…} }`, an object declared inside an IIFE or a function, and a namespace hung on the page or on another object, like `window.App = { init() {…} }`, `App.utils = {…}` or `dw_page = {…}`. Calls such as `api.load()`, `window.App.init()`, `App.utils.pad()`, a sibling's `this.render()`, and `App.init()` from another script on the page now reach them, the calls made inside a member belong to that member instead of the object, and a bare `init()` is no longer taken for `App.init`, so script-tag apps no longer lose most of their code from `callers` and impact. A member's qualified name now carries its object, exported or not (`api::load`, `window.App::init`), so asking for `App.init` finds it. Re-index JavaScript and TypeScript projects after upgrading. Thanks @tkhoaaa for the report and @danusha2345. (#2300)
+24
View File
@@ -225,6 +225,30 @@ export function validateEmail(email: string): boolean {
expect(result.nodes.size).toBeLessThanOrEqual(5);
});
it('adds a widely implemented interface\'s subtypes within the hierarchy budget', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-context-hierarchy-'));
fs.writeFileSync(path.join(dir, 'lifecycle.ts'), 'export interface IDisposable {\n release(): void;\n}\n');
for (let i = 0; i < 15; i++) {
fs.writeFileSync(
path.join(dir, `impl${i}.ts`),
`import { IDisposable } from './lifecycle';\nexport class Impl${i} implements IDisposable {\n release(): void {}\n}\n`
);
}
const graph = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
try {
await graph.indexAll();
// No traversal, so every implementor here comes from the hierarchy
// step, which takes at most a quarter of the budget: 3 of 12.
const result = await graph.findRelevantContext('IDisposable', { maxNodes: 12, traversalDepth: 0 });
const implementors = [...result.nodes.values()].filter((n) => /^Impl\d+$/.test(n.name));
expect(implementors.length).toBeGreaterThan(0);
expect(implementors.length).toBeLessThanOrEqual(3);
} finally {
graph.destroy();
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('buildContext()', () => {
+18
View File
@@ -242,6 +242,24 @@ export { main };
expect(hierarchy.nodes.has(derivedClass.id)).toBe(true);
});
it('returns the subtypes and implementors of a type, not only its ancestors', () => {
const base = cg.getNodesByKind('class').find((n) => n.name === 'BaseClass')!;
const printable = cg.getNodesByKind('interface').find((n) => n.name === 'Printable')!;
const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass')!;
const fromBase = cg.getTypeHierarchy(base.id);
expect(fromBase.nodes.has(derived.id)).toBe(true);
expect(fromBase.edges.some((e) => e.source === derived.id && e.target === base.id && e.kind === 'extends')).toBe(true);
const fromInterface = cg.getTypeHierarchy(printable.id);
expect(fromInterface.nodes.has(derived.id)).toBe(true);
expect(fromInterface.edges.some((e) => e.source === derived.id && e.target === printable.id && e.kind === 'implements')).toBe(true);
// The ancestors still come back from the subtype's side.
const fromDerived = cg.getTypeHierarchy(derived.id);
expect(fromDerived.nodes.has(base.id) && fromDerived.nodes.has(printable.id)).toBe(true);
});
it('should return empty subgraph for non-existent node', () => {
const hierarchy = cg.getTypeHierarchy('non-existent-id');
+4 -1
View File
@@ -1143,13 +1143,16 @@ export class ContextBuilder {
if (hierarchyNodesAdded >= maxHierarchyNodes) break;
if (typeHierarchyKinds.has(result.node.kind)) {
const hierarchy = this.traverser.getTypeHierarchy(result.node.id);
// The budget holds per node: a widely implemented interface brings
// its whole descendant tree (IDisposable: hundreds of types).
for (const [id, node] of hierarchy.nodes) {
if (!nodes.has(id)) {
if (!nodes.has(id) && hierarchyNodesAdded < maxHierarchyNodes) {
nodes.set(id, node);
hierarchyNodesAdded++;
}
}
for (const edge of hierarchy.edges) {
if (!nodes.has(edge.source) || !nodes.has(edge.target)) continue;
const exists = edges.some(
(e) => e.source === edge.source && e.target === edge.target && e.kind === edge.kind
);
+6 -3
View File
@@ -435,16 +435,19 @@ export class GraphTraverser {
const nodes = new Map<string, Node>();
const edges: Edge[] = [];
const visited = new Set<string>();
// Add focal node
nodes.set(focalNode.id, focalNode);
// Each direction keeps its own visited set: the ancestor walk marks the
// focal node first, and a shared set made the descendant walk return at
// its guard before reading one incoming edge, so no type ever had
// subtypes (#2833).
// Get ancestors (what this extends/implements)
this.getTypeAncestors(nodeId, nodes, edges, visited);
this.getTypeAncestors(nodeId, nodes, edges, new Set<string>());
// Get descendants (what extends/implements this)
this.getTypeDescendants(nodeId, nodes, edges, visited);
this.getTypeDescendants(nodeId, nodes, edges, new Set<string>());
return {
nodes,