mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 22:43:17 +08:00
perf(resolution): read a bare JS/TS call's local binding once per match — graph byte-identical
matchByExactName asked isLocallyBoundJsName for every cross-file candidate of a bare JavaScript or TypeScript call, though the answer depends only on the reference's name and file, which are the same for every candidate. The helper memoizes it per file and name, but each call still built the key and read the memo. The answer is now kept in a lazy local for the one match, at the same place in the filter, so candidates, order, scoring and confidence are untouched. Validation: fresh indexes of codegraph's own source, excalidraw, vben, express and zod dump byte-identical to main's (nodes, edges with their metadata, unresolved refs, files), and express stays identical through sync after a JS file is added, edited and deleted. A CPU profile of indexing codegraph's own source (all threads, two runs per side) puts isLocallyBoundJsName's self time at 150-173 ms on main and 45-46 ms here; the scans it guards (bindsAtSites) are unchanged. Whole-index CPU on vben, 10 interleaved runs, moves less than its noise. The PR's 8 tests pin the matcher's behaviour and pass here and on main. Original PR #2871 by @luojiyin1987. Fixes #2870 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Colby Mchenry
co-authored by
Claude Opus 5.5
parent
2afee00cb0
commit
0174f804c3
@@ -0,0 +1,97 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { CodeGraph } from '../src';
|
||||||
|
import { matchByExactName } from '../src/resolution/name-matcher';
|
||||||
|
import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
|
||||||
|
import type { Node } from '../src/types';
|
||||||
|
|
||||||
|
function node(id: string, filePath = 'lib/other.js', extra: Partial<Node> = {}): Node {
|
||||||
|
return { id, filePath, kind: 'function', name: 'target', qualifiedName: 'target', language: 'javascript',
|
||||||
|
startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0, ...extra };
|
||||||
|
}
|
||||||
|
function context(nodes: Node[], source = 'target();'): ResolutionContext {
|
||||||
|
return {
|
||||||
|
getNodesByName: name => nodes.filter(n => n.name === name),
|
||||||
|
getNodesByLowerName: name => nodes.filter(n => n.name.toLowerCase() === name),
|
||||||
|
getNodesByQualifiedName: name => nodes.filter(n => n.qualifiedName === name),
|
||||||
|
getNodesByKind: kind => nodes.filter(n => n.kind === kind),
|
||||||
|
getNodesInFile: file => nodes.filter(n => n.filePath === file),
|
||||||
|
getImportMappings: () => [], getAllFiles: () => [], getProjectRoot: () => '/project',
|
||||||
|
fileExists: () => true,
|
||||||
|
readFile: file => file === 'lib/caller.js' ? source : 'function target() {}',
|
||||||
|
getFileLines: file => (file === 'lib/caller.js' ? source : 'function target() {}').split('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ref: UnresolvedRef = { fromNodeId: 'caller', referenceName: 'target', referenceKind: 'calls',
|
||||||
|
filePath: 'lib/caller.js', language: 'javascript', line: 1, column: 0 };
|
||||||
|
|
||||||
|
describe('exact-name binding checks', () => {
|
||||||
|
it('keeps zero and one candidate outcomes', () => {
|
||||||
|
expect(matchByExactName(ref, context([]))).toBeNull();
|
||||||
|
expect(matchByExactName(ref, context([node('one')]))).toEqual({
|
||||||
|
original: ref, targetNodeId: 'one', confidence: 0.9, resolvedBy: 'exact-match',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('keeps the first candidate when scores tie', () => {
|
||||||
|
const a = node('a', 'lib/a.js'), b = node('b', 'lib/b.js');
|
||||||
|
for (const nodes of [[a, b], [b, a]]) {
|
||||||
|
expect(matchByExactName(ref, context(nodes))).toEqual({
|
||||||
|
original: ref, targetNodeId: nodes[0]!.id, confidence: 0.4, resolvedBy: 'exact-match',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('keeps the same-file winner in a cross-file collision', () => {
|
||||||
|
expect(matchByExactName(ref, context([node('other'), node('own', ref.filePath)]))?.targetNodeId).toBe('own');
|
||||||
|
});
|
||||||
|
it('rejects every cross-file candidate when a parameter binds the name', () => {
|
||||||
|
expect(matchByExactName({ ...ref, line: 2, column: 2 }, context([node('a'), node('b', 'lib/b.js')],
|
||||||
|
'function caller(target) {\n target();\n}'))).toBeNull();
|
||||||
|
});
|
||||||
|
it.each(['javascript', 'typescript'] as const)('rejects import and member nodes for bare %s calls', language => {
|
||||||
|
const nodes = [node('import', 'lib/i.js', { kind: 'import', language }),
|
||||||
|
node('member', 'lib/m.js', { kind: 'method', qualifiedName: 'Service::target', language }),
|
||||||
|
node('function', 'lib/f.js', { language })];
|
||||||
|
expect(matchByExactName({ ...ref, language }, context(nodes))?.targetNodeId).toBe('function');
|
||||||
|
});
|
||||||
|
it('does not retain the binding result between references', () => {
|
||||||
|
const nodes = [node('a'), node('b', 'lib/b.js')];
|
||||||
|
expect(matchByExactName(ref, context(nodes))?.targetNodeId).toBe('a');
|
||||||
|
expect(matchByExactName({ ...ref, line: 2, column: 2 },
|
||||||
|
context(nodes, 'function caller(target) {\n target();\n}'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exact-name bindings through indexing and sync', () => {
|
||||||
|
let root: string | undefined;
|
||||||
|
let graph: CodeGraph | undefined;
|
||||||
|
afterEach(() => {
|
||||||
|
graph?.close();
|
||||||
|
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
it('preserves nested scopes and import aliases through re-exports and symbol changes', async () => {
|
||||||
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-exact-binding-'));
|
||||||
|
const write = (file: string, source: string) => fs.writeFileSync(path.join(root!, file), source);
|
||||||
|
write('definition.ts', 'export function original() { return 1; }');
|
||||||
|
write('barrel.ts', "export { original as exposed } from './definition';");
|
||||||
|
write('caller.ts', "import { exposed as alias } from './barrel';\nexport function caller() { return alias(); }");
|
||||||
|
write('nested.ts', 'export function outer() {\n function hidden() { return 1; }\n return hidden();\n}\nexport function outside() { return hidden(); }');
|
||||||
|
graph = await CodeGraph.init(root);
|
||||||
|
await graph.indexAll();
|
||||||
|
const targets = (name: string) => {
|
||||||
|
const caller = graph!.getNodesByName(name).find(n => n.kind === 'function')!;
|
||||||
|
return graph!.getOutgoingEdges(caller.id).filter(e => e.kind === 'calls').map(e => graph!.getNode(e.target)?.name);
|
||||||
|
};
|
||||||
|
expect(targets('caller')).toContain('original');
|
||||||
|
expect(targets('outer')).toContain('hidden');
|
||||||
|
expect(targets('outside')).not.toContain('hidden');
|
||||||
|
write('collision.ts', 'export function alias() { return 2; }');
|
||||||
|
await graph.sync();
|
||||||
|
expect(targets('caller')).toContain('original');
|
||||||
|
expect(targets('caller')).not.toContain('alias');
|
||||||
|
fs.unlinkSync(path.join(root, 'collision.ts'));
|
||||||
|
await graph.sync();
|
||||||
|
expect(targets('caller')).toContain('original');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11866,6 +11866,8 @@ export function matchByExactName(
|
|||||||
const inheritanceRef = isInheritanceRef(ref);
|
const inheritanceRef = isInheritanceRef(ref);
|
||||||
// Read once, and only for a candidate in another file: it scans the file.
|
// Read once, and only for a candidate in another file: it scans the file.
|
||||||
let bareTypeName: boolean | undefined;
|
let bareTypeName: boolean | undefined;
|
||||||
|
// Every candidate uses the same binding key. Keep this check lazy.
|
||||||
|
let locallyBoundJsName: boolean | undefined;
|
||||||
const tsBareTypeName = (): boolean => (bareTypeName ??= isTsBareTypeName(ref, context));
|
const tsBareTypeName = (): boolean => (bareTypeName ??= isTsBareTypeName(ref, context));
|
||||||
const sameName = outOfRepoCall
|
const sameName = outOfRepoCall
|
||||||
? innerScopeShadows(context.getNodesByName(ref.referenceName), ref, context)
|
? innerScopeShadows(context.getNodesByName(ref.referenceName), ref, context)
|
||||||
@@ -12008,7 +12010,7 @@ export function matchByExactName(
|
|||||||
!ESM_FAMILY.has(n.language) || !isSealedModule(n.filePath, context)) &&
|
!ESM_FAMILY.has(n.language) || !isSealedModule(n.filePath, context)) &&
|
||||||
// A name the file binds itself (a parameter, a const) shadows every other
|
// A name the file binds itself (a parameter, a const) shadows every other
|
||||||
// file's symbol of that name, so a bare call has no cross-file candidate.
|
// file's symbol of that name, so a bare call has no cross-file candidate.
|
||||||
!(bareJs && n.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context))
|
!(bareJs && n.filePath !== ref.filePath && (locallyBoundJsName ??= isLocallyBoundJsName(ref.referenceName, ref.filePath, context)))
|
||||||
);
|
);
|
||||||
const ranked = dartBare ? nearestDartMembers(preferOwnDartLibrary(filtered, ref, context), ref, context)
|
const ranked = dartBare ? nearestDartMembers(preferOwnDartLibrary(filtered, ref, context), ref, context)
|
||||||
: dartTypeRef && dartPrefix === null ? preferOwnDartLibrary(filtered, ref, context)
|
: dartTypeRef && dartPrefix === null ? preferOwnDartLibrary(filtered, ref, context)
|
||||||
|
|||||||
Reference in New Issue
Block a user