fix(resolution): bind implements to the interface of a value+interface pair (#2055)

The inheritance target-kind gate (#1796) rejected any extends/implements
resolution whose target cannot be a supertype. VS Code declares every
service twice under one name, `export const IFoo = createDecorator<IFoo>(…)`
beside `export interface IFoo`, and the import resolver takes the first
export of that name, which is the value. The gate then dropped the edge
instead of taking the interface the same file declares.

Found by the pre-release v1.6.0 vs main comparison: 864 `implements` edges
lost on vscode (AccessibilityService -> IAccessibilityService and ~860 like
it), plus the ~5.8k interface-dispatch call edges synthesized from them.
The gate now moves a TypeScript constant/variable target to the one
same-named supertype in its file; with no such sibling it still drops the
edge. vscode: implements 4,139 -> 5,003 (v1.6.0: 5,436, 254 of them on
non-type targets; now 0), node count unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-27 19:55:29 +00:00
committed by GitHub
parent 1cbac0afaa
commit 49607201d6
3 changed files with 64 additions and 5 deletions
+1 -1
View File
@@ -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)
+36
View File
@@ -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<IFooService>(…)` 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<IFooService>('fooService');\n`;
const type = `export interface IFooService {\n run(): void;\n}\n`;
write('src/instantiation.ts', `export function createDecorator<T>(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',
+27 -4
View File
@@ -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<IFoo>('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);