From 3d9352c7aceada275f4c743a9068f540e3dbf0a8 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 0000000..26c0865 --- /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 2521b49a9a427aff88a2d833b77a7f81292a1072 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). 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 | 1 + __tests__/fuzzy-lexical-reach.test.ts | 71 +++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 12 ++++- 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d0e38..ae57cd6 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/__tests__/fuzzy-lexical-reach.test.ts b/__tests__/fuzzy-lexical-reach.test.ts index 26c0865..964c348 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 2ce958e..82e567a 100644 --- a/src/resolution/name-matcher.ts +++ b/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 { From 7c758aaf0ad877155d6b5e12e8caaa1e1741694d 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 964c348..b0ff1c4 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 82e567a..943b2d7 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -354,6 +354,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. @@ -371,6 +374,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('::'));