mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 03:49:59 +08:00
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>
253 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|