From 8385e60abc522a0b224df0c9a32d2a9d3d4711a8 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 6 Sep 2026 13:25:04 +0300 Subject: [PATCH 1/3] test(resolution): a builtin method call must not land on another file's closure The two-file reachability fixture from #1709: a `function text()` nested in one file, a `settled.value.text()` call in another. The caller must not get a `calls` edge onto the closure; the in-container call still resolves. --- __tests__/fuzzy-lexical-reach.test.ts | 73 +++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 __tests__/fuzzy-lexical-reach.test.ts diff --git a/__tests__/fuzzy-lexical-reach.test.ts b/__tests__/fuzzy-lexical-reach.test.ts new file mode 100644 index 000000000..26c086558 --- /dev/null +++ b/__tests__/fuzzy-lexical-reach.test.ts @@ -0,0 +1,73 @@ +/** + * A function nested inside another function is only callable from inside its + * container. matchByExactName already filters candidates that way; matchFuzzy + * must too, or a call to a builtin method (`res.text()`) whose only same-named + * project symbol is some file's closure resolves onto that closure. + */ + +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'; + +describe('fuzzy matching respects lexical reachability of nested functions', () => { + let tempDir: string; + let cg: CodeGraph | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-fuzzy-reach-')); + }); + + 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. + } + }); + + it('does not resolve a builtin method call onto another file\'s closure of the same name', async () => { + fs.writeFileSync( + path.join(tempDir, 'seed.ts'), + [ + 'export function readSeedState(raw: string): string {', + ' function text(): string {', + ' return raw.trim();', + ' }', + ' return text();', + '}', + '', + ].join('\n') + ); + fs.writeFileSync( + path.join(tempDir, 'fetch.ts'), + [ + 'export async function readOkText(settled: { value: Response }): Promise {', + ' // A chained receiver reaches the resolver as the bare method name.', + ' return settled.value.text();', + '}', + '', + ].join('\n') + ); + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const closure = cg + .getNodesByKind('function') + .find((n) => n.name === 'text' && n.filePath === 'seed.ts'); + const caller = cg.getNodesByKind('function').find((n) => n.name === 'readOkText'); + expect(closure).toBeDefined(); + expect(caller).toBeDefined(); + + const fromCaller = cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls'); + expect(fromCaller.map((e) => e.target)).not.toContain(closure!.id); + + // The in-container call still resolves. + const container = cg.getNodesByKind('function').find((n) => n.name === 'readSeedState'); + const inside = cg.getOutgoingEdges(container!.id).filter((e) => e.kind === 'calls'); + expect(inside.map((e) => e.target)).toContain(closure!.id); + }); +}); From 4c8f165c866e31e9a285ff25ace317f0dc6dcede Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sun, 6 Sep 2026 13:26:44 +0300 Subject: [PATCH 2/3] fix(resolution): fuzzy reachability rejects a unique guess, never manufactures one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). #1709 fixed that by filtering the candidate set the way exact-match does, 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 --- CHANGELOG.md | 2 + __tests__/fuzzy-lexical-reach.test.ts | 71 +++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 11 ++++- 3 files changed, 83 insertions(+), 1 deletion(-) 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, From 5eb575026446ffaae7e9fdfa3286ce9628db7ee3 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Mon, 7 Sep 2026 09:53:37 +0300 Subject: [PATCH 3/3] fix(resolution): C and C++ nesting is never a scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isLexicallyReachable trusted the graph's nesting for every language. C and C++ have no nested named functions, so a function shown inside another is an extraction artifact: tree-sitter-c cannot parse a macro call whose arguments are designated initializers — betaflight's RESET_CONFIG(pidProfile_t, pidProfile, .pid = { … }, …); — and its error recovery runs the enclosing function_definition (source lines 168–309) to line 1667, nesting the 45 functions after it. That tree has 310 such functions in 73 files. Before this commit exact-match already rejected them as unreachable and the fuzzy fallback picked them up at 0.5; with the survivor-side check alone, fuzzy rejected them too and 117 real calls into pid.c disappeared (base → 4c8f165 on the 2,109-file betaflight fork: LOST 117, GAINED 0, all fuzzy, all pid.c). With the gate the same tree is LOST 117 fuzzy / GAINED 117 exact-match — the identical edges, now resolved by the strategy that should have had them, at 0.9. vite (no C) is unchanged. Co-Authored-By: Claude Fable 5.1 --- __tests__/fuzzy-lexical-reach.test.ts | 9 +++++++++ src/resolution/name-matcher.ts | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/__tests__/fuzzy-lexical-reach.test.ts b/__tests__/fuzzy-lexical-reach.test.ts index 964c348e4..b0ff1c4e4 100644 --- a/__tests__/fuzzy-lexical-reach.test.ts +++ b/__tests__/fuzzy-lexical-reach.test.ts @@ -138,6 +138,15 @@ describe('fuzzy reachability rejects a unique guess but never manufactures one', expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([closure, method]))).toBeNull(); }); + it('trusts no nesting in C, where a nested function is an extraction artifact', () => { + // betaflight: tree-sitter-c's recovery from `RESET_CONFIG(…, .pid = {…})` + // runs resetPidProfile to the end of pid.c, so every function after it is + // "nested" in the graph. C has no nested named functions; the call reaches it. + const cClosure = node({ ...closure, id: 'f:c', language: 'c' as Node['language'], filePath: 'pid.c' }); + const cRef = { ...callFrom('core.c', 3), language: 'c' as UnresolvedRef['language'] }; + expect(matchFuzzy(cRef, contextWith([cClosure]))?.targetNodeId).toBe('f:c'); + }); + 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 11a5a3d32..c2a3e313a 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -352,6 +352,9 @@ export function matchFunctionRef( return null; } +/** Languages with no nested named functions: nesting in the graph is never a scope. */ +const NO_NESTED_FUNCTIONS = new Set(['c', 'cpp']); + /** * A function nested inside another FUNCTION is only callable from within its * container — Python, JS/TS, and every closure language scope it lexically. @@ -369,6 +372,14 @@ function isLexicallyReachable( context: ResolutionContext ): boolean { if (candidate.kind !== 'function') return true; + // C and C++ have no nested named functions, so a function the graph shows + // inside another is an extraction artifact, not a scope: tree-sitter-c + // cannot parse a macro call whose arguments are designated initializers + // (betaflight's `RESET_CONFIG(pidProfile_t, pidProfile, .pid = {…})`), and + // its error recovery runs the enclosing function_definition to the end of + // the file, nesting every function after it. Trusting that nesting rejected + // 117 real calls into pid.c on that tree; the functions are reachable. + if (NO_NESTED_FUNCTIONS.has(candidate.language)) return true; const qn = candidate.qualifiedName; if (!qn || !qn.includes('::')) return true; const parentQn = qn.slice(0, qn.lastIndexOf('::'));