diff --git a/CHANGELOG.md b/CHANGELOG.md index 1815c4150..a5ebb7ed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,6 +201,7 @@ 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 filtered 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 at confidence 0.5. The fallback now applies the same reachability filter. On a 584-file repo that removed the 11 fuzzy edges onto nested functions and nothing else. Re-index after upgrading. - **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 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); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272..a49dd553b 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2412,7 +2412,10 @@ export function matchFuzzy( // Filter to callable kinds only (function, method, class) const callableKinds = new Set(['function', 'method', 'class']); - const callableCandidates = applyLanguageGate(candidates.filter((n) => callableKinds.has(n.kind)), ref); + const callableCandidates = applyLanguageGate( + candidates.filter((n) => callableKinds.has(n.kind) && isLexicallyReachable(n, ref, context)), + ref + ); // Prefer same-language matches const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);