diff --git a/CHANGELOG.md b/CHANGELOG.md index dc63884d4..02fa7681d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/__tests__/context.test.ts b/__tests__/context.test.ts index c46c612d7..cc0ca1ed0 100644 --- a/__tests__/context.test.ts +++ b/__tests__/context.test.ts @@ -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()', () => { diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts index 875c9ed2a..f5e07d39d 100644 --- a/__tests__/graph.test.ts +++ b/__tests__/graph.test.ts @@ -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'); diff --git a/src/context/index.ts b/src/context/index.ts index 189329a9d..0f43d4cba 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -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 ); diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts index 47368ac6a..8fc4c3099 100644 --- a/src/graph/traversal.ts +++ b/src/graph/traversal.ts @@ -435,16 +435,19 @@ export class GraphTraverser { const nodes = new Map(); const edges: Edge[] = []; - const visited = new Set(); // 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()); // Get descendants (what extends/implements this) - this.getTypeDescendants(nodeId, nodes, edges, visited); + this.getTypeDescendants(nodeId, nodes, edges, new Set()); return { nodes,