From d9ba9aa998f6db8ac7763427b00a962fb13893e4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:24:07 +0100 Subject: [PATCH] SM-10: Add MRO fast path before D2 fuzzy widening in resolveCallTarget (#741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Add MRO fast path before D2 fuzzy widening in resolveCallTarget When receiverTypeName is known, try resolveMethodByOwner (owner-scoped + MRO lookup) before falling back to the expensive lookupFuzzy in D2. This short-circuits cross-file member call resolution for the common non-overloaded case. The fast path is skipped when overload disambiguation hints are available (overloadHints or preComputedArgTypes) to avoid picking the wrong overload for same-return-type overloaded methods. Passes heritageMap to resolveCallTarget from all 4 call sites: - Language seed path (processCalls) - Sequential path (processCalls) - walkMixedChain fallback - Worker path (processCallsFromExtracted) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9e49521f-2472-47bc-96e9-be4a46b073f0 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(SM-10): address PR #741 review Correctness: - Module-alias guard for D0. When call.receiverName matches an active entry in ctx.moduleAliasMap for the current file, D0 is now skipped and resolution falls through to D1-D4 which respects the alias-narrowed candidate pool. Prevents a homonymous class in a different file from being picked by ctx.resolve(receiverTypeName) inside resolveMethodByOwner. New unit test pins the contract. Unit tests (call-processor.test.ts — 3 new): - D0 hit: child.parentMethod() resolves via MRO walk when heritageMap is provided. - D0 skipped: same scenario still resolves via D1-D4 when heritageMap is undefined (backward-compat guard). - Module-alias guard: two files both define class User with a save() method; 'import auth_mod as auth' in app.py must resolve auth.user.save() to auth_mod.py, not user_mod.py. Integration language coverage (+3 fixtures/tests): - swift-child-extends-parent — first-wins, gated on swiftAvailable. - ruby-child-extends-parent — first-wins. - php-child-extends-parent — first-wins (uses ParentClass since 'Parent' is a PHP reserved word). * test(SM-10): address second PR #741 review round Unit tests (call-processor.test.ts, +2 new): - overloadHints guard: Java source with two same-return-type overloads method(int) and method(String), int added first so lookupMethodByOwner would return it. processCalls auto-generates overloadHints for Java, forcing D0 to be skipped. o.method("hello") must resolve to method(String) via literal-inferred disambiguation. - preComputedArgTypes guard: worker-path equivalent via processCallsFromExtracted with ExtractedCall.argTypes=['String']. Same two overloads, same correctness guarantee. Integration tests (+2 fixtures + test blocks): - go-child-extends-parent — struct embedding, first-wins (Go structs are labeled 'Struct' not 'Class' in GitNexus). - dart-child-extends-parent — extends, first-wins, gated on dartAvailable like other Dart tests. Documentation: - Expanded the fallthrough comment in resolveMethodByOwner to clarify that unknown-extension paths land on plain lookupMethodByOwner without an ancestor walk, and that D1-D4 still runs on D0 miss. * test(SM-10): D0 miss with heritageMap present falls through to D1-D4 Closes the last remaining gap from PR #741 review round 3. The existing 'D0 skipped' test only covered the heritageMap=undefined case, leaving the miss-with-heritageMap path implicitly covered by integration tests only. This adds a focused unit test where: - Class Obj has a method doWork findable via tiered resolution (import-scoped) but intentionally NOT registered in methodByOwner (no ownerId), so lookupMethodByOwner misses. - heritageMap is provided but built from an empty heritage array, so getAncestors(class:Obj) returns []. The MRO walk yields no parents. - lookupMethodByOwnerWithMRO therefore returns undefined → D0 miss. - D1 resolves the receiver type; D2 widens via lookupFuzzy; D3 file-filter picks the single matching candidate. - A CALLS edge must still be emitted — D0 miss must not swallow the call. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- gitnexus/src/core/ingestion/call-processor.ts | 47 ++- .../dart-child-extends-parent/app.dart | 8 + .../dart-child-extends-parent/child.dart | 3 + .../dart-child-extends-parent/parent.dart | 5 + .../go-child-extends-parent/go.mod | 3 + .../go-child-extends-parent/models/child.go | 5 + .../go-child-extends-parent/models/parent.go | 7 + .../go-child-extends-parent/services/app.go | 8 + .../php-child-extends-parent/src/App.php | 14 + .../php-child-extends-parent/src/Child.php | 7 + .../php-child-extends-parent/src/Parent.php | 11 + .../ruby-child-extends-parent/lib/app.rb | 8 + .../ruby-child-extends-parent/lib/child.rb | 4 + .../ruby-child-extends-parent/lib/parent.rb | 5 + .../Sources/App.swift | 6 + .../Sources/Child.swift | 2 + .../Sources/Parent.swift | 5 + .../test/integration/resolvers/dart.test.ts | 38 ++ .../test/integration/resolvers/go.test.ts | 32 ++ .../test/integration/resolvers/php.test.ts | 27 ++ .../test/integration/resolvers/ruby.test.ts | 27 ++ .../test/integration/resolvers/swift.test.ts | 33 ++ gitnexus/test/unit/call-processor.test.ts | 345 ++++++++++++++++++ 23 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go create mode 100644 gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Parent.php create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/app.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/child.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/parent.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/App.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Child.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Parent.swift diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ecb778a46..deb58e9e9 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -787,6 +787,8 @@ export const processCalls = async ( ctx, undefined, widenCache, + undefined, + heritageMap, ); if (!resolved) return; @@ -1033,6 +1035,8 @@ export const processCalls = async ( ctx, hints, widenCache, + undefined, + heritageMap, ); if (!resolved) return; @@ -1285,6 +1289,7 @@ const resolveCallTarget = ( overloadHints?: OverloadHints, widenCache?: WidenCache, preComputedArgTypes?: (string | undefined)[], + heritageMap?: HeritageMap, ): ResolveResult | null => { const tiered = ctx.resolve(call.calledName, currentFile); if (!tiered) return null; @@ -1360,6 +1365,35 @@ 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. + // 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. + // (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. + const hasActiveModuleAlias = + !!call.receiverName && ctx.moduleAliasMap?.get(currentFile)?.has(call.receiverName) === true; + if (!overloadHints && !preComputedArgTypes && !hasActiveModuleAlias) { + const mroResult = resolveMethodByOwner( + call.receiverTypeName, + call.calledName, + currentFile, + ctx, + heritageMap, + ); + if (mroResult) { + return toResolveResult(mroResult, tiered.tier); + } + } + // D1. Resolve the receiver type const typeResolved = ctx.resolve(call.receiverTypeName, currentFile); if (typeResolved && typeResolved.candidates.length > 0) { @@ -1628,8 +1662,12 @@ const resolveMethodByOwner = ( } } - // Fallback when no HeritageMap (or the file extension is unrecognized): - // plain direct lookup with no ancestor walk. + // Fallback when no HeritageMap (or the file extension is unrecognized by + // `getLanguageFromFilename`, e.g. a synthetic path or an extension that is + // not registered in supported-languages.ts): plain direct lookup with no + // ancestor walk. All primary languages register their extensions, so this + // branch is only reached for edge cases where the MRO walk would not be + // applicable anyway. D1-D4 in resolveCallTarget still runs on D0 miss. return ctx.symbols.lookupMethodByOwner(classDef.nodeId, methodName); }; @@ -1839,6 +1877,10 @@ const walkMixedChain = ( { calledName: step.name, callForm: 'member', receiverTypeName: currentType }, filePath, ctx, + undefined, + undefined, + undefined, + heritageMap, ); if (!resolved) { // Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type @@ -1988,6 +2030,7 @@ export const processCallsFromExtracted = async ( undefined, widenCache, effectiveCall.argTypes, + heritageMap, ); if (!resolved) { // Vue template component fallback: match calledName against imported .vue basenames diff --git a/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart new file mode 100644 index 000000000..0c431a071 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart @@ -0,0 +1,8 @@ +import 'child.dart'; + +class App { + void run() { + final c = Child(); + c.parentMethod(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart new file mode 100644 index 000000000..fafe63719 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart @@ -0,0 +1,3 @@ +import 'parent.dart'; + +class Child extends Parent {} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart new file mode 100644 index 000000000..28c09da37 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart @@ -0,0 +1,5 @@ +class Parent { + String parentMethod() { + return 'parent'; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod new file mode 100644 index 000000000..192e075e8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod @@ -0,0 +1,3 @@ +module example.com/app + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go new file mode 100644 index 000000000..aac88e503 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go @@ -0,0 +1,5 @@ +package models + +type Child struct { + Parent +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go new file mode 100644 index 000000000..4aa63ee03 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go @@ -0,0 +1,7 @@ +package models + +type Parent struct{} + +func (p *Parent) ParentMethod() string { + return "parent" +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go new file mode 100644 index 000000000..cde995386 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go @@ -0,0 +1,8 @@ +package services + +import "example.com/app/models" + +func Run() { + c := &models.Child{} + c.ParentMethod() +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php new file mode 100644 index 000000000..71ddc605a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php @@ -0,0 +1,14 @@ +parentMethod(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php new file mode 100644 index 000000000..a031de193 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php @@ -0,0 +1,7 @@ + String { + return "parent" + } +} diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts index 7f995e09d..82fa77f22 100644 --- a/gitnexus/test/integration/resolvers/dart.test.ts +++ b/gitnexus/test/integration/resolvers/dart.test.ts @@ -474,3 +474,41 @@ describe.skipIf(!dartAvailable)('Dart interface dispatch (METHOD_IMPLEMENTS)', ( expect(saveEdge).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Dart first-wins +// --------------------------------------------------------------------------- + +describe.skipIf(!dartAvailable)( + 'Dart Child extends Parent — inherited method resolution (SM-9)', + () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'dart-child-extends-parent'), + () => {}, + ); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge: Child → Parent', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Child → Parent'); + }); + + it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parentMethod' && c.targetFilePath.includes('parent.dart'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); + }, +); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 0ec9b2f6c..69761f889 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -1345,3 +1345,35 @@ describe('Go method enrichment', () => { expect(classifyCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Go struct embedding +// --------------------------------------------------------------------------- + +describe('Go Child embeds Parent — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'go-child-extends-parent'), () => {}); + }, 60000); + + it('detects Parent and Child structs', () => { + const structs = getNodesByLabel(result, 'Struct'); + expect(structs).toContain('Parent'); + expect(structs).toContain('Child'); + }); + + it('emits EXTENDS edge: Child → Parent (struct embedding)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Child → Parent'); + }); + + it('resolves c.ParentMethod() to Parent.ParentMethod via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'ParentMethod' && c.targetFilePath.includes('parent.go'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('Run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index a72da9439..e120d92f4 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -1780,3 +1780,30 @@ describe('PHP abstract dispatch', () => { expect(names).toEqual(['find', 'save']); }); }); + +// --------------------------------------------------------------------------- +// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — PHP first-wins +// --------------------------------------------------------------------------- + +describe('PHP Child extends ParentClass — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-child-extends-parent'), () => {}); + }, 60000); + + it('detects ParentClass and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('ParentClass'); + expect(classes).toContain('Child'); + }); + + it('resolves $c->parentMethod() to ParentClass::parentMethod via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.php'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 865c41c4a..7e508e756 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1330,3 +1330,30 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { expect(methods).toContain('run'); }); }); + +// --------------------------------------------------------------------------- +// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Ruby first-wins +// --------------------------------------------------------------------------- + +describe('Ruby Child extends Parent — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-child-extends-parent'), () => {}); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('resolves c.parent_method to Parent#parent_method via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.rb'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 272770527..f00435165 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -865,3 +865,36 @@ describe.skipIf(!swiftAvailable)('Swift overloaded method disambiguation', () => expect(mi.length).toBe(3); }); }); + +// --------------------------------------------------------------------------- +// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Swift first-wins +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)( + 'Swift Child extends Parent — inherited method resolution (SM-9)', + () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-child-extends-parent'), + () => {}, + ); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.swift'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); + }, +); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index bb4590d5a..81dc9f042 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -1591,3 +1591,348 @@ describe('processCallsFromExtracted — interface dispatch', () => { expect(toB?.reason).toBe('interface-dispatch'); }); }); + +// --------------------------------------------------------------------------- +// SM-10: D0 MRO fast path in resolveCallTarget +// --------------------------------------------------------------------------- + +describe('processCalls — D0 MRO fast path (SM-10)', () => { + let graph: ReturnType; + let ctx: ResolutionContext; + + beforeEach(() => { + graph = createKnowledgeGraph(); + ctx = createResolutionContext(); + }); + + const setupChildParent = () => { + const parentFile = 'src/models/Parent.java'; + const childFile = 'src/models/Child.java'; + const appFile = 'src/services/App.java'; + const parentId = 'class:models/Parent.java:Parent'; + const childId = 'class:models/Child.java:Child'; + const parentMethodId = 'method:models/Parent.java:parentMethod'; + + ctx.symbols.add(parentFile, 'Parent', parentId, 'Class'); + ctx.symbols.add(childFile, 'Child', childId, 'Class'); + ctx.symbols.add(parentFile, 'parentMethod', parentMethodId, 'Method', { + ownerId: parentId, + returnType: 'String', + }); + ctx.importMap.set(appFile, new Set([childFile, parentFile])); + return { parentFile, childFile, appFile, parentId, childId, parentMethodId }; + }; + + it('D0 hit: child.parentMethod() resolves via MRO walk when heritageMap is provided', async () => { + const { parentMethodId, appFile, parentFile, childFile } = setupChildParent(); + + const heritage: ExtractedHeritage[] = [ + { + filePath: childFile, + className: 'Child', + parentName: 'Parent', + kind: 'extends', + }, + ]; + const heritageMap = buildHeritageMap(heritage, ctx); + + await processCalls( + graph, + [ + { + path: parentFile, + content: + 'package models;\npublic class Parent {\n public String parentMethod() { return ""; }\n}\n', + }, + { + path: childFile, + content: 'package models;\npublic class Child extends Parent {}\n', + }, + { + path: appFile, + content: + 'package services;\nimport models.Child;\npublic class App {\n public void run() {\n Child c = new Child();\n c.parentMethod();\n }\n}\n', + }, + ], + createASTCache(), + ctx, + undefined, + undefined, + undefined, + undefined, + undefined, + heritageMap, + ); + + const parentMethodCalls = graph.relationships.filter( + (r) => r.type === 'CALLS' && r.targetId === parentMethodId, + ); + expect(parentMethodCalls).toHaveLength(1); + }); + + it('D0 miss: heritageMap provided but method not in MRO chain falls through to D1-D4', async () => { + // Setup: Class Obj has a method `doWork` that is findable via tiered + // resolution (import-scoped lookup), but intentionally NOT registered in + // methodByOwner (no `ownerId` property). heritageMap is provided but has + // no ancestry entry for class:Obj. Expected flow: + // D0: lookupMethodByOwner(classId, 'doWork') → undefined + // heritageMap.getAncestors(classId) → [] + // lookupMethodByOwnerWithMRO returns undefined → D0 miss + // D1-D4: receiver type resolves to Obj; D2 widens via lookupFuzzy; + // D3 file-filter picks the only candidate in Obj's file. + // Guarantees D0 miss does not swallow the call — D1-D4 still runs. + const classFile = 'src/models/Obj.java'; + const appFile = 'src/services/App.java'; + const classId = 'class:models/Obj.java:Obj'; + const doWorkId = 'method:models/Obj.java:doWork'; + + ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + // Intentionally omit ownerId so methodByOwner has no entry — forces D0 miss. + ctx.symbols.add(classFile, 'doWork', doWorkId, 'Method', { + returnType: 'void', + parameterCount: 0, + }); + ctx.importMap.set(appFile, new Set([classFile])); + + // Empty heritage — no ancestry for Obj, so the MRO walk yields no parents. + const heritageMap = buildHeritageMap([], ctx); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'doWork', + sourceId: 'method:services/App.java:run', + argCount: 0, + callForm: 'member', + receiverTypeName: 'Obj', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap); + + const doWorkCalls = graph.relationships.filter( + (r) => r.type === 'CALLS' && r.targetId === doWorkId, + ); + expect(doWorkCalls).toHaveLength(1); + }); + + it('D0 skipped: same scenario still resolves via D1-D4 when heritageMap is undefined', async () => { + const { parentMethodId, appFile, parentFile, childFile } = setupChildParent(); + + await processCalls( + graph, + [ + { + path: parentFile, + content: + 'package models;\npublic class Parent {\n public String parentMethod() { return ""; }\n}\n', + }, + { + path: childFile, + content: 'package models;\npublic class Child extends Parent {}\n', + }, + { + path: appFile, + content: + 'package services;\nimport models.Child;\npublic class App {\n public void run() {\n Child c = new Child();\n c.parentMethod();\n }\n}\n', + }, + ], + createASTCache(), + ctx, + // no heritageMap — D0 fast path must be skipped, D1-D4 must still resolve + ); + + const parentMethodCalls = graph.relationships.filter( + (r) => r.type === 'CALLS' && r.targetId === parentMethodId, + ); + expect(parentMethodCalls).toHaveLength(1); + }); + + it('overloadHints guard: D0 skipped so literal-inferred overload disambiguation picks the right overload', async () => { + // Java sequential path: processCalls auto-generates `overloadHints` for + // languages whose provider exposes `inferLiteralType` (Java/Kotlin/C#/C++). + // When two overloads share the same return type, lookupMethodByOwner + // returns defs[0] (the first-added overload) regardless of argument + // types. Without the D0 guard this would mis-resolve `o.method("hello")` + // to method(int). With the guard, D0 is skipped because overloadHints + // is present, and the literal-inferred overload path in D2-D4+E picks + // method(String) correctly. + const classFile = 'src/models/Obj.java'; + const appFile = 'src/services/App.java'; + const classId = 'class:models/Obj.java:Obj'; + const methodIntId = 'method:models/Obj.java:method(int)'; + const methodStringId = 'method:models/Obj.java:method(String)'; + + ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + // int overload added FIRST so lookupMethodByOwner would return it. + ctx.symbols.add(classFile, 'method', methodIntId, 'Method', { + ownerId: classId, + returnType: 'String', + parameterCount: 1, + parameterTypes: ['int'], + }); + ctx.symbols.add(classFile, 'method', methodStringId, 'Method', { + ownerId: classId, + returnType: 'String', + parameterCount: 1, + parameterTypes: ['String'], + }); + ctx.importMap.set(appFile, new Set([classFile])); + + const heritageMap = buildHeritageMap([], ctx); + + await processCalls( + graph, + [ + { + path: classFile, + content: + 'package models;\npublic class Obj {\n public String method(int x) { return ""; }\n public String method(String s) { return ""; }\n}\n', + }, + { + path: appFile, + content: + 'package services;\nimport models.Obj;\npublic class App {\n public void run() {\n Obj o = new Obj();\n o.method("hello");\n }\n}\n', + }, + ], + createASTCache(), + ctx, + undefined, + undefined, + undefined, + undefined, + undefined, + heritageMap, + ); + + // Exactly one resolved call, and it must target the String overload. + const methodCalls = graph.relationships.filter( + (r) => r.type === 'CALLS' && (r.targetId === methodIntId || r.targetId === methodStringId), + ); + expect(methodCalls).toHaveLength(1); + expect(methodCalls[0].targetId).toBe(methodStringId); + }); + + it('preComputedArgTypes guard: D0 skipped so arg-type disambiguation picks the right overload', async () => { + // Two overloads of the same method with identical return types live on + // the same owner class. Without the D0 guard, lookupMethodByOwner would + // return defs[0] (the first overload added) regardless of argument types, + // silently mis-resolving an `obj.method("hello")` call to method(int). + // With the guard, preComputedArgTypes forces D0 to be skipped and D2-D4+E + // disambiguates by parameter type. + const classFile = 'src/models/Obj.java'; + const appFile = 'src/services/App.java'; + const classId = 'class:models/Obj.java:Obj'; + const methodIntId = 'method:models/Obj.java:method(int)'; + const methodStringId = 'method:models/Obj.java:method(String)'; + + ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + // int overload added FIRST — without the guard this would be returned by + // lookupMethodByOwner's same-return-type fast path. + ctx.symbols.add(classFile, 'method', methodIntId, 'Method', { + ownerId: classId, + returnType: 'String', + parameterCount: 1, + parameterTypes: ['int'], + }); + ctx.symbols.add(classFile, 'method', methodStringId, 'Method', { + ownerId: classId, + returnType: 'String', + parameterCount: 1, + parameterTypes: ['String'], + }); + ctx.importMap.set(appFile, new Set([classFile])); + + const heritageMap = buildHeritageMap([], ctx); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'method', + sourceId: 'method:services/App.java:run', + argCount: 1, + callForm: 'member', + receiverTypeName: 'Obj', + argTypes: ['String'], + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap); + + const methodCalls = graph.relationships.filter((r) => r.type === 'CALLS'); + // Exactly one resolved call, and it must target the String overload — + // NOT the int overload that lookupMethodByOwner would have returned. + expect(methodCalls).toHaveLength(1); + expect(methodCalls[0].targetId).toBe(methodStringId); + }); + + it('module-alias guard: D0 skipped when receiverName matches an active module alias', async () => { + // Setup: two files each define a class named User with a method save(). + // The caller has a Python-style module alias `import auth_mod as auth`, + // so auth.User().save() must resolve to auth_mod.py, NOT user_mod.py. + // D0 would call ctx.resolve('User') and could pick the wrong file; the + // alias guard must short-circuit D0 so the alias-filtered D1-D4 path + // runs and picks the correct file. + const authModFile = 'auth_mod.py'; + const userModFile = 'user_mod.py'; + const appFile = 'app.py'; + const authUserId = 'class:auth_mod.py:User'; + const userUserId = 'class:user_mod.py:User'; + const authSaveId = 'method:auth_mod.py:save'; + const userSaveId = 'method:user_mod.py:save'; + + ctx.symbols.add(authModFile, 'User', authUserId, 'Class'); + ctx.symbols.add(userModFile, 'User', userUserId, 'Class'); + ctx.symbols.add(authModFile, 'save', authSaveId, 'Method', { + ownerId: authUserId, + returnType: 'bool', + }); + ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', { + ownerId: userUserId, + returnType: 'bool', + }); + // Register the module alias: in app.py, `auth` points to auth_mod.py. + const aliasMap = new Map([['auth', authModFile]]); + ctx.moduleAliasMap.set(appFile, aliasMap); + ctx.importMap.set(appFile, new Set([authModFile])); + + const heritageMap = buildHeritageMap([], ctx); + + await processCalls( + graph, + [ + { + path: authModFile, + content: 'class User:\n def save(self):\n return True\n', + }, + { + path: userModFile, + content: 'class User:\n def save(self):\n return True\n', + }, + { + path: appFile, + content: + 'import auth_mod as auth\n\ndef run():\n user = auth.User()\n user.save()\n', + }, + ], + createASTCache(), + ctx, + undefined, + undefined, + undefined, + undefined, + undefined, + heritageMap, + ); + + // save() must resolve to auth_mod.py, NOT user_mod.py. + const authSave = graph.relationships.find( + (r) => r.type === 'CALLS' && r.targetId === authSaveId, + ); + const userSave = graph.relationships.find( + (r) => r.type === 'CALLS' && r.targetId === userSaveId, + ); + expect(authSave).toBeDefined(); + expect(userSave).toBeUndefined(); + }); +});