From 8d48504ce184c3e3b970be9d52c44ab71959d1eb Mon Sep 17 00:00:00 2001 From: Mubashir Rahim Date: Thu, 4 Jun 2026 14:56:59 +0500 Subject: [PATCH] fix(resolution): same-dir component preference must use a path boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The React and Svelte framework resolvers disambiguate same-named components by preferring one in the referencing file's directory: const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/')); const sameDir = components.filter((n) => n.filePath.startsWith(fromDir)); `startsWith(fromDir)` has no trailing-separator boundary, so a component in a SIBLING directory whose name merely shares a prefix (`src/ui-kit/` vs `src/ui/`) is treated as same-directory and can win the tie — the reference resolves to the wrong component (a wrong graph edge). Compare the directory portions exactly instead. A small `dirOf` helper returns the directory (or '' for a root-level file, fixing the `lastIndexOf('/') === -1` edge where `substring(0, -1)` yielded '' and made `startsWith('')` match every file). Co-Authored-By: Claude Opus 4.8 --- __tests__/framework-samedir-boundary.test.ts | 132 +++++++++++++++++++ src/resolution/frameworks/react.ts | 20 ++- src/resolution/frameworks/svelte.ts | 20 ++- 3 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 __tests__/framework-samedir-boundary.test.ts diff --git a/__tests__/framework-samedir-boundary.test.ts b/__tests__/framework-samedir-boundary.test.ts new file mode 100644 index 000000000..9487e8667 --- /dev/null +++ b/__tests__/framework-samedir-boundary.test.ts @@ -0,0 +1,132 @@ +/** + * Regression: "prefer same directory" component resolution must compare + * directories at a path-segment boundary, not as a raw string prefix. + * + * The React/Vue/Svelte framework resolvers disambiguate same-named + * components by preferring one in the referencing file's directory: + * + * const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/')); + * const sameDir = components.filter((n) => n.filePath.startsWith(fromDir)); + * + * `startsWith(fromDir)` has no trailing-separator boundary, so a file in a + * SIBLING directory whose name merely shares a prefix (`src/ui-kit/...` + * vs `src/ui/...`) is wrongly treated as same-directory and can win the + * tie — resolving a reference to the wrong component (a wrong graph edge). + */ + +import { describe, it, expect } from 'vitest'; +import { reactResolver } from '../src/resolution/frameworks/react'; +import { svelteResolver } from '../src/resolution/frameworks/svelte'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; +import type { Node, Language } from '../src/types'; + +function makeComponent( + id: string, + name: string, + filePath: string, + language: Language = 'typescript' +): Node { + return { + id, + kind: 'component', + name, + qualifiedName: `${filePath}::${name}`, + filePath, + language, + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; +} + +/** Minimal context: only the methods resolveComponent touches need to work. */ +function makeContext(nodesByName: Record): ResolutionContext { + return { + getNodesByName: (name: string) => nodesByName[name] ?? [], + getNodesInFile: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/repo', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; +} + +describe('framework same-directory preference uses a path boundary', () => { + it('does not treat a prefix-sibling directory as the same directory', () => { + // Reference site lives in src/ui/. Two components named Widget exist: + // the real same-dir one in src/ui/, and a decoy in the prefix-sibling + // src/ui-kit/. The decoy is listed FIRST so a raw-prefix match returns + // it instead of the true same-directory component. + const trueSameDir = makeComponent('id-ui', 'Widget', 'src/ui/Widget.tsx'); + const prefixSibling = makeComponent('id-uikit', 'Widget', 'src/ui-kit/Widget.tsx'); + + const context = makeContext({ Widget: [prefixSibling, trueSameDir] }); + + const ref: UnresolvedRef = { + fromNodeId: 'caller', + referenceName: 'Widget', + referenceKind: 'references', + line: 5, + column: 0, + filePath: 'src/ui/Card.tsx', + language: 'typescript', + }; + + const resolved = reactResolver.resolve(ref, context); + expect(resolved).not.toBeNull(); + // Must resolve to the component actually in src/ui/, never the + // src/ui-kit/ sibling that only shares a string prefix. + expect(resolved!.targetNodeId).toBe('id-ui'); + }); + + it('still prefers a true same-directory component over an unrelated one', () => { + // Sanity: the same-directory preference itself still works after the fix. + const sameDir = makeComponent('id-same', 'Panel', 'src/ui/Panel.tsx'); + const elsewhere = makeComponent('id-other', 'Panel', 'src/widgets/Panel.tsx'); + + // List the unrelated one first; the same-dir one must still win. + const context = makeContext({ Panel: [elsewhere, sameDir] }); + + const ref: UnresolvedRef = { + fromNodeId: 'caller', + referenceName: 'Panel', + referenceKind: 'references', + line: 5, + column: 0, + filePath: 'src/ui/Card.tsx', + language: 'typescript', + }; + + const resolved = reactResolver.resolve(ref, context); + expect(resolved).not.toBeNull(); + expect(resolved!.targetNodeId).toBe('id-same'); + }); + + it('Svelte: prefix-sibling directory is not the same directory', () => { + const trueSameDir = makeComponent('sv-ui', 'Widget', 'src/ui/Widget.svelte', 'svelte'); + const prefixSibling = makeComponent('sv-uikit', 'Widget', 'src/ui-kit/Widget.svelte', 'svelte'); + + const context = makeContext({ Widget: [prefixSibling, trueSameDir] }); + + // Svelte dispatches component resolution on `calls` refs. + const ref: UnresolvedRef = { + fromNodeId: 'caller', + referenceName: 'Widget', + referenceKind: 'calls', + line: 5, + column: 0, + filePath: 'src/ui/Card.svelte', + language: 'svelte', + }; + + const resolved = svelteResolver.resolve(ref, context); + expect(resolved).not.toBeNull(); + expect(resolved!.targetNodeId).toBe('sv-ui'); + }); +}); diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index d60aef40f..e3c2818b7 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -279,6 +279,17 @@ const BUILT_IN_TYPES = new Set([ const COMPONENT_KINDS = new Set(['component', 'function', 'class']); +/** + * Directory portion of a posix-style path, or '' for a root-level file. + * `src/ui/Card.tsx` -> `src/ui`; `App.tsx` -> ''. Used for exact + * same-directory comparison (vs a raw `startsWith`, which matches + * prefix-sibling directories like `src/ui-kit` for `src/ui`). + */ +function dirOf(filePath: string): string { + const slash = filePath.lastIndexOf('/'); + return slash >= 0 ? filePath.slice(0, slash) : ''; +} + /** * Resolve a component reference using name-based lookup */ @@ -293,9 +304,12 @@ function resolveComponent( const components = candidates.filter((n) => COMPONENT_KINDS.has(n.kind)); if (components.length === 0) return null; - // Prefer same directory - const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/')); - const sameDir = components.filter((n) => n.filePath.startsWith(fromDir)); + // Prefer same directory. Compare directory paths exactly — a raw + // `startsWith(fromDir)` would treat a prefix-sibling directory + // (`src/ui-kit/` for `src/ui/`) as the same directory and resolve to + // the wrong component. + const fromDir = dirOf(fromFile); + const sameDir = components.filter((n) => dirOf(n.filePath) === fromDir); if (sameDir.length > 0) return sameDir[0]!.id; // Prefer component directories diff --git a/src/resolution/frameworks/svelte.ts b/src/resolution/frameworks/svelte.ts index 8848c8576..96b28efd6 100644 --- a/src/resolution/frameworks/svelte.ts +++ b/src/resolution/frameworks/svelte.ts @@ -201,6 +201,17 @@ function isPascalCase(str: string): boolean { return /^[A-Z][a-zA-Z0-9]*$/.test(str); } +/** + * Directory portion of a posix-style path, or '' for a root-level file. + * `src/ui/Card.svelte` -> `src/ui`; `App.svelte` -> ''. Used for exact + * same-directory comparison (vs a raw `startsWith`, which matches + * prefix-sibling directories like `src/ui-kit` for `src/ui`). + */ +function dirOf(filePath: string): string { + const slash = filePath.lastIndexOf('/'); + return slash >= 0 ? filePath.slice(0, slash) : ''; +} + /** * Resolve a Svelte component reference using name-based lookup */ @@ -215,9 +226,12 @@ function resolveComponent( if (components.length === 0) return null; - // Prefer same directory - const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/')); - const sameDir = components.filter((n) => n.filePath.startsWith(fromDir)); + // Prefer same directory. Compare directory paths exactly — a raw + // `startsWith(fromDir)` would treat a prefix-sibling directory + // (`src/ui-kit/` for `src/ui/`) as the same directory and resolve to + // the wrong component. + const fromDir = dirOf(fromFile); + const sameDir = components.filter((n) => dirOf(n.filePath) === fromDir); if (sameDir.length > 0) return sameDir[0]!.id; return components[0]!.id;