From b55ad79f5a57dd6b703e1f4e7e30c189fde46d19 Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Mon, 10 Aug 2026 20:25:47 +0900 Subject: [PATCH 1/3] fix(resolution): gate extends/implements to real supertypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 2 + __tests__/inheritance-target-kind.test.ts | 163 ++++++++++++++++++++++ src/resolution/import-resolver.ts | 127 +++++++++++++++++ src/resolution/index.ts | 47 ++++++- src/resolution/name-matcher.ts | 12 +- src/resolution/types.ts | 34 +++++ 6 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 __tests__/inheritance-target-kind.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8ab6f79..e71348d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- An inheritance relationship is no longer invented between a type and an unrelated symbol that merely shares a name with its supertype. When a class or struct implemented something defined outside your project — a trait from the Rust standard library, an interface from an npm package — the graph would attach that relationship to whatever local symbol happened to have the same name: an enum variant, a type alias, even a function. On a Rust project of moderate size this fabricated a dozen implementation relationships, which then showed up in diagrams and in answers about who implements what. A supertype that isn't in your project is now simply left unresolved, and when several same-named symbols compete, only ones that can actually be a supertype are considered — so the real trait wins instead of losing to a same-named variant. Thanks @ctype-lab. Re-index after upgrading to clear the bad relationships from an existing project. + - C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515) - A long-lived index no longer drifts away from what a fresh `codegraph index` would produce. When a file gained or lost a symbol, references to that name in files the sync never touched kept pointing at the definition that was correct before the change, and — because nothing distinguished two same-named definitions — the winner could come down to the order files happened to be written, which differs between a full index and a sync. On this project's own repository, replaying 80 commits through `sync` left 5.7% of connections wrong; it is now 1.3%, and the wrong-answers-still-being-asserted half drops by 99.7%. Since call edges are what flow questions follow and what `codegraph_explore` ranks files by, this quietly degraded answers as an index aged, with nothing to indicate it. Syncing is unchanged in speed, and an edit that only changes a function's body does no extra work at all. Set `CODEGRAPH_NO_REBIND=1` to opt out. diff --git a/__tests__/inheritance-target-kind.test.ts b/__tests__/inheritance-target-kind.test.ts new file mode 100644 index 000000000..557304629 --- /dev/null +++ b/__tests__/inheritance-target-kind.test.ts @@ -0,0 +1,163 @@ +/** + * `extends`/`implements` target-kind gate. + * + * 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('inheritance 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('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); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index a32a97916..2cdc573cc 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -2274,3 +2274,130 @@ function resolveStaticMember( } return candidates[0]; } + +/** + * Rust `use` declarations, flattened to `localName → full path`. + * + * Rust is the one supported language with NO `ImportMapping` extraction (see + * `extractImportMappings`), so this is the only channel that can tell whether + * a bare type name in a Rust file was brought in by a `use`. Handles nested + * groups (`use a::{b::C, d as E}`), globs (skipped — they bind no single + * name), and `as` aliases. + */ +function collectRustUseBindings(content: string): Map { + const out = new Map(); + + // Expand one level of `{...}` at a time so `a::{b::{C, D}, E}` flattens. + const expand = (spec: string): string[] => { + const open = spec.indexOf('{'); + if (open === -1) return [spec.trim()]; + const prefix = spec.slice(0, open); + let depth = 0; + let close = -1; + for (let i = open; i < spec.length; i++) { + if (spec[i] === '{') depth++; + else if (spec[i] === '}') { + depth--; + if (depth === 0) { close = i; break; } + } + } + if (close === -1) return []; + const suffix = spec.slice(close + 1); + const inner = spec.slice(open + 1, close); + const parts: string[] = []; + let depth2 = 0; + let start = 0; + for (let i = 0; i <= inner.length; i++) { + const ch = inner[i]; + if (ch === '{') depth2++; + else if (ch === '}') depth2--; + if (i === inner.length || (ch === ',' && depth2 === 0)) { + const seg = inner.slice(start, i).trim(); + if (seg) parts.push(seg); + start = i + 1; + } + } + return parts.flatMap((p) => expand(prefix + p + suffix)); + }; + + // `use` items end at the first `;`. Attributes/visibility (`pub use`) are + // irrelevant to the binding itself. + const useRe = /(^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/g; + let m: RegExpExecArray | null; + while ((m = useRe.exec(content)) !== null) { + for (const spec of expand(m[2]!.replace(/\s+/g, ' '))) { + const aliasMatch = /^(.*?)\s+as\s+([A-Za-z_]\w*)$/.exec(spec); + const rawPath = (aliasMatch ? aliasMatch[1]! : spec).trim(); + if (!rawPath || rawPath.endsWith('*')) continue; + const segments = rawPath.split('::').map((s) => s.trim()).filter(Boolean); + const leaf = segments[segments.length - 1]; + if (!leaf) continue; + const local = aliasMatch ? aliasMatch[2]! : leaf; + out.set(local, segments.join('::')); + } + } + return out; +} + +/** + * Is `name`, as used in `ref`'s file, bound by an import whose module lives + * OUTSIDE the repository? + * + * When it is, no in-repo node can be the referent: the symbol is defined in a + * third-party crate/package, and any same-named local symbol the name-matcher + * finds is a coincidence. Rust `use std::error::Error;` + `impl Error for + * MapperError {}` bound to a local `MapperError::Error` variant, and once + * non-type kinds were filtered out it simply moved to an unrelated local + * `type Error` alias — restricting kinds alone RELOCATES the false edge + * instead of removing it, so locality has to be checked too. + * + * Answers only when it can be CERTAIN, because a false "yes" deletes a real + * edge. Two languages qualify, each with an oracle that cannot be wrong: + * + * - **Rust** — the `use` path is rooted at a standard-library crate + * (`std`/`core`/`alloc`/`proc_macro`), which by definition ships outside + * any repository. Deliberately NOT generalized to "the module path doesn't + * resolve to a file": a crate can re-export another workspace crate's + * modules (`pub use pupil_core::{ports, domain};`), so `crate::ports::X` + * has no `src/ports/` directory to walk yet is entirely in-repo — that + * generalization measured 13 real trait implementations deleted. + * - **ES modules** — `isExternalImport`, which already accounts for tsconfig + * path aliases and monorepo workspace packages. + * + * Everything else returns false and resolves exactly as before. JVM and Python + * imports notably do NOT go through `resolveImportPath` (they have dedicated + * FQN/module matchers), so there is no trustworthy oracle to consult here. + */ +const ESM_IMPORT_LANGUAGES = new Set([ + 'typescript', 'tsx', 'javascript', 'jsx', 'arkts', 'svelte', 'vue', 'astro', +]); + +/** Rust path roots that always name a standard-library crate. */ +const RUST_STDLIB_ROOTS = new Set(['std', 'core', 'alloc', 'proc_macro']); + +export function isBoundToOutOfRepoImport( + ref: UnresolvedRef, + context: ResolutionContext +): boolean { + const name = ref.referenceName; + if (name.includes('::') || name.includes('.')) return false; // qualified refs resolve by path + + if (ref.language === 'rust') { + const content = context.readFile(ref.filePath); + if (!content) return false; + const usePath = collectRustUseBindings(content).get(name); + if (!usePath) return false; + const segments = usePath.split('::'); + if (segments.length < 2 || !RUST_STDLIB_ROOTS.has(segments[0]!)) return false; + // 2015-edition crate-relative paths can shadow a stdlib root with a local + // module of the same name — if the path walks to a real file, it's local. + return resolveRustModuleFile(segments.slice(0, -1), ref.filePath, context) === null; + } + + if (!ESM_IMPORT_LANGUAGES.has(ref.language)) return false; + for (const imp of context.getImportMappings(ref.filePath, ref.language)) { + if (imp.localName !== name) continue; + return isExternalImport(imp.source, ref.language, context); + } + return false; +} diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 01f615b28..cc3e58427 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -15,9 +15,11 @@ import { ResolutionContext, FrameworkResolver, ImportMapping, + SUPERTYPE_TARGET_KINDS, + isInheritanceRef, } from './types'; import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; -import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver'; +import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; @@ -34,6 +36,11 @@ const SUPERTYPE_BEARING_KINDS = new Set([ 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', ]); +// SUPERTYPE_TARGET_KINDS (the kinds an extends/implements edge may TARGET) +// lives in ./types — the name-matcher needs the same set to restrict its +// candidate pool before ranking. It is deliberately wider than +// SUPERTYPE_BEARING_KINDS above, which is about the DECLARING side. + /** * Languages whose chained static-factory/fluent calls defer to the conformance * second pass. Dotted-receiver languages resolve via matchDottedCallChain; the @@ -850,9 +857,18 @@ export class ReferenceResolver { } /** - * Resolve a single reference + * Resolve a single reference. + * + * Thin decorator over `resolveOneInner` so every strategy — framework, + * import, name-match, chain, CFML component path — passes through the + * inheritance target-kind gate at ONE seam. Filtering inside the + * name-matcher would have covered `matchByExactName` only. */ resolveOne(ref: UnresolvedRef): ResolvedRef | null { + return this.gateTargetKind(this.resolveOneInner(ref), ref); + } + + private resolveOneInner(ref: UnresolvedRef): ResolvedRef | null { // Skip built-in/external references if (this.isBuiltInOrExternal(ref)) { return null; @@ -2414,6 +2430,33 @@ export class ReferenceResolver { return edges.length; } + /** + * Drop an `extends`/`implements` resolution that cannot be describing a real + * supertype. Applied at the `resolveOne` seam so it covers every strategy + * uniformly — framework, import, name-match, chain, CFML component path. + * + * Two independent reasons to drop, and BOTH are needed: + * 1. The target's kind can never be a supertype (an enum member, a method, + * a variable). `matchByExactName` additionally narrows its candidate + * pool by the same set, so a legitimate supertype outranks a same-named + * non-type rather than merely losing its edge. + * 2. The name is imported from outside the repo, so NO local node is the + * referent. Without this, filtering by kind alone just relocates the + * false edge onto the next same-named local type. + * + * Direction is one-way: this only ever REMOVES an edge, never adds one. A + * dropped ref stays in `unresolved_refs` as `failed`, which is the honest + * record for a supertype that lives outside the repo — silent beats wrong. + */ + private gateTargetKind(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null { + if (!result) return result; + if (!isInheritanceRef(ref)) return result; + const target = this.queries.getNodeById(result.targetNodeId); + if (target && !SUPERTYPE_TARGET_KINDS.has(target.kind)) return null; + if (isBoundToOutOfRepoImport(ref, this.context)) return null; + return result; + } + private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null { if (!result) return result; const tgt = this.getLanguageFromNodeId(result.targetNodeId); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 651051466..b07711b03 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -5,7 +5,7 @@ */ import { Language, Node } from '../types'; -import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types'; +import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef } from './types'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will @@ -407,7 +407,15 @@ export function matchByExactName( const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref) .filter((n) => n.kind !== 'import') // Nested locals are only reachable from inside their container (#1230). - .filter((n) => isLexicallyReachable(n, ref, context)); + .filter((n) => isLexicallyReachable(n, ref, context)) + // An `extends`/`implements` ref names a supertype, so anything that can't + // BE one is not a candidate at all. This is eligibility, not + // ranking: kind is only a scoring bonus below (and none is awarded for + // inheritance refs), so without this a same-named `enum_member` outranked + // the real `trait`, and as the sole candidate was adopted outright by the + // single-match shortcut. Restricting the pool BEFORE ranking lets the + // legitimate supertype win instead of merely dropping the false edge. + .filter((n) => !isInheritanceRef(ref) || SUPERTYPE_TARGET_KINDS.has(n.kind)); if (candidates.length === 0) { return null; diff --git a/src/resolution/types.ts b/src/resolution/types.ts index bc80e2fc7..229bb3836 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -273,3 +273,37 @@ export type ReExport = /** Module specifier of the upstream module. */ source: string; }; + +/** + * Node kinds an `extends`/`implements` edge may legally TARGET — the things a + * type can actually inherit from or conform to. + * + * Kept deliberately wide: `type_alias` because TS `class X implements + * SomeAliasedObjectType` is valid, `component` because a framework component + * node stands in for a class, and `module`/`namespace` because whole + * languages inherit from one — Ruby `include Trackable` targets a `module`, + * Erlang `-behaviour(gen_server)` targets the behaviour module, which Erlang + * extraction indexes as a `namespace` (the conformance pass in + * `resolution/index.ts` makes the same `module` allowance). + * + * Everything omitted (`enum_member`, `method`, `field`, `property`, + * `variable`, `constant`, `function`, `parameter`, `import`, `export`, + * `file`, `route`) can never be a supertype in any supported language, so an + * inheritance edge pointing at one is false data. + * + * Why this is needed: the name-matcher scores node kind as a BONUS, + * never a filter, and awards no bonus at all for inheritance refs — so a + * same-named non-type outranked (or, as the sole candidate, was adopted + * outright as) the real supertype. Rust `use std::error::Error;` + `impl Error + * for MapperError {}` bound to the local `MapperError::Error` VARIANT. The + * supertype is out-of-repo and simply unresolvable; a failed ref is correct. + */ +export const SUPERTYPE_TARGET_KINDS = new Set([ + 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'union', + 'type_alias', 'component', 'module', 'namespace', +]); + +/** True for the reference kinds that assert an inheritance/conformance relation. */ +export function isInheritanceRef(ref: UnresolvedRef): boolean { + return ref.referenceKind === 'extends' || ref.referenceKind === 'implements'; +} From f010a09e0376bb87682d08e1cb22312398f714bd Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Mon, 10 Aug 2026 20:43:12 +0900 Subject: [PATCH 2/3] fix(resolution): an import never resolves to a member of a type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- CHANGELOG.md | 2 ++ ....test.ts => reference-target-kind.test.ts} | 27 +++++++++++++++++-- src/resolution/index.ts | 21 ++++++++++++--- src/resolution/name-matcher.ts | 8 ++++-- src/resolution/types.ts | 17 ++++++++++++ 5 files changed, 67 insertions(+), 8 deletions(-) rename __tests__/{inheritance-target-kind.test.ts => reference-target-kind.test.ts} (86%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e71348d92..96468f952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - An inheritance relationship is no longer invented between a type and an unrelated symbol that merely shares a name with its supertype. When a class or struct implemented something defined outside your project — a trait from the Rust standard library, an interface from an npm package — the graph would attach that relationship to whatever local symbol happened to have the same name: an enum variant, a type alias, even a function. On a Rust project of moderate size this fabricated a dozen implementation relationships, which then showed up in diagrams and in answers about who implements what. A supertype that isn't in your project is now simply left unresolved, and when several same-named symbols compete, only ones that can actually be a supertype are considered — so the real trait wins instead of losing to a same-named variant. Thanks @ctype-lab. Re-index after upgrading to clear the bad relationships from an existing project. +- An import no longer connects to a class property or an interface method that happens to share its name. Importing something the project doesn't contain — `path` from Node's standard library, a helper from an npm package — left the import looking for any symbol with that name, and a common word like `path`, `url` or `join` almost always matches a member somewhere. No language lets you import a member out of a type, so those connections are simply dropped now. Re-index after upgrading to clear them from an existing project. + - C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515) - A long-lived index no longer drifts away from what a fresh `codegraph index` would produce. When a file gained or lost a symbol, references to that name in files the sync never touched kept pointing at the definition that was correct before the change, and — because nothing distinguished two same-named definitions — the winner could come down to the order files happened to be written, which differs between a full index and a sync. On this project's own repository, replaying 80 commits through `sync` left 5.7% of connections wrong; it is now 1.3%, and the wrong-answers-still-being-asserted half drops by 99.7%. Since call edges are what flow questions follow and what `codegraph_explore` ranks files by, this quietly degraded answers as an index aged, with nothing to indicate it. Syncing is unchanged in speed, and an edit that only changes a function's body does no extra work at all. Set `CODEGRAPH_NO_REBIND=1` to opt out. diff --git a/__tests__/inheritance-target-kind.test.ts b/__tests__/reference-target-kind.test.ts similarity index 86% rename from __tests__/inheritance-target-kind.test.ts rename to __tests__/reference-target-kind.test.ts index 557304629..bc3894143 100644 --- a/__tests__/inheritance-target-kind.test.ts +++ b/__tests__/reference-target-kind.test.ts @@ -1,5 +1,5 @@ /** - * `extends`/`implements` target-kind gate. + * 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 @@ -23,7 +23,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { CodeGraph } from '../src'; -describe('inheritance target-kind gate', () => { +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 }); }); @@ -149,6 +149,29 @@ describe('inheritance target-kind gate', () => { expect(has(edges, 'LocalSearch', 'SearchApi', 'type_alias')).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', diff --git a/src/resolution/index.ts b/src/resolution/index.ts index cc3e58427..ac48b9fe7 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -17,6 +17,7 @@ import { ImportMapping, SUPERTYPE_TARGET_KINDS, isInheritanceRef, + isImportableKind, } from './types'; import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos } from './import-resolver'; @@ -2431,11 +2432,15 @@ export class ReferenceResolver { } /** - * Drop an `extends`/`implements` resolution that cannot be describing a real - * supertype. Applied at the `resolveOne` seam so it covers every strategy - * uniformly — framework, import, name-match, chain, CFML component path. + * Drop a resolution whose target cannot be what the reference names. + * Applied at the `resolveOne` seam so it covers every strategy uniformly — + * framework, import, name-match, chain, CFML component path. + * + * For `imports`: the target must be importable. A member that only exists + * inside a type never is. + * + * For `extends`/`implements`, it cannot be describing a real supertype when: * - * Two independent reasons to drop, and BOTH are needed: * 1. The target's kind can never be a supertype (an enum member, a method, * a variable). `matchByExactName` additionally narrows its candidate * pool by the same set, so a legitimate supertype outranks a same-named @@ -2450,6 +2455,14 @@ export class ReferenceResolver { */ private gateTargetKind(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null { if (!result) return result; + + // An `imports` reference names something importable — never a member that + // only exists inside a type. + if (ref.referenceKind === 'imports') { + const target = this.queries.getNodeById(result.targetNodeId); + return target && !isImportableKind(target.kind) ? null : result; + } + if (!isInheritanceRef(ref)) return result; const target = this.queries.getNodeById(result.targetNodeId); if (target && !SUPERTYPE_TARGET_KINDS.has(target.kind)) return null; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index b07711b03..6b83c0686 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -5,7 +5,7 @@ */ import { Language, Node } from '../types'; -import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef } from './types'; +import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef, isImportableKind } from './types'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will @@ -415,7 +415,11 @@ export function matchByExactName( // the real `trait`, and as the sole candidate was adopted outright by the // single-match shortcut. Restricting the pool BEFORE ranking lets the // legitimate supertype win instead of merely dropping the false edge. - .filter((n) => !isInheritanceRef(ref) || SUPERTYPE_TARGET_KINDS.has(n.kind)); + .filter((n) => !isInheritanceRef(ref) || SUPERTYPE_TARGET_KINDS.has(n.kind)) + // Likewise for `imports`: a member that only exists inside a type is not + // importable, so it is not a candidate. Without this a `path`/`id`/`url` + // import resolved to some interface's same-named property. + .filter((n) => ref.referenceKind !== 'imports' || isImportableKind(n.kind)); if (candidates.length === 0) { return null; diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 229bb3836..428e7828d 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -307,3 +307,20 @@ export const SUPERTYPE_TARGET_KINDS = new Set([ export function isInheritanceRef(ref: UnresolvedRef): boolean { return ref.referenceKind === 'extends' || ref.referenceKind === 'implements'; } + +/** + * Node kinds an `imports` edge may never TARGET: members that only exist + * INSIDE a type. No language lets you import a class's property, an + * interface's method or an enum's variant — you import the type that + * contains it. The name-matcher has no kind filter, so a bare + * `import path from 'node:path'` (unresolvable, since the module is external) + * name-matched an interface property called `path` in an unrelated file. + */ +const NON_IMPORTABLE_KINDS = new Set([ + 'property', 'field', 'method', 'enum_member', 'parameter', +]); + +/** Can an `imports` reference legally resolve to this node kind? */ +export function isImportableKind(kind: Node['kind']): boolean { + return !NON_IMPORTABLE_KINDS.has(kind); +} From 7f2035e198e82c9fb1b4d63fe62b63acb45435c9 Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Mon, 10 Aug 2026 21:08:19 +0900 Subject: [PATCH 3/3] fix(resolution): classify SFC script imports as ES module specifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `\n
hi
\n'], + ['vue', 'src/Box.vue', '\n\n'], + ['astro', 'src/Box.astro', '---\n$IMPORT$\nexport class SfcBox implements Serializable {\n n = 1;\n}\n---\n
\n'], + ])('drops an npm supertype in a %s single-file component', async (_lang, file, body) => { + // An SFC imports inside its \n
hi
\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 diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 2cdc573cc..e65ae40f6 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -287,6 +287,21 @@ const C_CPP_STDLIB_HEADERS = new Set([ 'version', ]); +/** + * Languages whose imports are ES-module specifiers, extracted by + * `extractJSImports` and therefore classified by the same bare-specifier / + * alias / workspace rules. Svelte, Vue and Astro belong here: an SFC imports + * inside its `