mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
49607201d6
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>
252 lines
12 KiB
TypeScript
252 lines
12 KiB
TypeScript
/**
|
|
* Reference target-kind gate — `extends`/`implements` and `imports`.
|
|
*
|
|
* The name-matcher treats node kind as a scoring BONUS, never a filter, and
|
|
* awards no bonus at all for inheritance refs. When exactly one same-named
|
|
* node exists, the single-candidate shortcut adopts it unconditionally at
|
|
* confidence 0.9. So a supertype that lives OUTSIDE the repo — imported by a
|
|
* bare name — bound to whatever local symbol happened to share that name,
|
|
* asserting an inheritance relationship absent from the source:
|
|
*
|
|
* use std::error::Error; // the supertype is out-of-repo
|
|
* impl Error for MapperError {} // ...but `MapperError::Error` is a variant
|
|
* → implements: enum MapperError -> enum_member Error
|
|
*
|
|
* The gate drops any inheritance resolution whose target cannot be a
|
|
* supertype. It only ever removes edges, so the tests below pin BOTH
|
|
* directions: the false edge is gone, and every legitimate supertype kind
|
|
* (in-repo trait, interface, class, and TS object-type alias) still resolves.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { CodeGraph } from '../src';
|
|
|
|
describe('reference target-kind gate', () => {
|
|
let dir: string;
|
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'inh-kind-')); });
|
|
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
|
|
const write = (rel: string, body: string) => {
|
|
const p = path.join(dir, rel);
|
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
fs.writeFileSync(p, body);
|
|
};
|
|
|
|
type InhEdge = { src: string; srcKind: string; tgt: string; tgtKind: string; kind: string };
|
|
|
|
const load = async (): Promise<{ edges: InhEdge[]; failed: { name: string; kind: string }[] }> => {
|
|
const cg = await CodeGraph.init(dir, { silent: true });
|
|
await cg.indexAll();
|
|
const db = (cg as any).db.db;
|
|
const edges: InhEdge[] = db
|
|
.prepare(
|
|
`SELECT s.name src, s.kind srcKind, t.name tgt, t.kind tgtKind, e.kind kind
|
|
FROM edges e
|
|
JOIN nodes s ON s.id = e.source
|
|
JOIN nodes t ON t.id = e.target
|
|
WHERE e.kind IN ('extends', 'implements')`
|
|
)
|
|
.all();
|
|
const failed: { name: string; kind: string }[] = db
|
|
.prepare(
|
|
`SELECT reference_name name, reference_kind kind
|
|
FROM unresolved_refs
|
|
WHERE reference_kind IN ('extends', 'implements')`
|
|
)
|
|
.all();
|
|
cg.close?.();
|
|
return { edges, failed };
|
|
};
|
|
|
|
const has = (edges: InhEdge[], src: string, tgt: string, tgtKind: string) =>
|
|
edges.some((e) => e.src === src && e.tgt === tgt && e.tgtKind === tgtKind);
|
|
|
|
it('drops an out-of-repo Rust supertype that name-matched a local enum member', async () => {
|
|
write(
|
|
'src/lib.rs',
|
|
`use std::error::Error;\n\n` +
|
|
`pub enum MapperError {\n Error,\n Missing,\n}\n\n` +
|
|
`impl Error for MapperError {}\n`
|
|
);
|
|
const { edges, failed } = await load();
|
|
expect(has(edges, 'MapperError', 'Error', 'enum_member')).toBe(false);
|
|
// The reference is not silently forgotten — it stays on record as failed,
|
|
// which is the honest outcome for a supertype the repo does not contain.
|
|
expect(failed.some((r) => r.name === 'Error')).toBe(true);
|
|
});
|
|
|
|
it('does not relocate the false edge onto a same-named local type alias', async () => {
|
|
// The kind filter alone would have moved this edge from the enum member to
|
|
// `type Error`, which IS a legal supertype kind — still false data, and
|
|
// harder for a consumer to reject. Locality is what removes it.
|
|
write('src/alias.rs', `pub type Error = String;\n`);
|
|
write(
|
|
'src/lib.rs',
|
|
`mod alias;\n\nuse std::error::Error;\n\n` +
|
|
`pub enum MapperError {\n Missing,\n}\n\n` +
|
|
`impl Error for MapperError {}\n`
|
|
);
|
|
const { edges, failed } = await load();
|
|
expect(edges.filter((e) => e.tgt === 'Error')).toEqual([]);
|
|
expect(failed.some((r) => r.name === 'Error')).toBe(true);
|
|
});
|
|
|
|
it('keeps a supertype imported by an in-repo `use` path', async () => {
|
|
write('src/ports.rs', `pub trait Sha256Port {\n fn hash(&self) -> String;\n}\n`);
|
|
write(
|
|
'src/lib.rs',
|
|
`mod ports;\n\nuse crate::ports::Sha256Port;\n\n` +
|
|
`pub struct Hasher {\n salt: String,\n}\n\n` +
|
|
`impl Sha256Port for Hasher {\n fn hash(&self) -> String { String::new() }\n}\n`
|
|
);
|
|
const { edges } = await load();
|
|
expect(has(edges, 'Hasher', 'Sha256Port', 'trait')).toBe(true);
|
|
});
|
|
|
|
it('keeps a trait reached through a re-exported sibling-crate module', async () => {
|
|
// `crate::ports` here is a re-export of ANOTHER crate's module, so no
|
|
// `src/ports.rs` exists to walk to. Treating "module path does not resolve
|
|
// to a file" as proof of out-of-repo deleted 13 real trait implementations
|
|
// on the reference fixture — hence the rule keys on stdlib roots only.
|
|
write('Cargo.toml', `[workspace]\nmembers = ["core", "app"]\n`);
|
|
write('core/Cargo.toml', `[package]\nname = "pupil_core"\nversion = "0.1.0"\n`);
|
|
write('core/src/lib.rs', `pub mod ports;\n`);
|
|
write('core/src/ports.rs', `pub trait CacheStore {\n fn get(&self);\n}\n`);
|
|
write('app/Cargo.toml', `[package]\nname = "app"\nversion = "0.1.0"\n`);
|
|
write('app/src/lib.rs', `pub use pupil_core::ports;\n\npub mod platform;\n`);
|
|
write(
|
|
'app/src/platform.rs',
|
|
`use crate::ports::CacheStore;\n\npub struct SafStorage {\n root: String,\n}\n\n` +
|
|
`impl CacheStore for SafStorage {\n fn get(&self) {}\n}\n`
|
|
);
|
|
const { edges } = await load();
|
|
expect(has(edges, 'SafStorage', 'CacheStore', 'trait')).toBe(true);
|
|
});
|
|
|
|
it('still resolves an in-repo Rust trait (the gate is not a blanket block)', async () => {
|
|
write(
|
|
'src/lib.rs',
|
|
`pub trait Mapper {\n fn map(&self) -> u32;\n}\n\n` +
|
|
`pub enum MapperError {\n Mapper,\n}\n\n` +
|
|
`pub struct Real {\n n: u32,\n}\n\n` +
|
|
`impl Mapper for Real {\n fn map(&self) -> u32 { 1 }\n}\n`
|
|
);
|
|
const { edges } = await load();
|
|
expect(has(edges, 'Real', 'Mapper', 'trait')).toBe(true);
|
|
expect(has(edges, 'Real', 'Mapper', 'enum_member')).toBe(false);
|
|
});
|
|
|
|
it('keeps a TypeScript class implementing an object-type alias', async () => {
|
|
write(
|
|
'src/api.ts',
|
|
`export type SearchApi = { query(q: string): string };\n\n` +
|
|
`export class LocalSearch implements SearchApi {\n` +
|
|
` query(q: string): string { return q; }\n}\n`
|
|
);
|
|
const { edges } = await load();
|
|
expect(has(edges, 'LocalSearch', 'SearchApi', 'type_alias')).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
['svelte', 'src/Box.svelte', '<script lang="ts">\n$IMPORT$\nexport class SfcBox implements Serializable {\n n = 1;\n}\n</script>\n<div>hi</div>\n'],
|
|
['vue', 'src/Box.vue', '<script lang="ts">\n$IMPORT$\nexport class SfcBox implements Serializable {\n n = 1;\n}\n</script>\n<template><div/></template>\n'],
|
|
['astro', 'src/Box.astro', '---\n$IMPORT$\nexport class SfcBox implements Serializable {\n n = 1;\n}\n---\n<div/>\n'],
|
|
])('drops an npm supertype in a %s single-file component', async (_lang, file, body) => {
|
|
// An SFC imports inside its <script> block (Astro: the `---` frontmatter)
|
|
// with ordinary ES module syntax, so a bare specifier there is external for
|
|
// exactly the same reason it is in a .ts file. Missing that, the npm
|
|
// supertype name-matched the local class below.
|
|
write('package.json', `{"name":"sfc","version":"1.0.0"}\n`);
|
|
write('src/models.ts', `export class Serializable {\n a = 1;\n}\n`);
|
|
write(file, body.replace('$IMPORT$', `import { Serializable } from 'some-npm-pkg';\n`));
|
|
const { edges, failed } = await load();
|
|
expect(edges.filter((e) => e.tgt === 'Serializable')).toEqual([]);
|
|
expect(failed.some((r) => r.name === 'Serializable')).toBe(true);
|
|
});
|
|
|
|
it('keeps an SFC supertype imported from a relative path', async () => {
|
|
write('package.json', `{"name":"sfc","version":"1.0.0"}\n`);
|
|
write('src/models.ts', `export class Serializable {\n a = 1;\n}\n`);
|
|
write(
|
|
'src/Box.svelte',
|
|
`<script lang="ts">\nimport { Serializable } from './models';\n\n` +
|
|
`export class SfcBox implements Serializable {\n n = 1;\n}\n</script>\n<div>hi</div>\n`
|
|
);
|
|
const { edges } = await load();
|
|
expect(has(edges, 'SfcBox', 'Serializable', 'class')).toBe(true);
|
|
});
|
|
|
|
it('does not resolve an import to a type member that shares its name', async () => {
|
|
// `import * as path from 'node:path'` is unresolvable — the module is
|
|
// external — so the name-matcher looked for any node called `path` and
|
|
// found a class property. No language lets you import a type's member.
|
|
write('src/types.ts', `export class Request {\n path = '';\n url = '';\n}\n`);
|
|
write(
|
|
'src/run.ts',
|
|
`import * as path from 'node:path';\n\nexport function run() {\n return path.join('a', 'b');\n}\n`
|
|
);
|
|
const cg = await CodeGraph.init(dir, { silent: true });
|
|
await cg.indexAll();
|
|
const db = (cg as any).db.db;
|
|
const rows: { tgt: string; tgtKind: string }[] = db
|
|
.prepare(
|
|
`SELECT t.name tgt, t.kind tgtKind
|
|
FROM edges e JOIN nodes t ON t.id = e.target
|
|
WHERE e.kind = 'imports'`
|
|
)
|
|
.all();
|
|
cg.close?.();
|
|
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',
|
|
`export interface Runner { run(): void }\n` +
|
|
`export class Base { run(): void {} }\n` +
|
|
`export class Child extends Base implements Runner { run(): void {} }\n`
|
|
);
|
|
const { edges } = await load();
|
|
expect(has(edges, 'Child', 'Base', 'class')).toBe(true);
|
|
expect(has(edges, 'Child', 'Runner', 'interface')).toBe(true);
|
|
});
|
|
});
|