Просмотр исходного кода

fix(resolution): fuzzy reachability rejects a unique guess, never manufactures one

A function nested inside another function is only callable from inside its
container (#1230). matchByExactName already declined such candidates; the
fuzzy fallback did not, so a builtin method call (`res.text()`) whose only
same-named project symbol was some file's closure resolved onto that
closure at 0.5 (#1708).

and on vitejs/vite@8492422 that traded 12 correct removals for 59 wrong
additions: the repo has a dozen `resolve` definitions, most nested, so the
filter left exactly one reachable `resolve` method and the strategy
committed every `import { resolve } from 'node:path'` call in the
playground configs to it. Filtering a crowd down to one survivor is not
evidence the survivor was ever the target.

So the check sits on the ONE candidate matchFuzzy would commit to: a
unique candidate the call cannot reach is declined; a crowd stays a crowd.
Same tree, measured against this branch's own base b9ca4b7: 12 edges lost
(all fuzzy, all onto nested functions — the same 12 #1709 removes), 0
gained, fuzzy 13 -> 1, every other resolvedBy row at zero.

The two-file fixture is #1709's, credited in the previous commit; four
direct tests pin the shape: a lone unreachable closure declines, the same
closure resolves from inside its container, closure + method is ambiguous
and declines (the candidate-set filter fails exactly this one), a lone
reachable method resolves as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
danusha2345 2 дней назад
Родитель
Сommit
2521b49a9a
3 измененных файлов с 83 добавлено и 1 удалено
  1. 1 0
      CHANGELOG.md
  2. 71 0
      __tests__/fuzzy-lexical-reach.test.ts
  3. 11 1
      src/resolution/name-matcher.ts

+ 1 - 0
CHANGELOG.md

@@ -219,6 +219,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - **A definition its language makes file-local no longer captures calls from other files.** A C `static` in another source file (`.c`/`.cc`/… — not a header's `static inline`, which is textually included), a Kotlin/Java/C#/Swift/Scala/Dart/PHP `private` member, a Go unexported name in another package, and a Rust non-`pub` item outside its module subtree cannot be what a name in another file means, but name matching accepted them whenever the names agreed: an Android `editor.apply()` onto an unrelated class's `private fun apply`, a JavaScript `fail(...)` onto a Go `func fail`, a Rust `.count()` onto a private `fn count` in another crate, and C USB helpers onto a `static` in a `.c` they never link. Such a target is now declined after the whole name-matching pipeline settles — the reference stays unresolved rather than falling through to a fuzzy namesake. Same-file definitions, a child Rust module reaching its ancestors' private items, and Rust `impl Trait for Type` methods stay resolvable. Re-index after upgrading. (#1730, #1731)
 - **A binding in a module that exports nothing is no longer a cross-file target.** On vite, every `import { defineConfig } from 'vite'` across the playground resolved onto a `const vite = await createServer(…)` sitting at module scope in `playground/ssr-html/test-stacktrace.js` — a file with an import and no export, so that binding is reachable from nowhere but itself. Name matching commits as soon as one candidate survives, and nothing asked whether an import could reach the survivor; that one binding took 157 edges. A JS/TS file holding an `import` and no export of any kind now offers its locals to no other file. Classic scripts, CommonJS (including `exports["x"] = …`), a later `export { … }`, and names contributed through `declare global` are all unaffected. Across vite this removed 320 wrong edges and added 18, each addition a reference that was previously ambiguous rather than newly invented. Re-index after upgrading. (#1719)
 - **A bare call inside a JavaScript or TypeScript method no longer resolves to the method itself.** When a method and a module-scope function share a name, `serialize(this.raw)` written inside `Record.serialize` means the function, but the nearest same-named definition won the tie and the graph recorded the method calling itself. A call written without a receiver can never reach a method in JS/TS, so methods are no longer candidates for it; `this.serialize()` and `other.serialize()` resolve as before. (#1714)
+- **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.
 

+ 71 - 0
__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<Node> & Pick<Node, 'id' | 'kind' | 'name' | 'filePath'>): 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');
+  });
+});

+ 11 - 1
src/resolution/name-matcher.ts

@@ -2799,13 +2799,23 @@ export function matchFuzzy(
   // a lone one and manufacture a 0.5 guess out of an ambiguity fuzzy declines.
   // Also decline a bare JS/TS call whose only survivor is a method or a
   // cross-file name the file already binds locally (#1714).
+  // 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 &&
     isVisibleAcrossFiles(finalCandidates[0]!, ref, context) &&
     isCrossFileReachable(finalCandidates[0]!, ref, context) &&
     !(isBareJsCall(ref, context) &&
       (finalCandidates[0]!.kind === 'method' ||
-        (finalCandidates[0]!.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context))))
+        (finalCandidates[0]!.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)))) &&
+    isLexicallyReachable(finalCandidates[0]!, ref, context)
   ) {
     const isCrossLanguage = finalCandidates[0]!.language !== ref.language;
     return {