diff --git a/CHANGELOG.md b/CHANGELOG.md index 7835144..e390d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -221,6 +221,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- 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) + - PHP static calls through imported class aliases now reach the correct class when services and repositories share method names, so callers and impact analysis show the right dependencies after re-indexing. (#1545) - TypeScript/JavaScript: a call through a field of the enclosing class — `this.mailer.send()` — now resolves on the field's declared type, so a delegating wrapper that shares the method's name no longer records itself as its own callee and `callers`, `impact` and trace stop lying on that shape. A field whose type is external or a builtin stays unresolved rather than guessed. Re-index after upgrading. (#1496) - TypeScript and JavaScript collection calls through local variables and their nested properties no longer link to unrelated project methods; re-index after upgrading. (#1566) diff --git a/__tests__/reference-target-kind.test.ts b/__tests__/reference-target-kind.test.ts new file mode 100644 index 0000000..43746af --- /dev/null +++ b/__tests__/reference-target-kind.test.ts @@ -0,0 +1,215 @@ +/** + * 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', '\n