refactor(SM-15): eliminate duplicated prepare logic in processCalls two-pass split

Replace the duplicated pre-pass + legacy-path code (parse → query → heritage
→ TypeEnv → exports) with a single preparation loop followed by a resolution
loop. Both paths now share the same preparation code — the only conditional
is the accumulator flush.

Side benefit: globalParentMap is now fully populated before any resolution
runs, improving cross-file isSubclassOf accuracy regardless of file order.

Net -118 lines (226 removed, 108 added).
This commit is contained in:
Gergo Magyar 2026-04-10 09:45:54 +01:00
parent ddb86b35cb
commit 00e62da0f8

View file

@ -700,20 +700,17 @@ export const processCalls = async (
const logSkipped = isVerboseIngestionEnabled();
const skippedByLang = logSkipped ? new Map<string, number>() : null;
// ── Two-pass split for accumulator ordering correctness ──
// When bindingAccumulator is present, the Phase 9 fallback in
// verifyConstructorBindings reads from the accumulator. If we flush and
// verify in the same per-file iteration, consumer files processed before
// their provider files won't see the provider's bindings — causing silent
// misses. Fix: pre-pass flushes ALL files' TypeEnv bindings into the
// accumulator before the main loop runs verifyConstructorBindings.
// This mirrors the worker path where all appendFile calls complete before
// processCallsFromExtracted runs. For the sequential path (<15 files),
// buffering per-file state is negligible.
//
// When bindingAccumulator is absent (legacy/Phase 14 path), the existing
// single-pass behavior is preserved — no pre-pass, no buffering.
interface PrePassState {
// ── Prepare-then-resolve: single preparation loop, deferred resolution ──
// All files are prepared (parse → query → heritage → TypeEnv) in one loop,
// then resolved (verifyConstructorBindings → call edges) in a second loop.
// This ensures:
// 1. When bindingAccumulator is present, ALL files flush their TypeEnv
// bindings before ANY verifyConstructorBindings reads — fixing the
// consumer-before-provider ordering bug on the sequential path.
// 2. globalParentMap is fully populated before resolution, improving
// cross-file isSubclassOf accuracy regardless of file order.
// For the sequential path (<15 files), buffering per-file state is negligible.
interface PreparedFile {
file: { path: string; content: string };
language: SupportedLanguages;
provider: ReturnType<typeof getProvider>;
@ -722,240 +719,125 @@ export const processCalls = async (
parentMap: ReadonlyMap<string, readonly string[]>;
typeEnv: ReturnType<typeof buildTypeEnv>;
}
const prePassStates: PrePassState[] | undefined = bindingAccumulator ? [] : undefined;
const prepared: PreparedFile[] = [];
if (bindingAccumulator) {
// ── Pre-pass: build TypeEnv, flush to accumulator, buffer state ──
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (i % 20 === 0) await yieldToEventLoop();
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (i % 20 === 0) await yieldToEventLoop();
const language = getLanguageFromFilename(file.path);
if (!language) continue;
if (!isLanguageAvailable(language)) {
if (skippedByLang) {
skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1);
}
continue;
const language = getLanguageFromFilename(file.path);
if (!language) continue;
if (!isLanguageAvailable(language)) {
if (skippedByLang) {
skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1);
}
const provider = getProvider(language);
const queryStr = provider.treeSitterQueries;
if (!queryStr) continue;
await loadLanguage(language, file.path);
let tree = astCache.get(file.path);
if (!tree) {
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content.length),
});
} catch (parseError) {
continue;
}
astCache.set(file.path, tree);
}
let matches;
try {
const lang = parser.getLanguage();
const query = new Parser.Query(lang, queryStr);
matches = query.matches(tree.rootNode);
} catch (queryError) {
console.warn(`Query error for ${file.path}:`, queryError);
continue;
}
// Extract heritage for parentMap (same as main loop)
const fileParentMap = new Map<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, any> = {};
match.captures.forEach((c) => (captureMap[c.name] = c.node));
if (captureMap['heritage.class'] && captureMap['heritage.extends']) {
const className: string = captureMap['heritage.class'].text;
const parentName: string = captureMap['heritage.extends'].text;
const extendsNode = captureMap['heritage.extends'];
const fieldDecl = extendsNode.parent;
if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name'))
continue;
let parents = fileParentMap.get(className);
if (!parents) {
parents = [];
fileParentMap.set(className, parents);
}
if (!parents.includes(parentName)) parents.push(parentName);
}
}
const parentMap: ReadonlyMap<string, readonly string[]> = fileParentMap;
for (const [cls, parents] of fileParentMap) {
let global = globalParentMap.get(cls);
let seen = globalParentSeen.get(cls);
if (!global) {
global = [];
globalParentMap.set(cls, global);
}
if (!seen) {
seen = new Set();
globalParentSeen.set(cls, seen);
}
for (const p of parents) {
if (!seen.has(p)) {
seen.add(p);
global.push(p);
}
}
}
const importedBindings = importedBindingsMap?.get(file.path);
const importedReturnTypes = importedReturnTypesMap?.get(file.path);
const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path);
const typeEnv = buildTypeEnv(tree, language, {
symbolTable: ctx.symbols,
parentMap,
importedBindings,
importedReturnTypes,
importedRawReturnTypes,
enclosingFunctionFinder: provider?.enclosingFunctionFinder,
extractFunctionName: provider?.methodExtractor?.extractFunctionName,
});
if (typeEnv && exportedTypeMap) {
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph);
if (fileExports) exportedTypeMap.set(file.path, fileExports);
}
typeEnv.flush(file.path, bindingAccumulator);
prePassStates!.push({ file, language, provider, tree, matches, parentMap, typeEnv });
continue;
}
const provider = getProvider(language);
const queryStr = provider.treeSitterQueries;
if (!queryStr) continue;
await loadLanguage(language, file.path);
let tree = astCache.get(file.path);
if (!tree) {
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content.length),
});
} catch (parseError) {
continue;
}
astCache.set(file.path, tree);
}
let matches;
try {
const lang = parser.getLanguage();
const query = new Parser.Query(lang, queryStr);
matches = query.matches(tree.rootNode);
} catch (queryError) {
console.warn(`Query error for ${file.path}:`, queryError);
continue;
}
// Extract heritage from query matches to build parentMap for buildTypeEnv.
// Heritage-processor runs in PARALLEL, so graph edges don't exist when buildTypeEnv runs.
const fileParentMap = new Map<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, any> = {};
match.captures.forEach((c) => (captureMap[c.name] = c.node));
if (captureMap['heritage.class'] && captureMap['heritage.extends']) {
const className: string = captureMap['heritage.class'].text;
const parentName: string = captureMap['heritage.extends'].text;
const extendsNode = captureMap['heritage.extends'];
const fieldDecl = extendsNode.parent;
if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name'))
continue;
let parents = fileParentMap.get(className);
if (!parents) {
parents = [];
fileParentMap.set(className, parents);
}
if (!parents.includes(parentName)) parents.push(parentName);
}
}
const parentMap: ReadonlyMap<string, readonly string[]> = fileParentMap;
// Merge per-file heritage into globalParentMap for cross-file isSubclassOf lookups.
for (const [cls, parents] of fileParentMap) {
let global = globalParentMap.get(cls);
let seen = globalParentSeen.get(cls);
if (!global) {
global = [];
globalParentMap.set(cls, global);
}
if (!seen) {
seen = new Set();
globalParentSeen.set(cls, seen);
}
for (const p of parents) {
if (!seen.has(p)) {
seen.add(p);
global.push(p);
}
}
}
const importedBindings = importedBindingsMap?.get(file.path);
const importedReturnTypes = importedReturnTypesMap?.get(file.path);
const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path);
const typeEnv = buildTypeEnv(tree, language, {
symbolTable: ctx.symbols,
parentMap,
importedBindings,
importedReturnTypes,
importedRawReturnTypes,
enclosingFunctionFinder: provider?.enclosingFunctionFinder,
extractFunctionName: provider?.methodExtractor?.extractFunctionName,
});
if (typeEnv && exportedTypeMap) {
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph);
if (fileExports) exportedTypeMap.set(file.path, fileExports);
}
if (bindingAccumulator) {
typeEnv.flush(file.path, bindingAccumulator);
}
prepared.push({ file, language, provider, tree, matches, parentMap, typeEnv });
}
// ── Main loop: resolve calls (and optionally build TypeEnv if no pre-pass) ──
const loopSource = prePassStates ?? files;
for (let i = 0; i < loopSource.length; i++) {
const isPrePassed = prePassStates !== undefined;
const entry = loopSource[i];
const file = isPrePassed
? (entry as PrePassState).file
: (entry as { path: string; content: string });
// ── Resolution loop: verify constructor bindings and resolve calls ──
// The accumulator (if present) is now fully populated from the preparation
// loop above, so verifyConstructorBindings sees all provider bindings
// regardless of file processing order.
for (let i = 0; i < prepared.length; i++) {
const { file, language, provider, tree, matches, parentMap, typeEnv } = prepared[i];
enclosingFnExtractCache.clear();
onProgress?.(i + 1, files.length);
if (i % 20 === 0) await yieldToEventLoop();
let language: SupportedLanguages;
let provider: ReturnType<typeof getProvider>;
let tree: ReturnType<typeof parser.parse>;
let matches: ReturnType<Parser.Query['matches']>;
let typeEnv: ReturnType<typeof buildTypeEnv>;
let parentMap: ReadonlyMap<string, readonly string[]>;
if (isPrePassed) {
// Reuse state from pre-pass — TypeEnv and flush already done
const state = entry as PrePassState;
language = state.language;
provider = state.provider;
tree = state.tree;
matches = state.matches;
typeEnv = state.typeEnv;
parentMap = state.parentMap;
} else {
// Legacy single-pass path (no bindingAccumulator)
const lang = getLanguageFromFilename(file.path);
if (!lang) continue;
if (!isLanguageAvailable(lang)) {
if (skippedByLang) {
skippedByLang.set(lang, (skippedByLang.get(lang) ?? 0) + 1);
}
continue;
}
language = lang;
provider = getProvider(language);
const queryStr = provider.treeSitterQueries;
if (!queryStr) continue;
await loadLanguage(language, file.path);
tree = astCache.get(file.path)!;
if (!tree) {
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content.length),
});
} catch (parseError) {
continue;
}
astCache.set(file.path, tree);
}
try {
const parseLang = parser.getLanguage();
const query = new Parser.Query(parseLang, queryStr);
matches = query.matches(tree.rootNode);
} catch (queryError) {
console.warn(`Query error for ${file.path}:`, queryError);
continue;
}
// Heritage extraction (same as pre-pass, only runs in legacy path)
const fileParentMap = new Map<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, any> = {};
match.captures.forEach((c) => (captureMap[c.name] = c.node));
if (captureMap['heritage.class'] && captureMap['heritage.extends']) {
const className: string = captureMap['heritage.class'].text;
const parentName: string = captureMap['heritage.extends'].text;
const extendsNode = captureMap['heritage.extends'];
const fieldDecl = extendsNode.parent;
if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name'))
continue;
let parents = fileParentMap.get(className);
if (!parents) {
parents = [];
fileParentMap.set(className, parents);
}
if (!parents.includes(parentName)) parents.push(parentName);
}
}
for (const [cls, parents] of fileParentMap) {
let global = globalParentMap.get(cls);
let seen = globalParentSeen.get(cls);
if (!global) {
global = [];
globalParentMap.set(cls, global);
}
if (!seen) {
seen = new Set();
globalParentSeen.set(cls, seen);
}
for (const p of parents) {
if (!seen.has(p)) {
seen.add(p);
global.push(p);
}
}
}
parentMap = fileParentMap;
const importedBindings = importedBindingsMap?.get(file.path);
const importedReturnTypes = importedReturnTypesMap?.get(file.path);
const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path);
typeEnv = buildTypeEnv(tree, language, {
symbolTable: ctx.symbols,
parentMap,
importedBindings,
importedReturnTypes,
importedRawReturnTypes,
enclosingFunctionFinder: provider?.enclosingFunctionFinder,
extractFunctionName: provider?.methodExtractor?.extractFunctionName,
});
if (typeEnv && exportedTypeMap) {
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph);
if (fileExports) exportedTypeMap.set(file.path, fileExports);
}
}
const callRouter = provider.callRouter;
const verifiedReceivers =