* fix(resolution): gate extends/implements to real supertypes
An inheritance reference bound to whatever local symbol shared its name.
The name-matcher scores node kind as a bonus, never a filter, and awards
no bonus at all for inheritance refs, so `use std::error::Error;` +
`impl Error for MapperError {}` resolved to the local `MapperError::Error`
VARIANT — an implementation relationship absent from the source.
Two changes, both needed. Filtering by kind alone was measured and it
only RELOCATES the false edge: with enum members excluded, the same 7
refs moved onto an unrelated local `type Error` alias, which is a legal
supertype kind and therefore harder for a consumer to reject.
1. Eligibility before ranking. `matchByExactName` restricts its candidate
pool to kinds that can BE a supertype, so a legitimate trait outranks
a same-named variant instead of merely losing its edge. `resolveOne`
is wrapped by a gate that applies the same set to every other strategy
at one seam — filtering inside the name-matcher would have missed the
framework, import, chain and CFML paths.
2. Locality. A name imported from outside the repository has no in-repo
referent at all, so no candidate is correct. Only oracles that cannot
be wrong are consulted: Rust `use` paths rooted at a stdlib crate, and
`isExternalImport` for ES modules. Generalizing the Rust side to "the
module path doesn't resolve to a file" was tried and reverted — a
crate re-exporting a sibling's modules (`pub use pupil_core::ports;`)
has no directory to walk, and that version deleted 13 real trait
implementations.
Measured on a Rust/Tauri project (2,682 nodes): the 11 false inheritance
edges are gone, all 59 real trait relationships are preserved, and node
count is unchanged. On this repository as a control, the only edge
removed is a class recorded as extending a function. Synthesized-edge
counts are identical in both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(resolution): an import never resolves to a member of a type
`import * as path from 'node:path'` is unresolvable — the module is
external — so the name-matcher fell back to finding any node called
`path`, and a common word like path/url/join/get matches a class property
or interface method somewhere in almost any repo. Nothing in any
supported language lets an import bind to a member that only exists
inside a type; you import the type.
Same shape as the inheritance gate that precedes it: eligibility applied
to the candidate pool before ranking, plus the resolveOne gate as the
backstop for every other strategy.
On this repository as a control: 19 imports pointing at methods and 4 at
properties are gone (all of them coincidences — `Walker::join`,
`Telemetry::events`), 3 refs now find the module constant they actually
name, node count unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(resolution): classify SFC script imports as ES module specifiers
`isExternalImport` had a TS/JS branch listing typescript/tsx/javascript/jsx/
arkts, so for Svelte, Vue and Astro it fell through every branch and returned
false — "not external" — for `import { Foo } from 'some-npm-pkg'`.
An SFC imports inside its `<script>` block (Astro: the `---` frontmatter) with
ordinary ES module syntax; `extractImportMappings` already routes all three
through the same `extractJSImports`. So the classifier disagreed with the
extractor about what those imports are.
Effect on the preceding commit: its locality check asks `isExternalImport`, so
it silently did nothing for SFCs. A class in a `.svelte`/`.vue`/`.astro` file
implementing a type imported from an npm package still bound to whatever local
class shared that name — verified against this branch before the fix, all three
languages.
The language set is now one constant used by both the classifier and the
locality check, so they cannot drift apart again. Relative and aliased
specifiers are unaffected: the branch returns "not external" for `./…`,
workspace members, tsconfig alias prefixes, `@/`, `~/` and `src/` exactly as it
does for `.ts`.
No edge changes on this repository as a control.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: ctype_lab <cksgud1226@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
216 lines
9.8 KiB
TypeScript
216 lines
9.8 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('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);
|
|
});
|
|
});
|