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; } /**