mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
SM-10: Add MRO fast path before D2 fuzzy widening in resolveCallTarget (#741)
* 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 <gergomagyar@icloud.com>
This commit is contained in:
parent
c19e76a4a3
commit
d9ba9aa998
23 changed files with 648 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
8
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import 'child.dart';
|
||||
|
||||
class App {
|
||||
void run() {
|
||||
final c = Child();
|
||||
c.parentMethod();
|
||||
}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import 'parent.dart';
|
||||
|
||||
class Child extends Parent {}
|
||||
5
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Parent {
|
||||
String parentMethod() {
|
||||
return 'parent';
|
||||
}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/app
|
||||
|
||||
go 1.21
|
||||
5
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models
|
||||
|
||||
type Child struct {
|
||||
Parent
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models
|
||||
|
||||
type Parent struct{}
|
||||
|
||||
func (p *Parent) ParentMethod() string {
|
||||
return "parent"
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package services
|
||||
|
||||
import "example.com/app/models"
|
||||
|
||||
func Run() {
|
||||
c := &models.Child{}
|
||||
c.ParentMethod()
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace Services;
|
||||
|
||||
use Models\Child;
|
||||
|
||||
class App
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$c = new Child();
|
||||
$c->parentMethod();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace Models;
|
||||
|
||||
class Child extends ParentClass
|
||||
{
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Parent.php
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Parent.php
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace Models;
|
||||
|
||||
class ParentClass
|
||||
{
|
||||
public function parentMethod(): string
|
||||
{
|
||||
return 'parent';
|
||||
}
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/app.rb
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/app.rb
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
require_relative 'child'
|
||||
|
||||
class App
|
||||
def run
|
||||
c = Child.new
|
||||
c.parent_method
|
||||
end
|
||||
end
|
||||
4
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/child.rb
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/child.rb
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
require_relative 'parent'
|
||||
|
||||
class Child < Parent
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/parent.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/parent.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Parent
|
||||
def parent_method
|
||||
"parent"
|
||||
end
|
||||
end
|
||||
6
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/App.swift
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/App.swift
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class App {
|
||||
func run() {
|
||||
let c = Child()
|
||||
c.parentMethod()
|
||||
}
|
||||
}
|
||||
2
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Child.swift
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Child.swift
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
class Child: Parent {
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Parent.swift
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Parent.swift
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Parent {
|
||||
func parentMethod() -> String {
|
||||
return "parent"
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<typeof createKnowledgeGraph>;
|
||||
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<string, string>([['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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue