perf: Cache import mappings and index fuzzy matches for resolution

Two major optimizations for the ref resolution phase:

1. Cache extractImportMappings() results per file path — previously
   re-read and re-parsed the source file for every single ref from
   that file (e.g. 100 refs from one file = 100 identical file reads)

2. Replace linear scan in matchFuzzy() with a lazily-built
   case-insensitive Map index — O(1) lookup instead of iterating
   all function/method/class nodes for every unresolved ref.
   Also drop low-value prefix matching (confidence 0.3).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Olaf Monien
2026-02-12 19:29:18 +01:00
co-authored by Claude Opus 4.6
parent 44ea6af043
commit ab9d7188c1
3 changed files with 53 additions and 33 deletions
+19 -6
View File
@@ -425,6 +425,16 @@ function extractPHPImports(content: string): ImportMapping[] {
return mappings;
}
// Cache import mappings per file to avoid re-reading and re-parsing
const importMappingCache = new Map<string, ImportMapping[]>();
/**
* Clear the import mapping cache (call between indexing runs)
*/
export function clearImportMappingCache(): void {
importMappingCache.clear();
}
/**
* Resolve a reference using import mappings
*/
@@ -432,14 +442,17 @@ export function resolveViaImport(
ref: UnresolvedRef,
context: ResolutionContext
): ResolvedRef | null {
// Read the source file to extract imports
const content = context.readFile(ref.filePath);
if (!content) {
return null;
// Use cached import mappings or extract and cache them
let imports = importMappingCache.get(ref.filePath);
if (!imports) {
const content = context.readFile(ref.filePath);
if (!content) {
return null;
}
imports = extractImportMappings(ref.filePath, content, ref.language);
importMappingCache.set(ref.filePath, imports);
}
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 + '.')) {
+4 -2
View File
@@ -16,8 +16,8 @@ import {
ResolutionContext,
FrameworkResolver,
} from './types';
import { matchReference } from './name-matcher';
import { resolveViaImport } from './import-resolver';
import { matchReference, clearFuzzyIndex } from './name-matcher';
import { resolveViaImport, clearImportMappingCache } from './import-resolver';
import { detectFrameworks } from './frameworks';
import { logDebug } from '../errors';
@@ -106,6 +106,8 @@ export class ReferenceResolver {
this.qualifiedNameCache.clear();
this.kindCache.clear();
this.nodeByIdCache.clear();
clearImportMappingCache();
clearFuzzyIndex();
this.cachesWarmed = false;
}
+30 -25
View File
@@ -190,6 +190,16 @@ function findBestMatch(
return bestNode;
}
// Lazily-built case-insensitive index for fuzzy matching
let fuzzyIndex: Map<string, Node[]> | null = null;
/**
* Clear the fuzzy match index (call between indexing runs)
*/
export function clearFuzzyIndex(): void {
fuzzyIndex = null;
}
/**
* Fuzzy match - last resort with lower confidence
*/
@@ -197,21 +207,29 @@ export function matchFuzzy(
ref: UnresolvedRef,
context: ResolutionContext
): ResolvedRef | null {
// Try case-insensitive match
const allNodes = [
...context.getNodesByKind('function'),
...context.getNodesByKind('method'),
...context.getNodesByKind('class'),
];
// Build case-insensitive index on first use
if (!fuzzyIndex) {
fuzzyIndex = new Map();
const kinds: Array<Node['kind']> = ['function', 'method', 'class'];
for (const kind of kinds) {
for (const node of context.getNodesByKind(kind)) {
const lower = node.name.toLowerCase();
const existing = fuzzyIndex.get(lower);
if (existing) {
existing.push(node);
} else {
fuzzyIndex.set(lower, [node]);
}
}
}
}
const lowerName = ref.referenceName.toLowerCase();
// Exact case-insensitive match
const caseInsensitive = allNodes.filter(
(n) => n.name.toLowerCase() === lowerName
);
// Exact case-insensitive match via index (O(1) lookup)
const caseInsensitive = fuzzyIndex.get(lowerName);
if (caseInsensitive.length === 1) {
if (caseInsensitive && caseInsensitive.length === 1) {
return {
original: ref,
targetNodeId: caseInsensitive[0]!.id,
@@ -220,20 +238,7 @@ export function matchFuzzy(
};
}
// 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',
};
}
// Skip prefix matching — too expensive and low value (confidence 0.3)
return null;
}