diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 9abd15b0e..3736d0c46 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -17,6 +17,7 @@ "commander": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", + "gitnexus-shared": "file:../gitnexus-shared", "glob": "^11.0.0", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index deb58e9e9..1ec22ea5f 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1365,33 +1365,29 @@ const resolveCallTarget = ( // belong to the wrong class (e.g. super.save() should hit the parent's save, // not the child's own save method in the same file). if (call.callForm === 'member' && call.receiverTypeName) { - // D0. MRO fast path: when heritageMap is available, try owner-scoped + MRO - // lookup before falling back to the expensive D2 fuzzy widening. - // This short-circuits the lookupFuzzy call for every cross-file member call. + // D0. Delegate to resolveMemberCall (SM-11): owner-scoped + MRO lookup + // before falling back to the expensive D1-D4 fuzzy widening. // Skip conditions: // (a) overloadHints or preComputedArgTypes present — the MRO lookup may // pick the wrong overload for same-return-type overloads since it - // does not consider argument types. D2-D4+E handles those correctly. + // does not consider argument types. D1-D4+E handles those correctly. // (b) A module alias on call.receiverName is active for this file — the // alias block above already narrowed `filteredCandidates` to a - // specific file (e.g. Python `import auth; auth.user.save()`). - // resolveMethodByOwner re-resolves `receiverTypeName` from scratch - // via `ctx.resolve`, which ignores that narrowing and could pick a - // homonymous class from the wrong file. Fall through to D1-D4 which - // respects the alias-filtered candidate pool. + // specific file. resolveMemberCall re-resolves `receiverTypeName` + // from scratch via `ctx.resolve`, which ignores that narrowing and + // could pick a homonymous class from the wrong file. Fall through to + // D1-D4 which respects the alias-filtered candidate pool. const hasActiveModuleAlias = !!call.receiverName && ctx.moduleAliasMap?.get(currentFile)?.has(call.receiverName) === true; if (!overloadHints && !preComputedArgTypes && !hasActiveModuleAlias) { - const mroResult = resolveMethodByOwner( + const memberResult = resolveMemberCall( call.receiverTypeName, call.calledName, currentFile, ctx, heritageMap, ); - if (mroResult) { - return toResolveResult(mroResult, tiered.tier); - } + if (memberResult) return memberResult; } // D1. Resolve the receiver type @@ -1671,6 +1667,46 @@ const resolveMethodByOwner = ( return ctx.symbols.lookupMethodByOwner(classDef.nodeId, methodName); }; +// --------------------------------------------------------------------------- +// SM-11: Owner-scoped + MRO member-call resolution (no fuzzy lookup) +// --------------------------------------------------------------------------- + +/** + * Resolve a member call using owner-scoped + MRO resolution only (no fuzzy lookup). + * Used for `obj.method()` calls where the receiver type is known. + * + * Delegates to {@link resolveMethodByOwner} which performs an O(1) owner-scoped + * method lookup and, when a {@link HeritageMap} is provided, walks the MRO chain + * via {@link lookupMethodByOwnerWithMRO}. + * + * {@link resolveCallTarget} delegates here for member calls before falling back + * to the more expensive fuzzy-widening path (D1-D4). + * + * @param ownerType - The receiver's type name (e.g. 'User') + * @param methodName - The method being called (e.g. 'save') + * @param currentFile - File path of the call site + * @param ctx - Resolution context + * @param heritageMap - Optional heritage map for MRO-aware ancestor walking + */ +export const resolveMemberCall = ( + ownerType: string, + methodName: string, + currentFile: string, + ctx: ResolutionContext, + heritageMap?: HeritageMap, +): ResolveResult | null => { + const methodDef = resolveMethodByOwner(ownerType, methodName, currentFile, ctx, heritageMap); + if (!methodDef) return null; + + // Determine confidence tier from how the owner type was resolved. + // ctx.resolve is per-file cached so this second call (resolveMethodByOwner + // already called it internally) is essentially free. + const ownerResolved = ctx.resolve(ownerType, currentFile); + const tier = ownerResolved?.tier ?? 'global'; + + return toResolveResult(methodDef, tier); +}; + // --------------------------------------------------------------------------- // MRO-aware method resolution via HeritageMap (SM-9) // --------------------------------------------------------------------------- @@ -1860,13 +1896,12 @@ const walkMixedChain = ( currentType = fieldResolved.typeName; continue; } - // Fast path: O(1) owner-scoped method lookup via methodByOwner index. - // Avoids fuzzy lookup when the owner type is known and the method is unambiguous. + // SM-11: delegate to resolveMemberCall for owner-scoped + MRO resolution. // Note: CALLS edges for intermediate chain steps are NOT emitted here — walkMixedChain // only threads types. CALLS edges come from the outer per-call-expression loop in processCalls. - const methodDef = resolveMethodByOwner(currentType, step.name, filePath, ctx, heritageMap); - if (methodDef?.returnType) { - const fastRetType = extractReturnTypeName(methodDef.returnType); + const memberResult = resolveMemberCall(currentType, step.name, filePath, ctx, heritageMap); + if (memberResult?.returnType) { + const fastRetType = extractReturnTypeName(memberResult.returnType); if (fastRetType) { currentType = fastRetType; continue; diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index b2ba7e288..e3578b416 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1398,3 +1398,119 @@ describe('lookupMethodByOwnerWithMRO', () => { expect(result!.nodeId).toBe('method:User:getName'); }); }); + +// --------------------------------------------------------------------------- +// resolveMemberCall — SM-11: owner-scoped + MRO member-call resolution +// --------------------------------------------------------------------------- + +import { resolveMemberCall } from '../../src/core/ingestion/call-processor.js'; + +describe('resolveMemberCall', () => { + let ctx: ResolutionContext; + + beforeEach(() => { + ctx = createResolutionContext(); + }); + + it('resolves direct method on owner type', () => { + ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + returnType: 'void', + ownerId: 'class:User', + }); + ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); + + const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('method:User:save'); + expect(result!.returnType).toBe('void'); + expect(result!.confidence).toBeGreaterThan(0); + }); + + it('resolves inherited method via MRO walk', () => { + ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.symbols.add('src/parent.java', 'validate', 'method:Parent:validate', 'Method', { + returnType: 'boolean', + ownerId: 'class:Parent', + }); + ctx.importMap.set('src/app.java', new Set(['src/child.java', 'src/parent.java'])); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = resolveMemberCall('Child', 'validate', 'src/app.java', ctx, map); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('method:Parent:validate'); + expect(result!.returnType).toBe('boolean'); + }); + + it('returns null for unknown owner type', () => { + const result = resolveMemberCall('NonExistent', 'save', 'src/app.ts', ctx); + expect(result).toBeNull(); + }); + + it('returns null for unknown method on known owner', () => { + ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); + + const result = resolveMemberCall('User', 'nonExistentMethod', 'src/app.ts', ctx); + expect(result).toBeNull(); + }); + + it('returns result with correct confidence tier for same-file resolution', () => { + ctx.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); + ctx.symbols.add('src/app.ts', 'save', 'method:User:save', 'Method', { + returnType: 'void', + ownerId: 'class:User', + }); + + const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx); + + expect(result).not.toBeNull(); + expect(result!.confidence).toBe(0.95); // same-file tier + expect(result!.reason).toBe('same-file'); + }); + + it('returns result with import-scoped tier for cross-file resolution', () => { + ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + returnType: 'void', + ownerId: 'class:User', + }); + ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); + + const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx); + + expect(result).not.toBeNull(); + expect(result!.confidence).toBe(0.9); // import-scoped tier + expect(result!.reason).toBe('import-resolved'); + }); + + it('resolves with heritage map across C3 MRO chain (Python)', () => { + ctx.symbols.add('src/a.py', 'A', 'class:A', 'Class'); + ctx.symbols.add('src/b.py', 'B', 'class:B', 'Class'); + ctx.symbols.add('src/c.py', 'C', 'class:C', 'Class'); + ctx.symbols.add('src/a.py', 'foo', 'method:A:foo', 'Method', { + returnType: 'str', + ownerId: 'class:A', + }); + ctx.importMap.set('src/main.py', new Set(['src/a.py', 'src/b.py', 'src/c.py'])); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/c.py', className: 'C', parentName: 'B', kind: 'extends' }, + { filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = resolveMemberCall('C', 'foo', 'src/main.py', ctx, map); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('method:A:foo'); + expect(result!.returnType).toBe('str'); + }); +});