Optimize reference resolution with in-memory caches

The resolving refs phase stalled on large projects (3400+ files, 38k+ nodes)
because matchFuzzy loaded ALL functions/methods/classes per ref, import
mappings were re-extracted per ref, and fileExists hit disk every call.

Add kindCache, lowerNameCache, importMappingCache, and knownFiles set to
warmCaches(). Rewrite matchFuzzy to use O(1) lowercase index lookup instead
of 3x getNodesByKind scans. Cache import mappings per file. Pre-build file
existence set from the index for O(1) fileExists checks.
This commit is contained in:
Colby McHenry
2026-02-10 18:14:26 -06:00
parent df937ceca1
commit acd9713632
4 changed files with 82 additions and 33 deletions
+3 -5
View File
@@ -432,14 +432,12 @@ export function resolveViaImport(
ref: UnresolvedRef,
context: ResolutionContext
): ResolvedRef | null {
// Read the source file to extract imports
const content = context.readFile(ref.filePath);
if (!content) {
// Use cached import mappings (avoids re-reading and re-parsing per ref)
const imports = context.getImportMappings(ref.filePath, ref.language);
if (imports.length === 0 && !context.readFile(ref.filePath)) {
return null;
}
const imports = extractImportMappings(ref.filePath, content, ref.language);
// Check if the reference name matches any import
for (const imp of imports) {
if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
+67 -1
View File
@@ -15,9 +15,10 @@ import {
ResolutionResult,
ResolutionContext,
FrameworkResolver,
ImportMapping,
} from './types';
import { matchReference } from './name-matcher';
import { resolveViaImport } from './import-resolver';
import { resolveViaImport, extractImportMappings } from './import-resolver';
import { detectFrameworks } from './frameworks';
import { logDebug } from '../errors';
@@ -39,6 +40,10 @@ export class ReferenceResolver {
private nameCache: Map<string, Node[]> = new Map();
private qualifiedNameCache: Map<string, Node[]> = new Map();
private nodeByIdCache: Map<string, Node> = new Map();
private kindCache: Map<string, Node[]> = new Map();
private lowerNameCache: Map<string, Node[]> = new Map();
private importMappingCache: Map<string, ImportMapping[]> = new Map();
private knownFiles: Set<string> | null = null;
private cachesWarmed = false;
constructor(projectRoot: string, queries: QueryBuilder) {
@@ -82,8 +87,28 @@ export class ReferenceResolver {
// Index by ID
this.nodeByIdCache.set(node.id, node);
// Index by kind
const byKind = this.kindCache.get(node.kind);
if (byKind) {
byKind.push(node);
} else {
this.kindCache.set(node.kind, [node]);
}
// Index by lowercase name (for fuzzy matching)
const lowerName = node.name.toLowerCase();
const byLower = this.lowerNameCache.get(lowerName);
if (byLower) {
byLower.push(node);
} else {
this.lowerNameCache.set(lowerName, [node]);
}
}
// Pre-build known files set from index
this.knownFiles = new Set(this.queries.getAllFiles().map((f) => f.path));
this.cachesWarmed = true;
}
@@ -96,6 +121,10 @@ export class ReferenceResolver {
this.nameCache.clear();
this.qualifiedNameCache.clear();
this.nodeByIdCache.clear();
this.kindCache.clear();
this.lowerNameCache.clear();
this.importMappingCache.clear();
this.knownFiles = null;
this.cachesWarmed = false;
}
@@ -131,10 +160,21 @@ export class ReferenceResolver {
},
getNodesByKind: (kind: Node['kind']) => {
if (this.cachesWarmed) {
return this.kindCache.get(kind) ?? [];
}
return this.queries.getNodesByKind(kind);
},
fileExists: (filePath: string) => {
// Check pre-built known files set first (O(1))
if (this.knownFiles) {
const normalized = filePath.replace(/\\/g, '/');
if (this.knownFiles.has(filePath) || this.knownFiles.has(normalized)) {
return true;
}
}
// Fall back to filesystem for files not yet indexed
const fullPath = path.join(this.projectRoot, filePath);
try {
return fs.existsSync(fullPath);
@@ -168,6 +208,32 @@ export class ReferenceResolver {
getAllFiles: () => {
return this.queries.getAllFiles().map((f) => f.path);
},
getNodesByLowerName: (lowerName: string) => {
if (this.cachesWarmed) {
return this.lowerNameCache.get(lowerName) ?? [];
}
// Fallback: scan all nodes (expensive, but only used if cache not warm)
return this.queries.getAllNodes().filter(
(n) => n.name.toLowerCase() === lowerName
);
},
getImportMappings: (filePath: string, language) => {
const cacheKey = filePath;
const cached = this.importMappingCache.get(cacheKey);
if (cached) return cached;
const content = this.context.readFile(filePath);
if (!content) {
this.importMappingCache.set(cacheKey, []);
return [];
}
const mappings = extractImportMappings(filePath, content, language);
this.importMappingCache.set(cacheKey, mappings);
return mappings;
},
};
}
+8 -27
View File
@@ -197,43 +197,24 @@ export function matchFuzzy(
ref: UnresolvedRef,
context: ResolutionContext
): ResolvedRef | null {
// Try case-insensitive match
const allNodes = [
...context.getNodesByKind('function'),
...context.getNodesByKind('method'),
...context.getNodesByKind('class'),
];
const lowerName = ref.referenceName.toLowerCase();
// Exact case-insensitive match
const caseInsensitive = allNodes.filter(
(n) => n.name.toLowerCase() === lowerName
);
// Use pre-built lowercase index for O(1) lookup instead of scanning all nodes
const candidates = context.getNodesByLowerName(lowerName);
if (caseInsensitive.length === 1) {
// Filter to callable kinds only (function, method, class)
const callableKinds = new Set(['function', 'method', 'class']);
const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind));
if (callableCandidates.length === 1) {
return {
original: ref,
targetNodeId: caseInsensitive[0]!.id,
targetNodeId: callableCandidates[0]!.id,
confidence: 0.5,
resolvedBy: 'fuzzy',
};
}
// Try prefix match (e.g., "get" matches "getUser")
const prefixMatches = allNodes.filter((n) =>
n.name.toLowerCase().startsWith(lowerName)
);
if (prefixMatches.length === 1) {
return {
original: ref,
targetNodeId: prefixMatches[0]!.id,
confidence: 0.3,
resolvedBy: 'fuzzy',
};
}
return null;
}
+4
View File
@@ -79,6 +79,10 @@ export interface ResolutionContext {
getProjectRoot(): string;
/** Get all files */
getAllFiles(): string[];
/** Get nodes by lowercase name (O(1) lookup for fuzzy matching) */
getNodesByLowerName(lowerName: string): Node[];
/** Get cached import mappings for a file */
getImportMappings(filePath: string, language: Language): ImportMapping[];
}
/**