feat(SM-15): wire BindingAccumulator into processCallsFromExtracted for Phase 9 cross-file return type propagation

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7cee6490-090d-4714-8cb5-a704168ff47a
This commit is contained in:
copilot-swe-agent[bot] 2026-04-09 20:40:20 +00:00 • committed by GitHub
parent 7bd59cf91c
commit 5819d52e09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 533 additions and 12 deletions

View file

@ -512,6 +512,7 @@ const verifyConstructorBindings = (
filePath: string,
ctx: ResolutionContext,
graph?: KnowledgeGraph,
bindingAccumulator?: BindingAccumulator,
): Map<string, string> => {
const verified = new Map<string, string>();
@ -548,12 +549,35 @@ const verifyConstructorBindings = (
}
}
let typeName: string | undefined;
if (callableDefs && callableDefs.length === 1 && callableDefs[0].returnType) {
const typeName = extractReturnTypeName(callableDefs[0].returnType);
if (typeName) {
verified.set(receiverKey(scope, varName), typeName);
typeName = extractReturnTypeName(callableDefs[0].returnType);
}
// Phase 9: BindingAccumulator fallback for cross-file return types.
// Used when the SymbolTable has no return type for a cross-file callee
// (e.g., a return type that TypeEnv resolved via fixpoint in the source
// file but was not stored as a SymbolTable returnType annotation).
// namedImportMap tells us which source file exported the callee so we
// can look up its file-scope binding directly in the accumulator.
if (!typeName && bindingAccumulator) {
const namedImports = ctx.namedImportMap.get(filePath);
const importBinding = namedImports?.get(calleeName);
if (importBinding) {
for (const [name, rawType] of bindingAccumulator.fileScopeEntries(
importBinding.sourcePath,
)) {
if (name === importBinding.exportedName) {
typeName = extractReturnTypeName(rawType);
break;
}
}
}
}
if (typeName) {
verified.set(receiverKey(scope, varName), typeName);
}
}
}
@ -2474,6 +2498,12 @@ const walkMixedChain = (
/**
* Fast path: resolve pre-extracted call sites from workers.
* No AST parsing — workers already extracted calledName + sourceId.
*
* @param bindingAccumulator Phase 9: optional accumulator carrying file-scope
* TypeEnv bindings from all worker-processed files. When the SymbolTable has
* no return type for a cross-file callee, `verifyConstructorBindings` falls
* back to the accumulator via `namedImportMap` to bind the variable to the
* callee's resolved type (e.g. `var x = getUser()` → `x: User`).
*/
export const processCallsFromExtracted = async (
graph: KnowledgeGraph,
@ -2482,6 +2512,7 @@ export const processCallsFromExtracted = async (
onProgress?: (current: number, total: number) => void,
constructorBindings?: FileConstructorBindings[],
heritageMap?: HeritageMap,
bindingAccumulator?: BindingAccumulator,
) => {
// Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName.
// The scope dimension prevents collisions when two functions in the same file
@ -2489,7 +2520,13 @@ export const processCallsFromExtracted = async (
const fileReceiverTypes = new Map<string, ReceiverTypeIndex>();
if (constructorBindings) {
for (const { filePath, bindings } of constructorBindings) {
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
const verified = verifyConstructorBindings(
bindings,
filePath,
ctx,
graph,
bindingAccumulator,
);
if (verified.size > 0) {
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
}

View file

@ -1000,6 +1000,13 @@ async function runChunkedParseAndResolve(
},
deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined,
fullWorkerHeritageMap,
// Phase 9: pass the accumulator so processCallsFromExtracted can fall back
// to file-scope TypeEnv bindings when the SymbolTable lacks a return type
// for a cross-file callee (e.g. var x = getUser() → x: User).
// The accumulator is populated but not yet finalized at this seam — all
// worker-path appendFile calls complete in the chunk loop above, so every
// file-scope binding is available here via fileScopeEntries().
bindingAccumulator,
);
}
@ -1759,14 +1766,15 @@ export const runPipelineFromRepo = async (
}
}
// Release the accumulator's heap footprint now. The ExportedTypeMap
// enrichment loop above is the only current consumer, and the dev
// telemetry log just captured peak state. Phase 14 and
// runGraphAnalysisPhases do not read the accumulator today — keeping
// it alive through those long-running phases pins heap for no reason.
// When Phase 9 wires a consumer into runCrossFileBindingPropagation,
// move this dispose() call to after that consumer completes or delete
// it entirely if the consumer takes lifecycle ownership.
// Release the accumulator's heap footprint now. Both consumers of the
// accumulator have completed:
// 1. ExportedTypeMap enrichment loop (enrichExportedTypeMap, above).
// 2. Phase 9: processCallsFromExtracted in runChunkedParseAndResolve,
// which uses the accumulator as a BindingAccumulator fallback for
// cross-file return types when the SymbolTable has no returnType.
// Phase 14 (runCrossFileBindingPropagation) and runGraphAnalysisPhases
// do not read the accumulator — keeping it alive through those long-
// running phases pins heap for no reason.
bindingAccumulator.dispose();
// Happy-path dispose completed — clear the cleanup ref so the catch
// handler doesn't attempt a second (harmless but noisy) dispose if a

View file

@ -210,3 +210,173 @@ describe('Cross-File Binding Propagation: TypeScript circular imports', () => {
expect(paths.some((p) => p.includes('b.ts') && p.includes('a.ts'))).toBe(true);
});
});
// ---------------------------------------------------------------------------
// SM-15 / Phase 9: Cross-file call-result variable binding — multi-language
//
// Each suite below loads a multi-file fixture where:
// - File A defines a factory function getUser() / get_user() → User
// - File B imports that function, calls `u = getUser()`, then calls u.save()
//
// The acceptance criteria: u.save() / u.save() / u.get_name() must resolve
// to the correct User method via cross-file call-result variable binding.
// These tests cover both the SymbolTable path (languages with explicit return
// type annotations) and validate that the Phase 9 BindingAccumulator wiring
// does not break existing behavior.
// ---------------------------------------------------------------------------
describe('Phase 9 — Cross-File Call-Result Binding: Java', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'java-cross-file'), () => {});
}, 60000);
it('detects User class with save and getName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('getName');
});
it('detects getUser factory and run method', () => {
expect(getNodesByLabel(result, 'Method')).toContain('getUser');
expect(getNodesByLabel(result, 'Method')).toContain('run');
});
it('resolves user.save() in run() to User#save via cross-file return type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('User'),
);
expect(saveCall).toBeDefined();
});
it('resolves user.getName() in run() to User#getName via cross-file return type', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(
(c) => c.target === 'getName' && c.source === 'run' && c.targetFilePath.includes('User'),
);
expect(getNameCall).toBeDefined();
});
});
describe('Phase 9 — Cross-File Call-Result Binding: Python', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'py-cross-file'), () => {});
}, 60000);
it('detects User class with save and get_name methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
// Python tree-sitter captures all function_definitions as Function, including methods
expect(getNodesByLabel(result, 'Function')).toContain('save');
expect(getNodesByLabel(result, 'Function')).toContain('get_name');
});
it('detects get_user function and run function', () => {
expect(getNodesByLabel(result, 'Function')).toContain('get_user');
expect(getNodesByLabel(result, 'Function')).toContain('run');
});
it('resolves u.save() in run() to User#save via cross-file return type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves u.get_name() in run() to User#get_name via cross-file return type', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(
(c) => c.target === 'get_name' && c.source === 'run' && c.targetFilePath.includes('models'),
);
expect(getNameCall).toBeDefined();
});
});
describe('Phase 9 — Cross-File Call-Result Binding: Go', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'go-cross-file'), () => {});
}, 60000);
it('detects User struct with Save and GetName methods', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('Save');
expect(getNodesByLabel(result, 'Method')).toContain('GetName');
});
it('detects GetUser function and main function', () => {
expect(getNodesByLabel(result, 'Function')).toContain('GetUser');
expect(getNodesByLabel(result, 'Function')).toContain('main');
});
it('resolves user.Save() in main() to User#Save via cross-file return type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'Save' && c.source === 'main' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
});
describe('Phase 9 — Cross-File Call-Result Binding: Kotlin', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'kotlin-cross-file'),
() => {},
);
}, 60000);
it('detects User class with save and getName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('getName');
});
it('detects getUser function and run method', () => {
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
expect(getNodesByLabel(result, 'Method')).toContain('run');
});
it('resolves u.save() in run() to User#save via cross-file return type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('User'),
);
expect(saveCall).toBeDefined();
});
});
describe('Phase 9 — Cross-File Call-Result Binding: Rust', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'rs-cross-file'), () => {});
}, 60000);
it('detects User struct with save and get_name methods', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
// Rust tree-sitter captures impl fns as Function nodes
expect(getNodesByLabel(result, 'Function')).toContain('save');
expect(getNodesByLabel(result, 'Function')).toContain('get_name');
});
it('detects get_user function and process function', () => {
expect(getNodesByLabel(result, 'Function')).toContain('get_user');
expect(getNodesByLabel(result, 'Function')).toContain('process');
});
it('resolves u.save() in process() to User#save via cross-file return type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
});

View file

@ -14,6 +14,7 @@ import {
type ResolutionContext,
} from '../../src/core/ingestion/resolution-context.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js';
import type {
ExtractedCall,
ExtractedFetchCall,
@ -574,6 +575,311 @@ describe('processCallsFromExtracted', () => {
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
// ---- Phase 9: BindingAccumulator fallback for cross-file return types ----
it('Phase 9: BindingAccumulator fallback — binds variable to return type when SymbolTable has no returnType', async () => {
// getUser is in the SymbolTable but WITHOUT a returnType (e.g., inferred return type
// that the structure processor did not capture). The BindingAccumulator for
// src/api.ts has getUser → User as a file-scope binding.
ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', {
// No returnType provided — simulates a structure-processor gap
});
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts']));
// namedImportMap: consumer.ts imports { getUser } from src/api.ts
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]),
);
// BindingAccumulator carries the TypeEnv-resolved binding from src/api.ts
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
it('Phase 9: BindingAccumulator fallback — SymbolTable return type takes precedence', async () => {
// When the SymbolTable DOES have a returnType, the accumulator should not override it.
ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', {
returnType: 'User',
});
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts']));
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]),
);
// Accumulator has a conflicting (wrong) type — should be ignored
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'WrongType' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
// Should resolve via SymbolTable (User#save), not the wrong accumulator type
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
it('Phase 9: BindingAccumulator fallback — skips when callee not in namedImportMap', async () => {
// Callee is not tracked in namedImportMap (e.g. a local function), so accumulator
// lookup is skipped. No CALLS edge expected since there is no binding source.
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
// No namedImportMap entry for getUser
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }],
},
];
// Use a method name that is owned by User (requires receiver type resolution)
// but also exists on multiple types so fuzzy lookup is ambiguous without a
// receiver type. Add a second owner so that unconstrained fuzzy lookup won't
// match unambiguously.
ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class');
ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', {
ownerId: 'Class:src/other.ts:OtherClass',
});
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
// Without accumulator fallback (no namedImportMap entry), x is untyped.
// Two methods named 'save' from unrelated types — fuzzy lookup is ambiguous → no edge.
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('Phase 9: BindingAccumulator fallback — unwraps Promise<User> type from accumulator', async () => {
// Accumulator stores raw type with Promise wrapper — extractReturnTypeName should unwrap it.
ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function');
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts']));
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['fetchUser', { sourcePath: 'src/api.ts', exportedName: 'fetchUser' }]]),
);
const acc = new BindingAccumulator();
// Accumulator stores raw Promise<User> as type — should be unwrapped
acc.appendFile('src/api.ts', [{ scope: '', varName: 'fetchUser', typeName: 'Promise<User>' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'fetchUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
it('Phase 9: BindingAccumulator fallback — skips primitive types from accumulator', async () => {
// Accumulator stores a primitive type — should not create a CALLS edge.
ctx.symbols.add('src/api.ts', 'getCount', 'Function:src/api.ts:getCount', 'Function');
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts']));
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['getCount', { sourcePath: 'src/api.ts', exportedName: 'getCount' }]]),
);
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getCount', typeName: 'number' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'count', calleeName: 'getCount' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'toString',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'count',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
// Primitive type — no CALLS edge
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('Phase 9: BindingAccumulator fallback — handles aliased import (localName ≠ exportedName)', async () => {
// import { getUser as fetchUser } from './api' — namedImportMap maps localName to exportedName
ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function');
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts']));
// Local alias: fetchUser → api.ts:getUser
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['fetchUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]),
);
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
// calleeName is the LOCAL alias used at the call site
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'fetchUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
// ---- Scope-aware constructor bindings (Phase 3) ----
it('receiverKey collision: same method name in different classes does not collide', async () => {