mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
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:
parent
7bd59cf91c
commit
5819d52e09
4 changed files with 533 additions and 12 deletions
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue