Files
codegraph/__tests__/reference-target-kind.test.ts
T
Colby MchenryandClaude Opus 5.5 65d734236e fix(rust): a private item, or one another crate holds, no longer takes a name a file imports (#2692)
A Rust type alias declared without pub recorded no visibility in either
engine, so name matching took dioxus's private type Result in
src/build/patch.rs for the Result<...> a file means through
use crate::Result. Type aliases now record it, as functions and structs
already do, and a private module item is out of scope outside its own
module ahead of the ranking. The pipeline's veto already rejected such
a pick after the ranking, which left the reference unresolved: that is
what kept tokio's timeout(...), split(...) and sleep(...) calls from
linking, since a private namesake outranked the function they import.

With the private items gone, a runner-up from an unrelated crate could
win instead: tokio's tests' spawn(...) calls, under
use tokio_test::task::spawn, went to the examples crate's pub fn spawn.
So a name a use binds is now an item of the crate its path starts from,
or of a project crate that one's [dependencies] name, in turn, which a
facade's re-exports need: clap::Command is clap_builder's. crate::,
self:: and super:: start from the file's own crate; a named root counts
when the file's own manifest lists it. Dependencies are found by path
(their own, or the workspace's for workspace = true), so renamed keys
and crates outside the members land, and dotted keys such as
helper.workspace = true are read. Dev and build dependencies are not
re-exported, so they don't count: tokio's own use crate::spawn, whose
spawn sits in a macro the index doesn't see, stays unresolved instead
of going to tokio-test's. A file in no crate the manifests name, or a
path from anything else, is matched as before.

reference-target-kind's sibling-crate fixture re-exported pupil_core
without depending on it, which Cargo rejects; it now declares it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:44:52 +00:00

253 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`);
// Re-exporting pupil_core's module takes a dependency on it, as Cargo requires.
write('app/Cargo.toml', `[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\npupil_core = { path = "../core" }\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);
});
});