import { describe, it, expect, vi, beforeEach } from 'vitest'; import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js'; import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js'; import { createResolutionContext, type ResolutionContext } from '../../src/core/ingestion/resolution-context.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import type { ExtractedCall, FileConstructorBindings } from '../../src/core/ingestion/workers/parse-worker.js'; describe('processCallsFromExtracted', () => { let graph: ReturnType; let ctx: ResolutionContext; beforeEach(() => { graph = createKnowledgeGraph(); ctx = createResolutionContext(); }); it('creates CALLS relationship for same-file resolution', async () => { ctx.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'helper', sourceId: 'Function:src/index.ts:main', }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].sourceId).toBe('Function:src/index.ts:main'); expect(rels[0].targetId).toBe('Function:src/index.ts:helper'); expect(rels[0].confidence).toBe(0.95); expect(rels[0].reason).toBe('same-file'); }); it('creates CALLS relationship for import-resolved resolution', async () => { ctx.symbols.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'format', sourceId: 'Function:src/index.ts:main', }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].confidence).toBe(0.9); expect(rels[0].reason).toBe('import-resolved'); }); it('resolves unique global symbol with moderate confidence', async () => { ctx.symbols.add('src/other.ts', 'uniqueFunc', 'Function:src/other.ts:uniqueFunc', 'Function'); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'uniqueFunc', sourceId: 'Function:src/index.ts:main', }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].confidence).toBe(0.5); expect(rels[0].reason).toBe('global'); }); it('refuses ambiguous global symbols — no CALLS edge created', async () => { ctx.symbols.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function'); ctx.symbols.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function'); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'render', sourceId: 'Function:src/index.ts:main', }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(0); }); it('skips unresolvable calls', async () => { const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'nonExistent', sourceId: 'Function:src/index.ts:main', }]; await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationshipCount).toBe(0); }); it('refuses non-callable symbols even when the name resolves', async () => { ctx.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class'); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'Widget', sourceId: 'Function:src/index.ts:main', }]; await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationshipCount).toBe(0); }); it('refuses CALLS edges to Interface symbols', async () => { ctx.symbols.add('src/types.ts', 'Serializable', 'Interface:src/types.ts:Serializable', 'Interface'); ctx.importMap.set('src/index.ts', new Set(['src/types.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'Serializable', sourceId: 'Function:src/index.ts:main', }]; await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0); }); it('refuses CALLS edges to Enum symbols', async () => { ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); ctx.importMap.set('src/index.ts', new Set(['src/status.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'Status', sourceId: 'Function:src/index.ts:main', }]; await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0); }); it('prefers same-file over import-resolved', async () => { ctx.symbols.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function'); ctx.symbols.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'render', sourceId: 'Function:src/index.ts:main', }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].targetId).toBe('Function:src/index.ts:render'); expect(rels[0].reason).toBe('same-file'); }); it('handles multiple calls from the same file', async () => { ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); ctx.symbols.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function'); const calls: ExtractedCall[] = [ { filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' }, { filePath: 'src/index.ts', calledName: 'bar', sourceId: 'Function:src/index.ts:main' }, ]; await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(2); }); it('uses arity to disambiguate import-scoped callable candidates', async () => { ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 0 }); ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1 }); ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'log', sourceId: 'Function:src/index.ts:main', argCount: 1, }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].targetId).toBe('Function:src/formatter.ts:log'); expect(rels[0].reason).toBe('import-resolved'); }); it('refuses ambiguous call targets when arity does not produce a unique match', async () => { ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 1 }); ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1 }); ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'log', sourceId: 'Function:src/index.ts:main', argCount: 1, }]; await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0); }); it('calls progress callback', async () => { ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); const calls: ExtractedCall[] = [ { filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' }, ]; const onProgress = vi.fn(); await processCallsFromExtracted(graph, calls, ctx, onProgress); expect(onProgress).toHaveBeenCalledWith(1, 1); }); it('handles empty calls array', async () => { await processCallsFromExtracted(graph, [], ctx); expect(graph.relationshipCount).toBe(0); }); // ---- Constructor-aware resolution (Phase 2) ---- it('resolves constructor call to Class when no Constructor node exists', async () => { ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'User', sourceId: 'Function:src/index.ts:main', callForm: 'constructor', }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].targetId).toBe('Class:src/models.ts:User'); expect(rels[0].reason).toBe('import-resolved'); }); it('resolves constructor call to Constructor node over Class node', async () => { ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User', 'Constructor', { parameterCount: 1 }); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'User', sourceId: 'Function:src/index.ts:main', argCount: 1, callForm: 'constructor', }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].targetId).toBe('Constructor:src/models.ts:User'); }); it('refuses Class target without callForm=constructor (existing behavior)', async () => { ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'User', sourceId: 'Function:src/index.ts:main', }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(0); }); it('constructor call falls back to callable types when no Constructor/Class found', async () => { ctx.symbols.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'Widget', sourceId: 'Function:src/index.ts:main', callForm: 'constructor', }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].targetId).toBe('Function:src/utils.ts:Widget'); }); it('constructor arity filtering narrows overloaded constructors', async () => { ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(0)', 'Constructor', { parameterCount: 0 }); ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(2)', 'Constructor', { parameterCount: 2 }); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'User', sourceId: 'Function:src/index.ts:main', argCount: 2, callForm: 'constructor', }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].targetId).toBe('Constructor:src/models.ts:User(2)'); }); it('cannot discriminate same-arity overloads by parameter type (known limitation)', async () => { ctx.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', { parameterCount: 1 }); ctx.symbols.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', { parameterCount: 1 }); ctx.importMap.set('src/index.ts', new Set(['src/UserDao.ts', 'src/RepoDao.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'save', sourceId: 'Function:src/index.ts:main', argCount: 1, }]; await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(0); }); // ---- Return type inference (Phase 4) ---- it('return type inference: binds variable to return type of callee', async () => { // getUser() returns User, and User has a save() method ctx.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.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/index.ts', new Set(['src/utils.ts', 'src/models.ts'])); // Binding: user = getUser() — getUser is not a class, so constructor path fails, // but return type inference should kick in const constructorBindings: FileConstructorBindings[] = [{ filePath: 'src/index.ts', bindings: [ { scope: 'main@0', varName: 'user', calleeName: 'getUser' }, ], }]; const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'save', sourceId: 'Function:src/index.ts:main', receiverName: 'user', callForm: 'member', }]; await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].targetId).toBe('Method:src/models.ts:save'); }); it('return type inference: unwraps Promise to User', async () => { ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', { returnType: 'Promise' }); 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/index.ts', new Set(['src/api.ts', 'src/models.ts'])); const constructorBindings: FileConstructorBindings[] = [{ filePath: 'src/index.ts', bindings: [ { scope: 'main@0', varName: 'user', calleeName: 'fetchUser' }, ], }]; const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'save', sourceId: 'Function:src/index.ts:main', receiverName: 'user', callForm: 'member', }]; await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].targetId).toBe('Method:src/models.ts:save'); }); it('return type inference: skips when return type is primitive', async () => { ctx.symbols.add('src/utils.ts', 'getCount', 'Function:src/utils.ts:getCount', 'Function', { returnType: 'number' }); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const constructorBindings: FileConstructorBindings[] = [{ filePath: 'src/index.ts', bindings: [ { scope: 'main@0', varName: 'count', calleeName: 'getCount' }, ], }]; const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'toString', sourceId: 'Function:src/index.ts:main', receiverName: 'count', callForm: 'member', }]; await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); // No binding should be created for primitive return types const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(0); }); it('return type inference: skips ambiguous callees (multiple definitions)', async () => { ctx.symbols.add('src/a.ts', 'getData', 'Function:src/a.ts:getData', 'Function', { returnType: 'User' }); ctx.symbols.add('src/b.ts', 'getData', 'Function:src/b.ts:getData', 'Function', { returnType: 'Repo' }); const constructorBindings: FileConstructorBindings[] = [{ filePath: 'src/index.ts', bindings: [ { scope: 'main@0', varName: 'data', calleeName: 'getData' }, ], }]; const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'save', sourceId: 'Function:src/index.ts:main', receiverName: 'data', callForm: 'member', }]; await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); // Ambiguous callee — don't guess const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(0); }); it('return type inference: prefers constructor binding over return type', async () => { // If the callee IS a class, constructor binding wins (existing behavior) 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/index.ts', new Set(['src/models.ts'])); const constructorBindings: FileConstructorBindings[] = [{ filePath: 'src/index.ts', bindings: [ { scope: 'main@0', varName: 'user', calleeName: 'User' }, ], }]; const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'save', sourceId: 'Function:src/index.ts:main', receiverName: 'user', callForm: 'member', }]; await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); 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 () => { // User.save@100 and Repo.save@200 are two methods named "save" in different classes. // Each has a local variable "db" pointing to a different type. // Without @startIndex in the key, the second binding would overwrite the first. ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' }); ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache' }); ctx.importMap.set('src/models/User.ts', new Set(['src/db/Database.ts'])); ctx.importMap.set('src/models/Repo.ts', new Set(['src/db/Cache.ts'])); // Two bindings: both enclosing scope is named "save" but at different startIndexes const constructorBindings: FileConstructorBindings[] = [ { filePath: 'src/models/User.ts', bindings: [ // save@100: inside User.save(), db = new Database() { scope: 'save@100', varName: 'db', calleeName: 'Database' }, ], }, { filePath: 'src/models/Repo.ts', bindings: [ // save@200: inside Repo.save(), db = new Cache() { scope: 'save@200', varName: 'db', calleeName: 'Cache' }, ], }, ]; const calls: ExtractedCall[] = [ { filePath: 'src/models/User.ts', calledName: 'query', sourceId: 'Method:src/models/User.ts:save', receiverName: 'db', callForm: 'member', }, { filePath: 'src/models/Repo.ts', calledName: 'query', sourceId: 'Method:src/models/Repo.ts:save', receiverName: 'db', callForm: 'member', }, ]; await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(2); const userQueryRel = rels.find(r => r.sourceId === 'Method:src/models/User.ts:save'); const repoQueryRel = rels.find(r => r.sourceId === 'Method:src/models/Repo.ts:save'); expect(userQueryRel?.targetId).toBe('Method:src/db/Database.ts:query'); expect(repoQueryRel?.targetId).toBe('Method:src/db/Cache.ts:query'); }); it('receiverKey collision: same scope funcName + same varName + same type resolves (non-ambiguous)', async () => { // Two save@* scopes both bind "db" to the same type — not ambiguous, should resolve. ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' }); ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts'])); const constructorBindings: FileConstructorBindings[] = [{ filePath: 'src/service.ts', bindings: [ { scope: 'save@10', varName: 'db', calleeName: 'Database' }, { scope: 'save@50', varName: 'db', calleeName: 'Database' }, ], }]; const calls: ExtractedCall[] = [{ filePath: 'src/service.ts', calledName: 'query', sourceId: 'Method:src/service.ts:save', receiverName: 'db', callForm: 'member', }]; await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].targetId).toBe('Method:src/db/Database.ts:query'); }); it('receiverKey collision: same scope funcName + same varName + different types → ambiguous, no CALLS edge', async () => { // Two save@* scopes in the same file bind "db" to different types — truly ambiguous. ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' }); ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache' }); ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts', 'src/db/Cache.ts'])); const constructorBindings: FileConstructorBindings[] = [{ filePath: 'src/service.ts', bindings: [ { scope: 'save@10', varName: 'db', calleeName: 'Database' }, { scope: 'save@50', varName: 'db', calleeName: 'Cache' }, ], }]; const calls: ExtractedCall[] = [{ filePath: 'src/service.ts', calledName: 'query', sourceId: 'Method:src/service.ts:save', receiverName: 'db', callForm: 'member', }]; await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); // Ambiguous — different types for same funcName+varName, should not emit a CALLS edge const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(0); }); it('scope-aware bindings: same varName in different functions resolves to correct type', async () => { ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class'); ctx.symbols.add('src/models.ts', 'save', 'Function:src/models.ts:save', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const constructorBindings: FileConstructorBindings[] = [{ filePath: 'src/index.ts', bindings: [ { scope: 'processUser@12', varName: 'obj', calleeName: 'User' }, { scope: 'processRepo@89', varName: 'obj', calleeName: 'Repo' }, ], }]; const calls: ExtractedCall[] = [ { filePath: 'src/index.ts', calledName: 'save', sourceId: 'Function:src/index.ts:processUser', receiverName: 'obj', callForm: 'member', }, { filePath: 'src/index.ts', calledName: 'save', sourceId: 'Function:src/index.ts:processRepo', receiverName: 'obj', callForm: 'member', }, ]; await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(2); // Both calls should resolve, each with the correct receiver type from their scope // (the important thing is they don't collide — without scope awareness, // last-write-wins would give both calls the same receiver type) expect(rels[0].sourceId).toBe('Function:src/index.ts:processUser'); expect(rels[1].sourceId).toBe('Function:src/index.ts:processRepo'); }); }); describe('extractReturnTypeName', () => { it('extracts simple type name', () => { expect(extractReturnTypeName('User')).toBe('User'); }); it('unwraps Promise', () => { expect(extractReturnTypeName('Promise')).toBe('User'); }); it('unwraps Option', () => { expect(extractReturnTypeName('Option')).toBe('User'); }); it('unwraps Result to first type arg', () => { expect(extractReturnTypeName('Result')).toBe('User'); }); it('strips nullable union: User | null', () => { expect(extractReturnTypeName('User | null')).toBe('User'); }); it('strips nullable union: User | undefined', () => { expect(extractReturnTypeName('User | undefined')).toBe('User'); }); it('strips nullable suffix: User?', () => { expect(extractReturnTypeName('User?')).toBe('User'); }); it('strips Go pointer: *User', () => { expect(extractReturnTypeName('*User')).toBe('User'); }); it('strips Rust reference: &User', () => { expect(extractReturnTypeName('&User')).toBe('User'); }); it('strips Rust mutable reference: &mut User', () => { expect(extractReturnTypeName('&mut User')).toBe('User'); }); it('returns undefined for primitives', () => { expect(extractReturnTypeName('string')).toBeUndefined(); expect(extractReturnTypeName('number')).toBeUndefined(); expect(extractReturnTypeName('boolean')).toBeUndefined(); expect(extractReturnTypeName('void')).toBeUndefined(); expect(extractReturnTypeName('int')).toBeUndefined(); }); it('returns undefined for genuine union types', () => { expect(extractReturnTypeName('User | Repo')).toBeUndefined(); }); it('returns undefined for empty string', () => { expect(extractReturnTypeName('')).toBeUndefined(); }); it('extracts qualified type: models.User → User', () => { expect(extractReturnTypeName('models.User')).toBe('User'); }); it('handles non-wrapper generics: Map → Map', () => { expect(extractReturnTypeName('Map')).toBe('Map'); }); it('handles nested wrapper: Promise>', () => { // Promise> → unwrap Promise → Option → unwrap Option → User expect(extractReturnTypeName('Promise>')).toBe('User'); }); it('returns base type for collection generics (not unwrapped)', () => { expect(extractReturnTypeName('Vec')).toBe('Vec'); expect(extractReturnTypeName('List')).toBe('List'); expect(extractReturnTypeName('Array')).toBe('Array'); expect(extractReturnTypeName('Set')).toBe('Set'); expect(extractReturnTypeName('ArrayList')).toBe('ArrayList'); }); it('unwraps Optional', () => { expect(extractReturnTypeName('Optional')).toBe('User'); }); it('extracts Ruby :: qualified type: Models::User → User', () => { expect(extractReturnTypeName('Models::User')).toBe('User'); }); it('extracts C++ :: qualified type: ns::HttpClient → HttpClient', () => { expect(extractReturnTypeName('ns::HttpClient')).toBe('HttpClient'); }); it('extracts deep :: qualified type: crate::models::User → User', () => { expect(extractReturnTypeName('crate::models::User')).toBe('User'); }); it('extracts mixed qualifier: ns.module::User → User', () => { expect(extractReturnTypeName('ns.module::User')).toBe('User'); }); it('returns undefined for lowercase :: qualified: std::vector', () => { expect(extractReturnTypeName('std::vector')).toBeUndefined(); }); it('extracts deep dot-qualified: com.example.models.User → User', () => { expect(extractReturnTypeName('com.example.models.User')).toBe('User'); }); it('unwraps wrapper over non-wrapper generic: Promise> → Map', () => { // Promise is a wrapper — unwrap it to get Map. // Map is not a wrapper, so return its base type: Map. expect(extractReturnTypeName('Promise>')).toBe('Map'); }); it('unwraps doubly-nested wrapper: Future> → User', () => { // Future → unwrap → Result; Result → unwrap first arg → User expect(extractReturnTypeName('Future>')).toBe('User'); }); it('unwraps CompletableFuture> → User', () => { // CompletableFuture → unwrap → Optional; Optional → unwrap → User expect(extractReturnTypeName('CompletableFuture>')).toBe('User'); }); // Rust smart pointer unwrapping it('unwraps Rc → User', () => { expect(extractReturnTypeName('Rc')).toBe('User'); }); it('unwraps Arc → User', () => { expect(extractReturnTypeName('Arc')).toBe('User'); }); it('unwraps Weak → User', () => { expect(extractReturnTypeName('Weak')).toBe('User'); }); it('unwraps MutexGuard → User', () => { expect(extractReturnTypeName('MutexGuard')).toBe('User'); }); it('unwraps RwLockReadGuard → User', () => { expect(extractReturnTypeName('RwLockReadGuard')).toBe('User'); }); it('unwraps Cow → User', () => { expect(extractReturnTypeName('Cow')).toBe('User'); }); // Nested: Arc> → User (double unwrap) it('unwraps Arc> → User', () => { expect(extractReturnTypeName('Arc>')).toBe('User'); }); // NOT unwrapped (containers/wrappers not in set) it('does not unwrap Mutex (not a Deref wrapper)', () => { expect(extractReturnTypeName('Mutex')).toBe('Mutex'); }); // Rust lifetime parameters in wrapper generics it("skips lifetime in Ref<'_, User> → User", () => { expect(extractReturnTypeName("Ref<'_, User>")).toBe('User'); }); it("skips lifetime in RefMut<'a, User> → User", () => { expect(extractReturnTypeName("RefMut<'a, User>")).toBe('User'); }); it("skips lifetime in MutexGuard<'_, User> → User", () => { expect(extractReturnTypeName("MutexGuard<'_, User>")).toBe('User'); }); it('returns undefined for lowercase non-class types', () => { expect(extractReturnTypeName('error')).toBeUndefined(); }); it('extracts PHP backslash-namespaced type: \\App\\Models\\User → User', () => { expect(extractReturnTypeName('\\App\\Models\\User')).toBe('User'); }); it('extracts PHP single-segment namespace: \\User → User', () => { expect(extractReturnTypeName('\\User')).toBe('User'); }); it('extracts PHP deep namespace: \\Vendor\\Package\\Sub\\Client → Client', () => { expect(extractReturnTypeName('\\Vendor\\Package\\Sub\\Client')).toBe('Client'); }); it('returns undefined for bare wrapper type names without generic arguments', () => { expect(extractReturnTypeName('Task')).toBeUndefined(); expect(extractReturnTypeName('Promise')).toBeUndefined(); expect(extractReturnTypeName('Future')).toBeUndefined(); expect(extractReturnTypeName('Option')).toBeUndefined(); expect(extractReturnTypeName('Result')).toBeUndefined(); expect(extractReturnTypeName('Observable')).toBeUndefined(); expect(extractReturnTypeName('ValueTask')).toBeUndefined(); expect(extractReturnTypeName('CompletableFuture')).toBeUndefined(); expect(extractReturnTypeName('Optional')).toBeUndefined(); }); // ---- Length caps (Phase 6) ---- it('pre-cap: returns undefined when raw input exceeds 2048 characters', () => { const longInput = 'A'.repeat(2049); expect(extractReturnTypeName(longInput)).toBeUndefined(); }); it('pre-cap: accepts raw input at exactly 2048 characters (boundary)', () => { // A 2048-char string of uppercase letters passes the pre-cap gate. // It won't match as a valid identifier (too long for post-cap), so the // result is undefined — but the pre-cap itself does NOT reject it. // We test this by verifying a 2048-char type that WOULD be valid in all // other respects is still returned as undefined (post-cap rejects it). const atLimit = 'U' + 'x'.repeat(2047); // 2048 chars, starts with uppercase // Post-cap (512) will reject this, but the pre-cap should not fire. // The important assertion: no throw and the result is undefined from post-cap. expect(extractReturnTypeName(atLimit)).toBeUndefined(); }); it('pre-cap: accepts inputs shorter than 2048 characters without rejection', () => { // 'User' is well under 2048 — should resolve normally. expect(extractReturnTypeName('User')).toBe('User'); }); it('post-cap: returns undefined when extracted type name exceeds 512 characters', () => { // Construct a raw string that is under the 2048-char pre-cap but produces // a final identifier longer than 512 characters after extraction. // A bare uppercase identifier of 513 chars satisfies all rules except post-cap. const longTypeName = 'U' + 'x'.repeat(512); // 513 chars, starts with uppercase expect(extractReturnTypeName(longTypeName)).toBeUndefined(); }); it('post-cap: accepts extracted type name at exactly 512 characters (boundary)', () => { // 512-char identifier should pass the post-cap check (> 512 rejects, not >=). const atLimit = 'U' + 'x'.repeat(511); // exactly 512 chars expect(extractReturnTypeName(atLimit)).toBe(atLimit); }); it('post-cap: accepts normal short type names well under 512 characters', () => { expect(extractReturnTypeName('HttpClient')).toBe('HttpClient'); expect(extractReturnTypeName('UserService')).toBe('UserService'); }); });