From 5abdf252f059c3f77a361b45ab58c16240bf08fc Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 16:10:31 +0300 Subject: [PATCH] fix(resolution): prefer the caller's own copy of a duplicated module When a bare name matches several copies of the same symbol (same qualified name, kind and file basename) in sibling modules, capped directory proximity can tie them on deep trees and the first-indexed copy won. Break such ties toward the copy sharing the longest directory prefix with the caller, and only when it is uniquely closest; otherwise the prior choice stands. A match picked as the caller's own copy of one symbol gets confidence 0.8 (the name is unambiguous, only the copy is), between the 0.7 proximity guess and the 0.9 unique-name match. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/same-module-copy-resolution.test.ts | 96 +++++++++++++++++++ src/resolution/name-matcher.ts | 61 +++++++++++- 3 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 __tests__/same-module-copy-resolution.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 17022e6343..9730e1505e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- When a project keeps copies of the same code in several modules, a call now links to the copy in the caller's own module, even in deeply nested trees where it could previously land on another module's copy. - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) diff --git a/__tests__/same-module-copy-resolution.test.ts b/__tests__/same-module-copy-resolution.test.ts new file mode 100644 index 0000000000..5a1c31d773 --- /dev/null +++ b/__tests__/same-module-copy-resolution.test.ts @@ -0,0 +1,96 @@ +/** + * A module duplicated under another source root (`rc2/…` vs `rcpro2/…`) gives + * every bare name two same-named candidates, one per copy. Directory proximity + * is capped, so on a deep tree both copies score the same and the first-indexed + * one used to win — a call in module B could land on module A's copy. The + * caller's own copy, when it is uniquely closest, must win; when no copy is + * closer (the caller sits above both), behavior is unchanged. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; + +// Six shared segments already reach the proximity cap. +const ROOT = 'a/b/c/d/e/f'; + +function write(dir: string, rel: string, lines: string[]): void { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, lines.join('\n') + '\n'); +} + +describe('exact-name resolution prefers the caller\'s own copy of a duplicated module', () => { + let tempDir: string; + let cg: CodeGraph | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-same-module-')); + for (const mod of ['rc2', 'rcpro2']) { + write(tempDir, `${ROOT}/${mod}/util.py`, ['def compute():', ' return 1']); + write(tempDir, `${ROOT}/${mod}/main.py`, ['def run():', ' return compute()']); + } + write(tempDir, `${ROOT}/rc2/only.py`, ['def solo():', ' return 2']); + write(tempDir, `${ROOT}/rc2/solo_caller.py`, ['def use_solo():', ' return solo()']); + // Equidistant from both copies. + write(tempDir, `${ROOT}/top.py`, ['def top():', ' return compute()']); + }); + + afterEach(() => { + cg?.destroy(); + cg = null; + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway. + } + }); + + async function callsFrom(fnName: string, filePath: string) { + const caller = cg!.getNodesByKind('function').find((n) => n.name === fnName && n.filePath === filePath); + expect(caller).toBeDefined(); + return cg!.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls'); + } + + function fn(name: string, filePath: string) { + const node = cg!.getNodesByKind('function').find((n) => n.name === name && n.filePath === filePath); + expect(node).toBeDefined(); + return node!; + } + + it('resolves each module\'s call to its own copy', async () => { + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const calls = await Promise.all( + ['rc2', 'rcpro2'].map((mod) => callsFrom('run', `${ROOT}/${mod}/main.py`)), + ); + expect(calls.map((c) => c.map((e) => e.target))).toEqual( + ['rc2', 'rcpro2'].map((mod) => [fn('compute', `${ROOT}/${mod}/util.py`).id]), + ); + expect(calls.map((c) => c[0]!.metadata?.confidence)).toEqual([0.8, 0.8]); + }); + + it('keeps the prior choice and confidence when the caller is equidistant', async () => { + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const calls = await callsFrom('top', `${ROOT}/top.py`); + expect(calls).toHaveLength(1); + const copies = ['rc2', 'rcpro2'].map((m) => fn('compute', `${ROOT}/${m}/util.py`).id); + expect(copies).toContain(calls[0]!.target); + expect(calls[0]!.metadata?.confidence).toBe(0.7); + }); + + it('leaves a single-candidate name unaffected', async () => { + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const calls = await callsFrom('use_solo', `${ROOT}/rc2/solo_caller.py`); + expect(calls).toHaveLength(1); + expect(calls[0]!.target).toBe(fn('solo', `${ROOT}/rc2/only.py`).id); + expect(calls[0]!.metadata?.confidence).toBe(0.9); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 6b582f6d9c..7b103bb6cd 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -12049,9 +12049,12 @@ export function matchByExactName( if (bestMatch && isCrossFileReachable(bestMatch, ref, context)) { // Lower confidence when the match is from a distant/unrelated module const proximity = computePathProximity(ref.filePath, bestMatch.filePath); + // Every candidate is a copy of one symbol and this is the caller's own + // copy: the name itself is not ambiguous, only which copy — so rank it + // above a proximity guess, but below a unique name (0.9). const confidence = isDecisiveSameFileMatch(bestMatch, ref, candidates, context) ? 0.9 - : proximity >= 30 ? 0.7 : 0.4; + : closestCopy(ref, candidates) === bestMatch ? 0.8 : proximity >= 30 ? 0.7 : 0.4; return { original: ref, targetNodeId: bestMatch.id, @@ -26798,6 +26801,7 @@ function findBestMatch( let bestScore = -1; let bestNode: Node | null = null; + let tied: Node[] = []; // Split the ref's path once (it's the same across every candidate) instead of // re-splitting it inside computePathProximity per candidate (#915 hot spot). @@ -26888,10 +26892,63 @@ function findBestMatch( if (score > bestScore) { bestScore = score; bestNode = candidate; + tied = [candidate]; + } else if (score === bestScore) { + tied.push(candidate); } } - return bestNode; + // Proximity is capped, so on deep trees two copies of the same symbol in + // sibling modules can tie; the caller's own copy, if uniquely closest, wins. + return (tied.length > 1 && closestCopy(ref, tied)) || bestNode; +} + +/** + * Number of leading directory segments two file paths share (uncapped). + */ +function sharedDirDepth(filePath1: string, filePath2: string): number { + const a = filePath1.split('/'); + const b = filePath2.split('/'); + a.pop(); + b.pop(); + let shared = 0; + while (shared < a.length && shared < b.length && a[shared] === b[shared]) shared++; + return shared; +} + +/** + * When every candidate is a copy of the same symbol — same qualified name, kind + * and file basename, as when a module is duplicated under another source root + * (`rc2/flymod/src/…` vs `rcpro2/flymod/src/…`) — return the copy whose file + * shares the longest directory prefix with the reference, i.e. the one in the + * caller's own module. Returns null when the candidates are not all copies or + * no copy is uniquely closest, so callers keep their existing choice. + */ +function closestCopy(ref: UnresolvedRef, candidates: Node[]): Node | null { + if (candidates.length < 2) return null; + const first = candidates[0]!; + const base = path.posix.basename(first.filePath); + let best: Node | null = null; + let bestDepth = -1; + let unique = false; + for (const c of candidates) { + if ( + c.qualifiedName !== first.qualifiedName || + c.kind !== first.kind || + path.posix.basename(c.filePath) !== base + ) { + return null; + } + const depth = sharedDirDepth(ref.filePath, c.filePath); + if (depth > bestDepth) { + bestDepth = depth; + best = c; + unique = true; + } else if (depth === bestDepth) { + unique = false; + } + } + return unique ? best : null; } /**