diff --git a/CHANGELOG.md b/CHANGELOG.md index 1815c4150..b8aa0c397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,6 +201,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- **Fuzzy matching no longer lands on a closure it cannot reach.** A function nested inside another function is only callable from inside its container, and exact-name matching already declined such candidates; the fuzzy fallback did not, so a builtin method call (`res.text()`, `items.push()`) whose only same-named project symbol was some file's closure resolved onto that closure. The fallback now checks that the one candidate it would commit to is reachable, and declines otherwise — it does not filter the candidate list first, which would turn a crowd of same-named definitions into a single "unique" survivor and hand it every call of that name. On vite that removes the 12 edges onto nested functions and adds none. Re-index after upgrading. Thanks @bompus. (#1708, #1709) + - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges. - **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does. diff --git a/__tests__/fuzzy-lexical-reach.test.ts b/__tests__/fuzzy-lexical-reach.test.ts index 26c086558..964c348e4 100644 --- a/__tests__/fuzzy-lexical-reach.test.ts +++ b/__tests__/fuzzy-lexical-reach.test.ts @@ -10,6 +10,9 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; +import { matchFuzzy } from '../src/resolution/name-matcher'; +import type { Node } from '../src/types'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; describe('fuzzy matching respects lexical reachability of nested functions', () => { let tempDir: string; @@ -71,3 +74,71 @@ describe('fuzzy matching respects lexical reachability of nested functions', () expect(inside.map((e) => e.target)).toContain(closure!.id); }); }); + +/** + * The reachability check must sit on the one candidate matchFuzzy would + * commit to, never on the candidate set. Filtering a crowd of same-named + * definitions down to the reachable ones leaves a single survivor, and the + * strategy then hands it every call of that name: vite has a dozen `resolve` + * definitions, most nested, and one reachable `resolve` method inherited 59 + * `import { resolve } from 'node:path'` calls that way (#1709). Driven + * directly, so the shape is pinned regardless of what the earlier strategies + * make of a given fixture. + */ +describe('fuzzy reachability rejects a unique guess but never manufactures one', () => { + const node = (partial: Partial & Pick): Node => ({ + qualifiedName: partial.name, + language: 'typescript', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + ...partial, + }); + // build.ts: function build() { const resolve = …; function resolve() {} } + const container = node({ id: 'f:build', kind: 'function', name: 'build', filePath: 'build.ts', startLine: 1, endLine: 40 }); + const closure = node({ id: 'f:build.resolve', kind: 'function', name: 'resolve', qualifiedName: 'build::resolve', filePath: 'build.ts', startLine: 10, endLine: 12 }); + // pluginContainer.ts: class PluginContainer { resolve() {} } + const method = node({ id: 'm:resolve', kind: 'method', name: 'resolve', qualifiedName: 'PluginContainer::resolve', filePath: 'pluginContainer.ts', startLine: 5, endLine: 9 }); + const contextWith = (nodes: Node[]): ResolutionContext => + ({ + getNodesInFile: () => [], + getNodesByName: (name: string) => nodes.filter((n) => n.name === name), + getNodesByLowerName: (name: string) => nodes.filter((n) => n.name.toLowerCase() === name), + getNodesByQualifiedName: (qn: string) => [container].filter((n) => n.qualifiedName === qn), + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getFileLines: () => [], + getProjectRoot: () => '', + getAllFiles: () => [], + getImportMappings: () => [], + }) as unknown as ResolutionContext; + const callFrom = (filePath: string, line: number): UnresolvedRef => ({ + fromNodeId: 'f:caller', + referenceName: 'resolve', + referenceKind: 'calls', + line, + column: 2, + filePath, + language: 'typescript', + }); + + it('declines the sole candidate when it is a closure the call cannot reach', () => { + expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([closure]))).toBeNull(); + }); + + it('still resolves the sole candidate from inside its container', () => { + expect(matchFuzzy(callFrom('build.ts', 20), contextWith([closure]))?.targetNodeId).toBe('f:build.resolve'); + }); + + it('does not let the unreachable closure drop out and leave the method as a "unique" match', () => { + // Two same-named callables: ambiguous, exactly as before the check existed. + expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([closure, method]))).toBeNull(); + }); + + it('resolves a lone reachable method as before', () => { + expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([method]))?.targetNodeId).toBe('m:resolve'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272..11a5a3d32 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2418,7 +2418,16 @@ export function matchFuzzy( const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language); const finalCandidates = sameLanguageCandidates.length > 0 ? sameLanguageCandidates : callableCandidates; - if (finalCandidates.length === 1) { + // A function nested inside another function is only callable from inside + // its container (#1230), so a builtin method call (`res.text()`) whose only + // same-named project symbol is some file's closure must decline (#1708). + // The check sits on the ONE candidate this strategy would commit to, not on + // the candidate set: filtering the unreachable ones out of a crowd would + // leave a single survivor and hand it every call of that name — on vite, + // `import { resolve } from 'node:path'` in a dozen playground configs onto + // the one reachable `resolve` method (#1709). Reachability may reject a + // unique guess; it must never manufacture one. + if (finalCandidates.length === 1 && isLexicallyReachable(finalCandidates[0]!, ref, context)) { const isCrossLanguage = finalCandidates[0]!.language !== ref.language; return { original: ref,