SM-10: Add MRO fast path before D2 fuzzy widening in resolveCallTarget (#741)
Some checks are pending
CI / quality (push) Waiting to run
CI / tests (push) Waiting to run
CI / e2e (push) Waiting to run
CI / Save PR Metadata (push) Blocked by required conditions
CI / CI Gate (push) Blocked by required conditions

* 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:
Copilot 2026-04-08 23:24:07 +01:00 committed by GitHub
parent c19e76a4a3
commit d9ba9aa998
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 648 additions and 2 deletions

View file

@ -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

View file

@ -0,0 +1,8 @@
import 'child.dart';
class App {
void run() {
final c = Child();
c.parentMethod();
}
}

View file

@ -0,0 +1,3 @@
import 'parent.dart';
class Child extends Parent {}

View file

@ -0,0 +1,5 @@
class Parent {
String parentMethod() {
return 'parent';
}
}

View file

@ -0,0 +1,3 @@
module example.com/app
go 1.21

View file

@ -0,0 +1,5 @@
package models
type Child struct {
Parent
}

View file

@ -0,0 +1,7 @@
package models
type Parent struct{}
func (p *Parent) ParentMethod() string {
return "parent"
}

View file

@ -0,0 +1,8 @@
package services
import "example.com/app/models"
func Run() {
c := &models.Child{}
c.ParentMethod()
}

View file

@ -0,0 +1,14 @@
<?php
namespace Services;
use Models\Child;
class App
{
public function run(): void
{
$c = new Child();
$c->parentMethod();
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace Models;
class Child extends ParentClass
{
}

View file

@ -0,0 +1,11 @@
<?php
namespace Models;
class ParentClass
{
public function parentMethod(): string
{
return 'parent';
}
}

View file

@ -0,0 +1,8 @@
require_relative 'child'
class App
def run
c = Child.new
c.parent_method
end
end

View file

@ -0,0 +1,4 @@
require_relative 'parent'
class Child < Parent
end

View file

@ -0,0 +1,5 @@
class Parent
def parent_method
"parent"
end
end

View file

@ -0,0 +1,6 @@
class App {
func run() {
let c = Child()
c.parentMethod()
}
}

View file

@ -0,0 +1,2 @@
class Child: Parent {
}

View file

@ -0,0 +1,5 @@
class Parent {
func parentMethod() -> String {
return "parent"
}
}

View file

@ -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');
});
},
);

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
},
);

View file

@ -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();
});
});