diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 5458d437e..e5fbb8cd2 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -459,19 +459,19 @@ const SKIP_SUBTREE_TYPES = new Set([ ]); const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']); +type ClassDefRef = { nodeId: string; type: string; filePath: string }; const lookupClassDefsByName = ( symbolTable: SymbolTable, name: string, allowedTypes: ReadonlySet = CLASS_LIKE_TYPES, -): Array<{ nodeId: string; type: string }> => - symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type)); +): ClassDefRef[] => symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type)); /** Memoize class definition lookups during fixpoint iteration. * SymbolTable is immutable during type resolution, so results never change. * Eliminates redundant array allocations + filter scans across iterations. */ const createClassDefCache = (symbolTable?: SymbolTable) => { - const cache = new Map>(); + const cache = new Map(); return (typeName: string) => { let result = cache.get(typeName); if (result === undefined) { @@ -561,7 +561,7 @@ export const isSubclassOf = ( const walkParentChain = ( typeName: string, parentMap: ReadonlyMap | undefined, - getClassDefs: (name: string) => Array<{ nodeId: string; type: string }>, + getClassDefs: (name: string) => ClassDefRef[], lookupOnClass: (nodeId: string) => T | undefined, ): T | undefined => { if (!parentMap) return undefined; @@ -597,7 +597,7 @@ const resolveFieldType = ( field: string, scopeEnv: ReadonlyMap, symbolTable?: SymbolTable, - getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>, + getClassDefs?: (typeName: string) => ClassDefRef[], parentMap?: ReadonlyMap, ): string | undefined => { if (!symbolTable) return undefined; @@ -619,14 +619,14 @@ const resolveFieldType = ( /** Resolve a method's return type given a receiver variable and method name. * Uses SymbolTable to find class nodeIds for the receiver's type, then - * looks up the method via lookupFuzzyCallable filtered by ownerId. + * looks up the method via owner-scoped lookupMethodByOwner. * Falls back to MRO parent chain walking if direct lookup fails (Phase 11A). */ const resolveMethodReturnType = ( receiver: string, method: string, scopeEnv: ReadonlyMap, symbolTable?: SymbolTable, - getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>, + getClassDefs?: (typeName: string) => ClassDefRef[], parentMap?: ReadonlyMap, ): string | undefined => { if (!symbolTable) return undefined; @@ -642,21 +642,29 @@ const resolveMethodReturnType = ( const classDefs = lookup(receiverType); if (classDefs.length === 0) return undefined; // Direct lookup first - const classNodeIds = new Set(classDefs.map((d) => d.nodeId)); - const methods = symbolTable - .lookupFuzzyCallable(method) - .filter((d) => d.ownerId && classNodeIds.has(d.ownerId)); + const directMethodLookups = classDefs.map((d) => ({ + classDef: d, + methodDef: symbolTable.lookupMethodByOwner(d.nodeId, method), + })); + const hasAmbiguousDirectLookup = directMethodLookups.some(({ classDef, methodDef }) => { + if (methodDef) return false; + return symbolTable + .lookupExactAll(classDef.filePath, method) + .some((d) => d.ownerId === classDef.nodeId); + }); + if (hasAmbiguousDirectLookup) return undefined; + const methods = directMethodLookups + .map(({ methodDef }) => methodDef) + .filter((d): d is NonNullable => d !== undefined); if (methods.length === 1 && methods[0].returnType) { return extractReturnTypeName(methods[0].returnType); } // MRO parent chain walking on miss if (methods.length === 0) { const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => { - const parentMethods = symbolTable - .lookupFuzzyCallable(method) - .filter((d) => d.ownerId === nodeId); - if (parentMethods.length !== 1 || !parentMethods[0].returnType) return undefined; - return extractReturnTypeName(parentMethods[0].returnType); + const parentMethod = symbolTable.lookupMethodByOwner(nodeId, method); + if (!parentMethod?.returnType) return undefined; + return extractReturnTypeName(parentMethod.returnType); }); return inherited; } diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 47942c7e3..db46bb31b 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,6 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; -import type { SymbolDefinition, SymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { + createSymbolTable, + type SymbolDefinition, + type SymbolTable, +} from '../../src/core/ingestion/symbol-table.js'; import { stripNullable, extractSimpleTypeName, @@ -2445,6 +2449,394 @@ function process(user: User) { const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); expect(flatGet(typeEnv, 'addr')).toBeUndefined(); }); + + it('method return type resolution uses lookupMethodByOwner-backed class defs', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => + name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:Repo' && methodName === 'getProfile' + ? { + nodeId: 'method:Repo:getProfile', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Profile', + } + : undefined, + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('inherited method return type resolution uses lookupMethodByOwner on parent owners', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => { + if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; + if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; + return []; + }, + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' + ? { + nodeId: 'method:BaseRepo:getProfile', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + } + : undefined, + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable, + parentMap: new Map([['Repo', ['BaseRepo']]]), + }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('method return type resolution handles multiple class defs when only one owner has the method', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => + name === 'Repo' + ? [ + createClassDef('Repo', 'Class', 'models-a.ts'), + { + ...createClassDef('Repo', 'Class', 'models-b.ts'), + nodeId: 'class:Repo:partial', + }, + ] + : [], + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:Repo:partial' && methodName === 'getProfile' + ? { + nodeId: 'method:Repo:getProfile', + filePath: 'models-b.ts', + type: 'Method', + ownerId: 'class:Repo:partial', + returnType: 'Profile', + } + : undefined, + lookupExactAll: () => [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('method return type resolution with multiple class defs falls back to MRO when direct owners miss', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => { + if (name === 'Repo') { + return [ + createClassDef('Repo', 'Class', 'models-a.ts'), + { ...createClassDef('Repo', 'Class', 'models-b.ts'), nodeId: 'class:Repo:partial' }, + ]; + } + if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; + return []; + }, + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' + ? { + nodeId: 'method:BaseRepo:getProfile', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + } + : undefined, + lookupExactAll: () => [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable, + parentMap: new Map([['Repo', ['BaseRepo']]]), + }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('method return type resolution stays unresolved when multiple class defs each define the method', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => + name === 'Repo' + ? [ + createClassDef('Repo', 'Class', 'models-a.ts'), + { + ...createClassDef('Repo', 'Class', 'models-b.ts'), + nodeId: 'class:Repo:partial', + }, + ] + : [], + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => { + if (methodName !== 'getProfile') return undefined; + if (ownerNodeId === 'class:Repo') { + return { + nodeId: 'method:Repo:getProfile#a', + filePath: 'models-a.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Profile', + }; + } + if (ownerNodeId === 'class:Repo:partial') { + return { + nodeId: 'method:Repo:getProfile#b', + filePath: 'models-b.ts', + type: 'Method', + ownerId: 'class:Repo:partial', + returnType: 'Profile', + }; + } + return undefined; + }, + lookupExactAll: () => [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + expect(flatGet(typeEnv, 'profile')).toBeUndefined(); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('method return type resolution preserves same-return overload success', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => + name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:Repo' && methodName === 'getProfile' + ? { + nodeId: 'method:Repo:getProfile#1', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Profile', + } + : undefined, + lookupExactAll: (filePath: string, name: string) => + filePath === 'models.ts' && name === 'getProfile' + ? [ + { + nodeId: 'method:Repo:getProfile#1', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Profile', + }, + { + nodeId: 'method:Repo:getProfile#2', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Profile', + }, + ] + : [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('method return type resolution stays unresolved for ambiguous overloads with differing returns', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => { + if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; + if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; + return []; + }, + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' + ? { + nodeId: 'method:BaseRepo:getProfile', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + } + : undefined, + lookupExactAll: (filePath: string, name: string) => + filePath === 'models.ts' && name === 'getProfile' + ? [ + { + nodeId: 'method:Repo:getProfile#1', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'User', + }, + { + nodeId: 'method:Repo:getProfile#2', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Admin', + }, + ] + : [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable, + parentMap: new Map([['Repo', ['BaseRepo']]]), + }); + expect(flatGet(typeEnv, 'profile')).toBeUndefined(); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('inherited method return type resolution preserves same-return overload success on parent owners', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => { + if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; + if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; + return []; + }, + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' + ? { + nodeId: 'method:BaseRepo:getProfile#1', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + } + : undefined, + lookupExactAll: (filePath: string, name: string) => + filePath === 'base.ts' && name === 'getProfile' + ? [ + { + nodeId: 'method:BaseRepo:getProfile#1', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + }, + { + nodeId: 'method:BaseRepo:getProfile#2', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + }, + ] + : [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable, + parentMap: new Map([['Repo', ['BaseRepo']]]), + }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('inherited method return type resolution stays unresolved for ambiguous overloads on parent owners', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const symbolTable = createSymbolTable(); + symbolTable.add('models.ts', 'Repo', 'class:Repo', 'Class'); + symbolTable.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', { + ownerId: 'class:BaseRepo', + parameterCount: 1, + returnType: 'User', + }); + symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', { + ownerId: 'class:BaseRepo', + parameterCount: 2, + returnType: 'Admin', + }); + const lookupFuzzyCallable = vi.spyOn(symbolTable, 'lookupFuzzyCallable'); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable, + parentMap: new Map([['Repo', ['BaseRepo']]]), + }); + expect(flatGet(typeEnv, 'profile')).toBeUndefined(); + expect(lookupFuzzyCallable).not.toHaveBeenCalled(); + }); }); describe('Python walrus operator type inference', () => {