Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
144 changes: 144 additions & 0 deletions __tests__/fuzzy-lexical-reach.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* 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';
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;
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<string> {',
' // 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);
});
});

/**
* 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 changes: 10 additions & 1 deletion src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down