diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index bd49f4d09..2322d0693 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -512,6 +512,7 @@ const verifyConstructorBindings = ( filePath: string, ctx: ResolutionContext, graph?: KnowledgeGraph, + bindingAccumulator?: BindingAccumulator, ): Map => { const verified = new Map(); @@ -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(); 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)); } diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 9a521e7c5..1996e65b1 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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 diff --git a/gitnexus/test/integration/cross-file-binding.test.ts b/gitnexus/test/integration/cross-file-binding.test.ts index edae951c0..75cea81f6 100644 --- a/gitnexus/test/integration/cross-file-binding.test.ts +++ b/gitnexus/test/integration/cross-file-binding.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index b0c2b04eb..4c3d641a2 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -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 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 as type — should be unwrapped + acc.appendFile('src/api.ts', [{ scope: '', varName: 'fetchUser', typeName: 'Promise' }]); + + 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 () => {