diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a245656f..c2a376456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -317,7 +317,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Rust unit structs (`struct Unit;`) and their trait implementation relationships now appear in the graph after re-indexing. (#1513, #1514) - Imports from Node built-ins or npm packages no longer connect to unrelated type members with matching names; re-index after upgrading to clear existing false dependencies. Thanks @ctype-lab. (#1537) -- Inheritance relationships no longer attach external Rust or npm supertypes to unrelated local symbols with the same name, including in Svelte, Vue and Astro components; re-index after upgrading to clear existing false relationships. Thanks @ctype-lab. (#1536) +- Inheritance relationships no longer attach external Rust or npm supertypes to unrelated local symbols with the same name, including in Svelte, Vue and Astro components; re-index after upgrading to clear existing false relationships. A TypeScript class implementing a service that is declared as both a value and an interface under one name, the pattern VS Code uses for every service, now links to the interface instead of the value. Thanks @ctype-lab. (#1536) - Spring's dependency-injection resolution patterns no longer capture inheritance references or references from other languages. In a polyglot repository where Spring is detected (a sibling Java module is enough), a Scala or Kotlin `extends`/`implements` reference could be resolved by Spring's directory-convention heuristics to an unrelated same-named class — for example a test fixture — corrupting the inheritance graph that `codegraph_impact` and `codegraph_explore` walk. Inheritance now always resolves through imports and name matching, and the Spring DI patterns only apply to Java/Kotlin references. Re-index after upgrading. (#1825) diff --git a/__tests__/reference-target-kind.test.ts b/__tests__/reference-target-kind.test.ts index 43746af1a..6eb421174 100644 --- a/__tests__/reference-target-kind.test.ts +++ b/__tests__/reference-target-kind.test.ts @@ -201,6 +201,42 @@ describe('reference target-kind gate', () => { expect(rows.filter((r) => r.tgtKind === 'property' || r.tgtKind === 'field')).toEqual([]); }); + it.each([ + ['value declared first', 'const'], + ['interface declared first', 'interface'], + ])('binds implements to the interface of a value+interface pair (%s)', async (_label, first) => { + // VS Code declares every service twice under one name — the DI identifier + // `export const IFooService = createDecorator(…)` and + // `export interface IFooService` — so the import resolves to a file holding + // both. The gate rejected the value and left the ref failed, dropping every + // `implements IFooService` in the codebase instead of taking the interface. + const value = `export const IFooService = createDecorator('fooService');\n`; + const type = `export interface IFooService {\n run(): void;\n}\n`; + write('src/instantiation.ts', `export function createDecorator(id: string): { id: string } { return { id }; }\n`); + write( + 'src/foo.ts', + `import { createDecorator } from './instantiation';\n\n` + (first === 'const' ? value + type : type + value) + ); + write( + 'src/fooService.ts', + `import { IFooService } from './foo';\n\nexport class FooService implements IFooService {\n run(): void {}\n}\n` + ); + const { edges, failed } = await load(); + expect(has(edges, 'FooService', 'IFooService', 'interface')).toBe(true); + expect(has(edges, 'FooService', 'IFooService', 'constant')).toBe(false); + expect(failed.some((r) => r.name === 'IFooService')).toBe(false); + }); + + it('still drops an implements whose only same-named target is a value', async () => { + write('src/foo.ts', `export const IBarService = { id: 'bar' };\n`); + write( + 'src/barService.ts', + `import { IBarService } from './foo';\n\nexport class BarService implements IBarService {\n id = 'bar';\n}\n` + ); + const { edges } = await load(); + expect(edges.filter((e) => e.tgt === 'IBarService')).toEqual([]); + }); + it('keeps class extends class and class implements interface', async () => { write( 'src/base.ts', diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 0d84ed46e..98e247392 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -2675,9 +2675,18 @@ export class ReferenceResolver { * referent. Without this, filtering by kind alone just relocates the * false edge onto the next same-named local type. * - * Direction is one-way: this only ever REMOVES an edge, never adds one. A - * dropped ref stays in `unresolved_refs` as `failed`, which is the honest - * record for a supertype that lives outside the repo — silent beats wrong. + * One exception to (1): a TypeScript VALUE that shares its name with a type + * in the same file. `export const IFoo = createDecorator('foo')` beside + * `export interface IFoo` is how VS Code declares every service, and an + * import of `IFoo` resolves to the file with both — a strategy that takes the + * first export of that name gets the value. The strategy found the right + * file and name; the type declared there is the supertype, so the edge moves + * to it rather than being dropped (dropping it lost ~900 `implements` edges + * on vscode). + * + * Otherwise this only ever REMOVES an edge, never adds one. A dropped ref + * stays in `unresolved_refs` as `failed`, which is the honest record for a + * supertype that lives outside the repo — silent beats wrong. */ private gateTargetKind(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null { if (!result) return result; @@ -2698,11 +2707,25 @@ export class ReferenceResolver { if (!isInheritanceRef(ref)) return result; const target = this.queries.getNodeById(result.targetNodeId); - if (target && !isSupertypeTarget(target)) return null; + if (target && !isSupertypeTarget(target)) { + const type = this.sameNamedTypeOfValue(target); + if (!type) return null; + result = { ...result, targetNodeId: type.id }; + } if (isBoundToOutOfRepoImport(ref, this.context)) return null; return result; } + /** The one supertype-kind node a TypeScript value shares its name and file with. */ + private sameNamedTypeOfValue(value: Node): Node | null { + if (value.kind !== 'constant' && value.kind !== 'variable') return null; + if (value.language !== 'typescript' && value.language !== 'tsx') return null; + const types = this.context + .getNodesInFile(value.filePath) + .filter((n) => n.name === value.name && isSupertypeTarget(n)); + return types.length === 1 ? types[0]! : null; + } + private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null { if (!result) return result; return gateLanguageMatch(result, ref, this.context);