mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
Extract resolveMemberCall from resolveCallTarget (SM-11) (#744)
* Initial plan * feat(SM-11): extract resolveMemberCall from resolveCallTarget - Create resolveMemberCall(ownerType, methodName, currentFile, ctx, heritageMap?) that uses owner-scoped + MRO resolution only (no fuzzy lookup) - resolveCallTarget delegates member calls (D0 path) to resolveMemberCall - walkMixedChain uses resolveMemberCall for owner-scoped member-call resolution - Add 7 unit tests for resolveMemberCall covering direct, inherited, MRO, null cases, and confidence tier assertions - Export resolveMemberCall for external use Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3b7889a9-5f2f-4572-8904-45084210f10d * fix(SM-11): address PR #744 review Blocking fixes: - B1: Revert unrelated package-lock.json gitnexus-shared addition - B2: Document confidence-tier semantic change on resolveMemberCall Performance / coupling fixes: - S1: walkMixedChain now calls resolveMethodByOwner directly (hot path) to avoid throwaway ResolveResult allocation per chain step - S2: Thread tier from resolveMethodByOwner via { def, tier } tuple; eliminates double ctx.resolve Alignment with semantic-model plan (Phase 3 target): - resolveMethodByOwner now iterates ALL class-like candidates from ctx.resolve, deduplicating matches by nodeId. Absorbs D4's ownerId-filtering into the owner-scoped path. - Handles homonym classes (two Users in different files) without falling through to D1-D4 fuzzy widening - Shared-ancestor MRO walks automatically dedup (both homonyms walk to same base method) - Unified direct-vs-MRO lookup under a single canWalkMRO check Tests added: - T1: Three D0 skip-condition tests via new _resolveCallTargetForTesting internal export (overloadHints, preComputedArgTypes, hasActiveModuleAlias) - T2: Rust qualified-syntax null test (trait-inherited method) + direct impl control - T3: C++ leftmost-base diamond inheritance test - B2 lock-in: cross-file class tier assertion - Homonym disambiguation: only-one-owns-method, both-own-method ambiguity, shared-ancestor MRO convergence Verification: - tsc --noEmit: clean - vitest run test/unit/: 3014 passed - vitest run test/integration/resolvers/: 1746 passed * test(SM-11): address second PR #744 review round + per-language integration tests Review fixes (https://github.com/abhigyanpatwari/GitNexus/pull/744#issuecomment-4211877593): P1 (Performance): Replace Map allocation in resolveMethodByOwner with a firstDef+ambiguous flag pattern. Zero allocation for the common single-candidate case on the hot path — the previous Map approach allocated on every member call regardless of whether deduplication was needed. P2 (Test gap): Strengthen the module-alias D0 skip test with a homonym fixture (two Users in different files). Previously the test passed whether or not D0 was actually bypassed; the new version proves D0 must be skipped by showing that resolveMemberCall directly returns null (ambiguous) but D1-D4 with alias narrowing picks the right one. Also fixes the underlying D2-vs-alias widening interaction: when filteredCandidates was narrowed by module-alias disambiguation, D2 no longer widens back to the full fuzzy pool (introduces aliasNarrowed boolean flag). L1 (Language coverage): Add C# and Kotlin implements-split tests at the resolveMemberCall layer. L2 (Maintainability): Export OverloadHints as @internal so the test can use a direct cast instead of fragile Parameters<...> type inference. Per-language integration tests: - rust-child-extends-parent: Direct impl method resolution via D0 (with honest documentation of the trait-method-as-Function gap that is Phase 5 / SM-16 scope) - java-interface-default-method: User implements Validator with default method resolved via implements-split MRO - csharp-interface-default-method: Same pattern for C# 8.0+ default interface methods - kotlin-interface-default-method: Same pattern for Kotlin interfaces with default implementations - python-multi-level-mro: 3-level C3 linearization (Grandparent ← Parent ← Child) - cpp-diamond-inheritance: Classic diamond (Base ← A, B ← Derived) via leftmost-base MRO Verification: - tsc --noEmit: clean - vitest run test/unit/: 3015 passed - vitest run test/integration/resolvers/: 1763 passed (+17 new per-language tests) * fix(SM-11): Codex adversarial review corrections + deeper D0 fixes Addresses the three high-severity findings from the Codex adversarial review of PR #744 (https://github.com/abhigyanpatwari/GitNexus/pull/744#issuecomment-4212075120), plus four deeper fixes discovered during regression triage. All discovered issues are now addressed end-to-end rather than papered over with tail-return fallbacks. Codex review findings: R1 (C++ diamond): The cpp-diamond-inheritance fixture used non-virtual inheritance, which is genuinely ambiguous in real C++ (two Base subobjects). Changed A and B to use 'virtual public Base' so there's a single shared Base subobject and d.method() is an unambiguous call that the leftmost-base MRO walk correctly resolves. R2 (C# default-interface): The csharp-interface-default-method fixture called user.Validate() via a User-typed variable, but C# does not inherit default interface methods as callable class members — the call is only valid through an interface-typed variable. Changed App.cs to 'IValidator user = new User(...)' which is the idiomatic dispatch pattern. R3 (resolveCallTarget tail-return): When D1-D4 receiver filtering produced zero file-matched and zero owner-matched candidates for a member call, the function fell through to the permissive single-candidate tail return — silently emitting CALLS edges for methods that don't belong to the receiver. Added an explicit null-route inside the D1-D4 block that fires only when both filters yielded 0. R4 (Rust negative assertion): Added the c.trait_only() negative integration test in rust.test.ts demonstrating that direct member calls on Rust structs do not walk trait ancestry. The test now passes because of R3 (previously fell through to the tail return). Regression triage discoveries: 1. D0 was dead code on the sequential pipeline. The sequential path sets overloadHints for every call regardless of whether the method is overloaded, and the original D0 skip condition '!overloadHints && !preComputedArgTypes' was therefore always false. The Java/C#/C++ SM-9/SM-10 inheritance tests were passing ONLY via the tail-return fallback. Fix: narrow the skip to 'overloadHints && filteredCandidates.length > 1' — skip D0 only when there are actually multiple candidates that need overload disambiguation. 2. lookupMethodByOwner couldn't disambiguate arity-differing overloads (e.g. C++ greet() vs greet(string)). With D0 now firing on the sequential path, same-name/different-arity overloads would collapse to an arbitrary first pick. Fix: added an optional argCount parameter to lookupMethodByOwner + lookupMethodByOwnerWithMRO that filters the overload set by parameterCount/requiredParameterCount before the returnType dedup. 3. Python and Rust class methods are captured as Function nodes (not Method) with ownerId set to the class. The methodByOwner index only accepted 'Method' and 'Constructor' types, so Python class methods and Rust trait methods were invisible to D0. Fix: extended the methodByOwner indexing condition to include 'Function' when ownerId is set. This also unlocks the Rust trait-method negative assertion by ensuring the qualified-syntax MRO strategy has something to return null for. 4. D0 was being skipped when a local variable shadowed an imported module name (Python 'from models.c import C; c = C()' creates both a module alias 'c → models/c.py' AND a typed local 'c'). Fix: the D0 skip now gates on 'aliasNarrowed' (a new boolean tracking whether the alias block actually narrowed filteredCandidates) instead of 'hasActiveModuleAlias'. If the method isn't in the aliased module, the receiver is a typed local variable and D0 should run. 5. PHP trait walk missed the HasTimestamps trait because lookupClassByName did not include 'Trait' type. buildHeritageMap uses lookupClassByName to resolve parent names, so 'BaseModel use HasTimestamps' was failing to register an ancestor edge for BaseModel → HasTimestamps. Fix: added 'Trait' to CLASS_TYPES. The trait is now a valid class-like type for heritage resolution (PHP use, Rust impl Trait for Struct, Scala traits). Test updates: - Updated the 'no heritageMap' unit test in call-processor.test.ts to assert the correct null-route behavior instead of the old tail-return fallback. - Added a new unit test asserting Trait inclusion in the class set. - Updated the 'does NOT include other type-like labels' test to remove Trait from its rejection set. Verification: - tsc --noEmit: clean - vitest run test/unit/: 3016 passed (+1 new Trait inclusion test) - vitest run test/integration/resolvers/: 1764 passed (+1 new Rust negative assertion) - Zero regressions --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergo Magyar <magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
This commit is contained in:
parent
d6debf3324
commit
bb68cc1eb0
31 changed files with 1228 additions and 72 deletions
|
|
@ -1191,9 +1191,15 @@ const toResolveResult = (definition: SymbolDefinition, tier: ResolutionTier): Re
|
|||
returnType: definition.returnType,
|
||||
});
|
||||
|
||||
/** Optional hints for overload disambiguation via argument literal types.
|
||||
* Only available on the sequential path (has AST); worker path passes undefined. */
|
||||
interface OverloadHints {
|
||||
/**
|
||||
* Optional hints for overload disambiguation via argument literal types.
|
||||
* Only available on the sequential path (has AST); worker path passes undefined.
|
||||
*
|
||||
* @internal Exported so tests can exercise the D0 skip-condition path without
|
||||
* constructing a real SyntaxNode. Do not use outside `call-processor.ts`
|
||||
* and its unit tests.
|
||||
*/
|
||||
export interface OverloadHints {
|
||||
callNode: SyntaxNode;
|
||||
inferLiteralType: LiteralTypeInferrer;
|
||||
typeEnv?: TypeEnvironment;
|
||||
|
|
@ -1279,6 +1285,31 @@ const tryOverloadDisambiguation = (
|
|||
/** Per-file cache for the widen path's lookupFuzzy calls. Cleared between files. */
|
||||
type WidenCache = Map<string, readonly SymbolDefinition[]>;
|
||||
|
||||
/** @internal Exported for unit tests of D0 skip conditions (SM-11). Do not use outside tests. */
|
||||
export const _resolveCallTargetForTesting = (
|
||||
call: Pick<
|
||||
ExtractedCall,
|
||||
'calledName' | 'argCount' | 'callForm' | 'receiverTypeName' | 'receiverName'
|
||||
>,
|
||||
currentFile: string,
|
||||
ctx: ResolutionContext,
|
||||
opts?: {
|
||||
overloadHints?: OverloadHints;
|
||||
widenCache?: WidenCache;
|
||||
preComputedArgTypes?: (string | undefined)[];
|
||||
heritageMap?: HeritageMap;
|
||||
},
|
||||
): ResolveResult | null =>
|
||||
resolveCallTarget(
|
||||
call,
|
||||
currentFile,
|
||||
ctx,
|
||||
opts?.overloadHints,
|
||||
opts?.widenCache,
|
||||
opts?.preComputedArgTypes,
|
||||
opts?.heritageMap,
|
||||
);
|
||||
|
||||
const resolveCallTarget = (
|
||||
call: Pick<
|
||||
ExtractedCall,
|
||||
|
|
@ -1328,6 +1359,10 @@ const resolveCallTarget = (
|
|||
// selects auth.py via moduleAliasMap. Runs for ALL member calls with a known module alias,
|
||||
// not just ambiguous ones — same-file tier may shadow the correct cross-module target when
|
||||
// the caller defines a function with the same name as the callee (Issue #417).
|
||||
//
|
||||
// Tracks `aliasNarrowed` so the D2 widening step below does NOT undo the alias filtering
|
||||
// by calling lookupFuzzy again (which would re-introduce homonym candidates from other files).
|
||||
let aliasNarrowed = false;
|
||||
if (call.callForm === 'member' && call.receiverName) {
|
||||
const aliasMap = ctx.moduleAliasMap?.get(currentFile);
|
||||
if (aliasMap) {
|
||||
|
|
@ -1336,6 +1371,7 @@ const resolveCallTarget = (
|
|||
const aliasFiltered = filteredCandidates.filter((c) => c.filePath === moduleFile);
|
||||
if (aliasFiltered.length > 0) {
|
||||
filteredCandidates = aliasFiltered;
|
||||
aliasNarrowed = true;
|
||||
} else {
|
||||
// Same-file tier returned a local match, but the alias points elsewhere.
|
||||
// Widen to global candidates and filter to the aliased module's file.
|
||||
|
|
@ -1350,7 +1386,10 @@ const resolveCallTarget = (
|
|||
const widened = filterCallableCandidates(fuzzyDefs, call.argCount, call.callForm).filter(
|
||||
(c) => c.filePath === moduleFile,
|
||||
);
|
||||
if (widened.length > 0) filteredCandidates = widened;
|
||||
if (widened.length > 0) {
|
||||
filteredCandidates = widened;
|
||||
aliasNarrowed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1365,33 +1404,45 @@ const resolveCallTarget = (
|
|||
// belong to the wrong class (e.g. super.save() should hit the parent's save,
|
||||
// not the child's own save method in the same file).
|
||||
if (call.callForm === 'member' && call.receiverTypeName) {
|
||||
// D0. MRO fast path: when heritageMap is available, try owner-scoped + MRO
|
||||
// lookup before falling back to the expensive D2 fuzzy widening.
|
||||
// This short-circuits the lookupFuzzy call for every cross-file member call.
|
||||
// D0. Delegate to resolveMemberCall (SM-11): owner-scoped + MRO lookup
|
||||
// before falling back to the expensive D1-D4 fuzzy widening.
|
||||
// Skip conditions:
|
||||
// (a) overloadHints or preComputedArgTypes present — the MRO lookup may
|
||||
// pick the wrong overload for same-return-type overloads since it
|
||||
// does not consider argument types. D2-D4+E handles those correctly.
|
||||
// does not consider argument types. D1-D4+E handles those correctly.
|
||||
// (b) A module alias on call.receiverName is active for this file — the
|
||||
// alias block above already narrowed `filteredCandidates` to a
|
||||
// specific file (e.g. Python `import auth; auth.user.save()`).
|
||||
// resolveMethodByOwner re-resolves `receiverTypeName` from scratch
|
||||
// via `ctx.resolve`, which ignores that narrowing and could pick a
|
||||
// homonymous class from the wrong file. Fall through to D1-D4 which
|
||||
// respects the alias-filtered candidate pool.
|
||||
const hasActiveModuleAlias =
|
||||
!!call.receiverName && ctx.moduleAliasMap?.get(currentFile)?.has(call.receiverName) === true;
|
||||
if (!overloadHints && !preComputedArgTypes && !hasActiveModuleAlias) {
|
||||
const mroResult = resolveMethodByOwner(
|
||||
// specific file. resolveMemberCall re-resolves `receiverTypeName`
|
||||
// from scratch via `ctx.resolve`, which ignores that narrowing and
|
||||
// could pick a homonymous class from the wrong file. Fall through to
|
||||
// D1-D4 which respects the alias-filtered candidate pool.
|
||||
// D0 skip for overload disambiguation: only fires when the name actually
|
||||
// has multiple candidates in the tiered pool. The sequential path sets
|
||||
// `overloadHints` for every call regardless of whether the method is
|
||||
// overloaded — skipping D0 unconditionally would make this fast path
|
||||
// dead code for the sequential pipeline. By gating on
|
||||
// `filteredCandidates.length > 1`, we preserve the original intent
|
||||
// (let D1-D4+E pick the right overload when there are multiple) while
|
||||
// allowing D0 to fire for the common single-candidate case.
|
||||
const hasOverloadConcern =
|
||||
(!!overloadHints || !!preComputedArgTypes) && filteredCandidates.length > 1;
|
||||
// D0 skip for active module alias: only fires when the alias block above
|
||||
// actually narrowed filteredCandidates. In Python, a local variable can
|
||||
// shadow an imported module name (e.g. `from models.c import C; c = C()`
|
||||
// creates both a module alias `c → models/c.py` AND a typed local `c`).
|
||||
// Checking `aliasNarrowed` rather than `ctx.moduleAliasMap.has(receiverName)`
|
||||
// ensures D0 still runs when the method isn't in the aliased module —
|
||||
// which means the receiver is a typed local variable, not a module reference.
|
||||
if (!hasOverloadConcern && !aliasNarrowed) {
|
||||
const memberResult = resolveMemberCall(
|
||||
call.receiverTypeName,
|
||||
call.calledName,
|
||||
currentFile,
|
||||
ctx,
|
||||
heritageMap,
|
||||
call.argCount,
|
||||
);
|
||||
if (mroResult) {
|
||||
return toResolveResult(mroResult, tiered.tier);
|
||||
}
|
||||
if (memberResult) return memberResult;
|
||||
}
|
||||
|
||||
// D1. Resolve the receiver type
|
||||
|
|
@ -1403,8 +1454,13 @@ const resolveCallTarget = (
|
|||
// D2. Widen candidates: same-file tier may miss the parent's method when
|
||||
// it lives in another file. Query the symbol table directly for all
|
||||
// global methods with this name, then apply arity/kind filtering.
|
||||
//
|
||||
// When the candidate set was already narrowed by module-alias
|
||||
// disambiguation, do NOT widen back to the full fuzzy pool — that
|
||||
// would undo the alias narrowing and reintroduce homonym candidates
|
||||
// from other files.
|
||||
const methodPool =
|
||||
filteredCandidates.length <= 1
|
||||
filteredCandidates.length <= 1 && !aliasNarrowed
|
||||
? filterCallableCandidates(
|
||||
ctx.symbols.lookupFuzzy(call.calledName),
|
||||
call.argCount,
|
||||
|
|
@ -1435,6 +1491,23 @@ const resolveCallTarget = (
|
|||
if (disambiguated) return toResolveResult(disambiguated, tiered.tier);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Zero-match null-route: we committed to receiver narrowing (D1 succeeded)
|
||||
// but both file-based (D3) and owner-based (D4) filters produced zero
|
||||
// matches. The lone candidate in `filteredCandidates` does not belong to
|
||||
// this receiver type — refuse to emit a CALLS edge rather than fall
|
||||
// through to the permissive single-candidate tail return.
|
||||
//
|
||||
// Addresses Codex review finding R3 (PR #744): member calls where
|
||||
// fuzzy fallback picked a globally-matching symbol that has no
|
||||
// relationship to the receiver's class hierarchy were silently
|
||||
// producing false-positive edges. Example: Rust `c.trait_only()` where
|
||||
// `trait_only` is captured as a Function node with no ownerId — it
|
||||
// matches the name but fails both file and owner narrowing, so the
|
||||
// old tail return would pick it incorrectly.
|
||||
if (fileFiltered.length === 0 && ownerFiltered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1630,9 +1703,33 @@ const resolveFieldOwnership = (
|
|||
|
||||
/**
|
||||
* Resolve a method by owner type name using the eagerly-populated methodByOwner index.
|
||||
* Returns the SymbolDefinition if an unambiguous method is found, undefined otherwise.
|
||||
* Falls through to undefined for: unknown type, no class-like candidates, ambiguous overloads.
|
||||
* When heritageMap is provided, falls back to MRO-aware parent chain walking.
|
||||
* Returns `{ def, tier }` when an unambiguous method is found, `undefined` otherwise.
|
||||
*
|
||||
* **Multi-candidate iteration (homonym disambiguation):** when `ctx.resolve(ownerType)`
|
||||
* returns multiple class-like candidates (e.g. two classes named `User` in different
|
||||
* files reachable from the call site), each is probed with `lookupMethodByOwnerWithMRO`.
|
||||
* Results are deduplicated by `nodeId` so that:
|
||||
*
|
||||
* - homonym classes that both walk up to the SAME ancestor's method collapse to 1 hit
|
||||
* - aliased re-exports that produce two candidates pointing at the same def collapse too
|
||||
*
|
||||
* After deduplication:
|
||||
*
|
||||
* - 0 unique matches → `undefined` (owner-scoped path has no answer; D1-D4 fuzzy
|
||||
* fallback in `resolveCallTarget` may still find something via lookupFuzzy)
|
||||
* - 1 unique match → return it
|
||||
* - ≥2 unique matches → `undefined` (genuine homonym ambiguity; don't silently pick one)
|
||||
*
|
||||
* This absorbs what was previously D4's job inside `resolveCallTarget` — "filter fuzzy
|
||||
* candidates to those whose ownerId is in the receiver type's nodeId set" — into the
|
||||
* owner-scoped path, aligning with the plan's target:
|
||||
*
|
||||
* `resolveCallTarget` D2 widening → `model.lookupMethodWithMRO(ownerNodeId, name)`
|
||||
*
|
||||
* The returned `tier` reflects how the owner TYPE was resolved (not the method name).
|
||||
* Threaded out here so callers don't need a second `ctx.resolve(ownerType, ...)` call —
|
||||
* this decouples callers from `ctx.resolve`'s per-file caching contract, which SM-16
|
||||
* will restructure when it replaces the `lookupFuzzy` data source.
|
||||
*/
|
||||
const resolveMethodByOwner = (
|
||||
receiverTypeName: string,
|
||||
|
|
@ -1640,35 +1737,110 @@ const resolveMethodByOwner = (
|
|||
filePath: string,
|
||||
ctx: ResolutionContext,
|
||||
heritageMap?: HeritageMap,
|
||||
): SymbolDefinition | undefined => {
|
||||
argCount?: number,
|
||||
): { def: SymbolDefinition; tier: ResolutionTier } | undefined => {
|
||||
const typeResolved = ctx.resolve(receiverTypeName, filePath);
|
||||
if (!typeResolved) return undefined;
|
||||
const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type));
|
||||
if (!classDef) return undefined;
|
||||
|
||||
// When HeritageMap is available, delegate to MRO-aware lookup which performs
|
||||
// the direct owner lookup itself before walking ancestors — avoids a double
|
||||
// direct lookup on the hot path.
|
||||
if (heritageMap) {
|
||||
const language = getLanguageFromFilename(filePath);
|
||||
if (language) {
|
||||
return lookupMethodByOwnerWithMRO(
|
||||
classDef.nodeId,
|
||||
methodName,
|
||||
heritageMap,
|
||||
ctx.symbols,
|
||||
language,
|
||||
);
|
||||
// MRO walking needs a language hint; compute once and reuse for every candidate.
|
||||
// Unknown extension → fall back to plain direct lookup (D1-D4 still runs on miss).
|
||||
const language = heritageMap ? getLanguageFromFilename(filePath) : null;
|
||||
const canWalkMRO = heritageMap != null && language != null;
|
||||
|
||||
// Iterate all class-like candidates tracking the first unambiguous hit.
|
||||
// Zero-allocation fast path: the common case is exactly one class candidate,
|
||||
// so we avoid building a Map. A second hit with a different `nodeId` flips
|
||||
// `ambiguous` and short-circuits the loop. Diamond MRO convergence on the
|
||||
// same inherited method collapses to one hit because `nodeId` matches.
|
||||
//
|
||||
// firstDef === undefined → owner-scoped resolution found nothing
|
||||
// firstDef && !ambiguous → unambiguous answer
|
||||
// ambiguous → genuine homonym ambiguity — refuse to pick
|
||||
//
|
||||
// argCount is threaded through so arity-differing overloads
|
||||
// (e.g. C++ `greet()` vs `greet(string)`) are disambiguated inside the
|
||||
// owner-scoped lookup rather than collapsing to an arbitrary first pick.
|
||||
let firstDef: SymbolDefinition | undefined;
|
||||
let ambiguous = false;
|
||||
for (const candidate of typeResolved.candidates) {
|
||||
if (!CLASS_LIKE_TYPES.has(candidate.type)) continue;
|
||||
const def = canWalkMRO
|
||||
? lookupMethodByOwnerWithMRO(
|
||||
candidate.nodeId,
|
||||
methodName,
|
||||
heritageMap,
|
||||
ctx.symbols,
|
||||
language,
|
||||
argCount,
|
||||
)
|
||||
: ctx.symbols.lookupMethodByOwner(candidate.nodeId, methodName, argCount);
|
||||
if (!def) continue;
|
||||
if (!firstDef) {
|
||||
firstDef = def;
|
||||
} else if (def.nodeId !== firstDef.nodeId) {
|
||||
ambiguous = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (!firstDef || ambiguous) return undefined;
|
||||
return { def: firstDef, tier: typeResolved.tier };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SM-11: Owner-scoped + MRO member-call resolution (no fuzzy lookup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a member call using owner-scoped + MRO resolution only (no fuzzy lookup).
|
||||
* Used for `obj.method()` calls where the receiver type is known.
|
||||
*
|
||||
* Delegates to {@link resolveMethodByOwner} which performs an O(1) owner-scoped
|
||||
* method lookup and, when a {@link HeritageMap} is provided, walks the MRO chain
|
||||
* via {@link lookupMethodByOwnerWithMRO}.
|
||||
*
|
||||
* {@link resolveCallTarget} delegates here for member calls before falling back
|
||||
* to the more expensive fuzzy-widening path (D1-D4).
|
||||
*
|
||||
* **SEMANTIC CHANGE (2026-04-09):** The confidence tier now reflects how the
|
||||
* owner TYPE was resolved, not how the method NAME was resolved globally. The
|
||||
* previous D0 fast path in `resolveCallTarget` used `tiered.tier` from
|
||||
* `ctx.resolve(calledName, ...)` — a name-based tier that matched what D1-D4
|
||||
* fuzzy widening would produce. The new tier is owner-type-based, which is
|
||||
* more accurate for owner-scoped resolution (the discriminant IS the class,
|
||||
* not the method name). Downstream consumers that filter CALLS edges by
|
||||
* confidence threshold may see shifted values on otherwise-unchanged code.
|
||||
* See the "returns result with correct confidence tier" tests below for the
|
||||
* locked-in behavior.
|
||||
*
|
||||
* **Performance:** Callers that only need the return type (e.g. `walkMixedChain`)
|
||||
* should call {@link resolveMethodByOwner} directly and use the `.def.returnType`
|
||||
* field instead, to avoid building a throwaway `ResolveResult`.
|
||||
*
|
||||
* @param ownerType - The receiver's type name (e.g. 'User')
|
||||
* @param methodName - The method being called (e.g. 'save')
|
||||
* @param currentFile - File path of the call site
|
||||
* @param ctx - Resolution context
|
||||
* @param heritageMap - Optional heritage map for MRO-aware ancestor walking
|
||||
*/
|
||||
export const resolveMemberCall = (
|
||||
ownerType: string,
|
||||
methodName: string,
|
||||
currentFile: string,
|
||||
ctx: ResolutionContext,
|
||||
heritageMap?: HeritageMap,
|
||||
argCount?: number,
|
||||
): ResolveResult | null => {
|
||||
const resolved = resolveMethodByOwner(
|
||||
ownerType,
|
||||
methodName,
|
||||
currentFile,
|
||||
ctx,
|
||||
heritageMap,
|
||||
argCount,
|
||||
);
|
||||
if (!resolved) return null;
|
||||
return toResolveResult(resolved.def, resolved.tier);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1761,9 +1933,12 @@ export const lookupMethodByOwnerWithMRO = (
|
|||
heritageMap: HeritageMap,
|
||||
symbols: SymbolTable,
|
||||
language: SupportedLanguages,
|
||||
argCount?: number,
|
||||
): SymbolDefinition | undefined => {
|
||||
// Direct lookup first (child override — no walk needed)
|
||||
const direct = symbols.lookupMethodByOwner(ownerNodeId, methodName);
|
||||
// Direct lookup first (child override — no walk needed).
|
||||
// argCount is threaded through so arity-differing overloads on the direct
|
||||
// owner can be disambiguated before the MRO walk starts.
|
||||
const direct = symbols.lookupMethodByOwner(ownerNodeId, methodName, argCount);
|
||||
if (direct) return direct;
|
||||
|
||||
const strategy = getProvider(language).mroStrategy;
|
||||
|
|
@ -1790,9 +1965,10 @@ export const lookupMethodByOwnerWithMRO = (
|
|||
ancestors = heritageMap.getAncestors(ownerNodeId);
|
||||
}
|
||||
|
||||
// Walk ancestors in MRO order — first match wins
|
||||
// Walk ancestors in MRO order — first match wins.
|
||||
// argCount narrows overloaded ancestors the same way as the direct lookup.
|
||||
for (const ancestorId of ancestors) {
|
||||
const method = symbols.lookupMethodByOwner(ancestorId, methodName);
|
||||
const method = symbols.lookupMethodByOwner(ancestorId, methodName, argCount);
|
||||
if (method) return method;
|
||||
}
|
||||
|
||||
|
|
@ -1861,12 +2037,16 @@ const walkMixedChain = (
|
|||
continue;
|
||||
}
|
||||
// Fast path: O(1) owner-scoped method lookup via methodByOwner index.
|
||||
// Avoids fuzzy lookup when the owner type is known and the method is unambiguous.
|
||||
// Note: CALLS edges for intermediate chain steps are NOT emitted here — walkMixedChain
|
||||
// only threads types. CALLS edges come from the outer per-call-expression loop in processCalls.
|
||||
const methodDef = resolveMethodByOwner(currentType, step.name, filePath, ctx, heritageMap);
|
||||
if (methodDef?.returnType) {
|
||||
const fastRetType = extractReturnTypeName(methodDef.returnType);
|
||||
//
|
||||
// We call `resolveMethodByOwner` directly (NOT `resolveMemberCall`) because this is
|
||||
// a hot path — called per chain step per call expression — and we only need the
|
||||
// return type string. Going through `resolveMemberCall` would allocate a throwaway
|
||||
// `ResolveResult` with confidence/reason that we immediately discard.
|
||||
const owned = resolveMethodByOwner(currentType, step.name, filePath, ctx, heritageMap);
|
||||
if (owned?.def.returnType) {
|
||||
const fastRetType = extractReturnTypeName(owned.def.returnType);
|
||||
if (fastRetType) {
|
||||
currentType = fastRetType;
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
|
||||
export const CLASS_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record']);
|
||||
export const CLASS_TYPES = new Set([
|
||||
'Class',
|
||||
'Struct',
|
||||
'Interface',
|
||||
'Enum',
|
||||
'Record',
|
||||
// Traits are class-like for heritage resolution: PHP `use Trait;`, Rust
|
||||
// `impl Trait for Struct`, and Scala traits all contribute methods to the
|
||||
// hierarchy of their using/implementing type. Including Trait here lets
|
||||
// buildHeritageMap resolve `h.parentName` to a Trait nodeId so the MRO
|
||||
// walker can visit the trait and find its methods.
|
||||
'Trait',
|
||||
]);
|
||||
|
||||
export interface SymbolDefinition {
|
||||
nodeId: string;
|
||||
|
|
@ -93,7 +105,24 @@ export interface SymbolTable {
|
|||
* overloads share the same returnType, undefined when return types differ (ambiguous).
|
||||
* Used by walkMixedChain for deterministic cross-class chain resolution.
|
||||
*/
|
||||
lookupMethodByOwner: (ownerNodeId: string, methodName: string) => SymbolDefinition | undefined;
|
||||
/**
|
||||
* Lookup a method by owner class + name, optionally filtered by arity.
|
||||
*
|
||||
* When `argCount` is provided, overloads whose parameter count doesn't
|
||||
* accommodate the call's argument count are filtered out before the
|
||||
* returnType dedup runs. This lets D0 (`resolveMemberCall`) disambiguate
|
||||
* arity-differing overloads (e.g. C++ `greet()` vs `greet(string)`) that
|
||||
* would otherwise collide on the shared `ownerId + methodName` key.
|
||||
*
|
||||
* Same-arity, same-returnType overloads (e.g. `save(int)` vs `save(String)`,
|
||||
* both returning `void`) still collapse to the first match — callers must
|
||||
* gate D0 on overload concern before invoking this function for that case.
|
||||
*/
|
||||
lookupMethodByOwner: (
|
||||
ownerNodeId: string,
|
||||
methodName: string,
|
||||
argCount?: number,
|
||||
) => SymbolDefinition | undefined;
|
||||
|
||||
/**
|
||||
* Look up class-like definitions (Class, Struct, Interface, Enum, Record) by name.
|
||||
|
|
@ -225,9 +254,16 @@ export const createSymbolTable = (): SymbolTable => {
|
|||
}
|
||||
globalIndex.get(name)!.push(def);
|
||||
|
||||
// C2. Methods and constructors with ownerId go to methodByOwner index
|
||||
// (in addition to globalIndex).
|
||||
if ((type === 'Method' || type === 'Constructor') && metadata?.ownerId) {
|
||||
// C2. Methods, constructors, and ownerId-bound Functions go to
|
||||
// methodByOwner index (in addition to globalIndex).
|
||||
//
|
||||
// Some language extractors emit class methods as `Function` with an
|
||||
// `ownerId` — notably Python (`def method(self):` inside a class body),
|
||||
// Rust trait methods, and Kotlin object/companion methods. Treating
|
||||
// `Function` with ownerId the same as `Method` here makes D0
|
||||
// (`resolveMemberCall`) work uniformly across all supported languages
|
||||
// instead of silently falling through to D1-D4 fuzzy widening.
|
||||
if ((type === 'Method' || type === 'Constructor' || type === 'Function') && metadata?.ownerId) {
|
||||
const key = `${metadata.ownerId}\0${name}`;
|
||||
const existing = methodByOwner.get(key);
|
||||
if (existing) {
|
||||
|
|
@ -303,18 +339,42 @@ export const createSymbolTable = (): SymbolTable => {
|
|||
const lookupMethodByOwner = (
|
||||
ownerNodeId: string,
|
||||
methodName: string,
|
||||
argCount?: number,
|
||||
): SymbolDefinition | undefined => {
|
||||
const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`);
|
||||
if (!defs || defs.length === 0) return undefined;
|
||||
if (defs.length === 1) return defs[0];
|
||||
// Multiple overloads: return first if all share the same defined returnType (safe for chain resolution).
|
||||
// Return undefined if return types differ or are absent (truly ambiguous — can't determine which overload).
|
||||
const firstReturnType = defs[0].returnType;
|
||||
if (firstReturnType === undefined) return undefined;
|
||||
for (let i = 1; i < defs.length; i++) {
|
||||
if (defs[i].returnType !== firstReturnType) return undefined;
|
||||
|
||||
// Arity narrowing: when an argCount is provided and there are multiple
|
||||
// overloads, keep only those whose parameterCount can accommodate the
|
||||
// call. This resolves arity-differing overloads (e.g. C++ `greet()` vs
|
||||
// `greet(string)`) that share the same `ownerId + methodName` key.
|
||||
//
|
||||
// Candidates with `parameterCount === undefined` (extractor didn't
|
||||
// populate the count — typically variadic or unknown) are retained
|
||||
// conservatively so that legitimate variadic matches still resolve.
|
||||
let pool = defs;
|
||||
if (argCount !== undefined && defs.length > 1) {
|
||||
const arityMatched = defs.filter((d) => {
|
||||
if (d.parameterCount === undefined) return true;
|
||||
const min = d.requiredParameterCount ?? d.parameterCount;
|
||||
return argCount >= min && argCount <= d.parameterCount;
|
||||
});
|
||||
// Only adopt the arity-narrowed pool when it found matches; if arity
|
||||
// rules out every candidate, fall back to the unfiltered set so the
|
||||
// caller's fuzzy path still has something to work with.
|
||||
if (arityMatched.length > 0) pool = arityMatched;
|
||||
}
|
||||
return defs[0];
|
||||
|
||||
if (pool.length === 1) return pool[0];
|
||||
// Multiple overloads after arity narrowing: return first if all share
|
||||
// the same defined returnType (safe for chain resolution), undefined if
|
||||
// return types differ (truly ambiguous — can't determine which overload).
|
||||
const firstReturnType = pool[0].returnType;
|
||||
if (firstReturnType === undefined) return undefined;
|
||||
for (let i = 1; i < pool.length; i++) {
|
||||
if (pool[i].returnType !== firstReturnType) return undefined;
|
||||
}
|
||||
return pool[0];
|
||||
};
|
||||
|
||||
const lookupClassByName = (name: string): SymbolDefinition[] => {
|
||||
|
|
|
|||
10
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/A.h
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/A.h
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#pragma once
|
||||
#include "Base.h"
|
||||
|
||||
// Virtual inheritance: together with `B : virtual public Base`, this creates
|
||||
// a single shared `Base` subobject under `Derived`, so `d.method()` is an
|
||||
// unambiguous call in real C++. Without the `virtual` keyword, a non-virtual
|
||||
// diamond would produce two separate `Base` subobjects and the call would
|
||||
// be ambiguous, requiring `d.A::method()` or `d.B::method()` to disambiguate.
|
||||
class A : virtual public Base {
|
||||
};
|
||||
7
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/B.h
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/B.h
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#pragma once
|
||||
#include "Base.h"
|
||||
|
||||
// See the comment in A.h — both sides of the diamond use virtual inheritance
|
||||
// so there is exactly one `Base` subobject under `Derived`.
|
||||
class B : virtual public Base {
|
||||
};
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Base.h
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Base.h
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
class Base {
|
||||
public:
|
||||
int method() { return 42; }
|
||||
};
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Derived.h
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Derived.h
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
#include "A.h"
|
||||
#include "B.h"
|
||||
|
||||
class Derived : public A, public B {
|
||||
};
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/app.cpp
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/app.cpp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "Derived.h"
|
||||
|
||||
void run() {
|
||||
Derived d;
|
||||
d.method();
|
||||
}
|
||||
15
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/App.cs
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/App.cs
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace InterfaceDefault;
|
||||
|
||||
public class App
|
||||
{
|
||||
public static void Run()
|
||||
{
|
||||
// Default interface methods in C# 8.0+ are reachable ONLY through
|
||||
// the interface type, not as inherited class members. Declaring the
|
||||
// variable as IValidator is the idiomatic way to invoke Validate().
|
||||
// `User user = new User(...); user.Validate();` would be a compile
|
||||
// error because User does not expose Validate as a class member.
|
||||
IValidator user = new User("alice");
|
||||
user.Validate();
|
||||
}
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/User.cs
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/User.cs
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace InterfaceDefault;
|
||||
|
||||
public class User : IValidator
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
public User(string name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/Validator.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/Validator.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace InterfaceDefault;
|
||||
|
||||
public interface IValidator
|
||||
{
|
||||
bool Validate() => true;
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/App.java
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/App.java
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
public class App {
|
||||
public static void run() {
|
||||
User user = new User("alice");
|
||||
user.validate();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/User.java
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/User.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
public class User implements Validator {
|
||||
private String name;
|
||||
|
||||
public User(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/Validator.java
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/java-interface-default-method/Validator.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
public interface Validator {
|
||||
default boolean validate() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/App.kt
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/App.kt
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package example
|
||||
|
||||
fun run() {
|
||||
val user = User("alice")
|
||||
user.validate()
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/User.kt
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/User.kt
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package example
|
||||
|
||||
class User(val name: String) : Validator
|
||||
5
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/Validator.kt
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/Validator.kt
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package example
|
||||
|
||||
interface Validator {
|
||||
fun validate(): Boolean = true
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/app.py
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/app.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from child import Child
|
||||
|
||||
|
||||
def run() -> None:
|
||||
c = Child()
|
||||
c.gp_method()
|
||||
5
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/child.py
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/child.py
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from parent import Parent
|
||||
|
||||
|
||||
class Child(Parent):
|
||||
pass
|
||||
3
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/grandparent.py
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/grandparent.py
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class Grandparent:
|
||||
def gp_method(self) -> str:
|
||||
return "grandparent"
|
||||
5
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/parent.py
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/parent.py
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from grandparent import Grandparent
|
||||
|
||||
|
||||
class Parent(Grandparent):
|
||||
pass
|
||||
16
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/child.rs
vendored
Normal file
16
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/child.rs
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
use crate::parent::Parent;
|
||||
|
||||
pub struct Child;
|
||||
|
||||
impl Child {
|
||||
// Direct impl method — MUST resolve via resolveMemberCall owner-scoped path.
|
||||
pub fn own_method(&self) -> &str {
|
||||
"child-own"
|
||||
}
|
||||
}
|
||||
|
||||
// Trait implementation — `trait_only` is provided by the trait's default impl
|
||||
// but is NOT reachable via direct `obj.trait_only()` in Rust without the trait
|
||||
// being in scope. The resolver correctly treats qualified-syntax MRO as opaque
|
||||
// to direct member calls.
|
||||
impl Parent for Child {}
|
||||
17
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/main.rs
vendored
Normal file
17
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/main.rs
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
mod child;
|
||||
mod parent;
|
||||
|
||||
use crate::child::Child;
|
||||
|
||||
fn run() {
|
||||
let c = Child;
|
||||
// Direct impl method — SHOULD resolve to Child::own_method.
|
||||
c.own_method();
|
||||
// Trait-inherited default — direct member-call SHOULD NOT resolve to
|
||||
// Parent::trait_only under Rust's qualified-syntax MRO strategy.
|
||||
c.trait_only();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
run();
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/parent.rs
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/parent.rs
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Trait "parent" — methods on a Rust trait are NOT reachable via direct
|
||||
// `obj.method()` syntax on structs that implement the trait unless the trait
|
||||
// itself is in scope. Our qualified-syntax MRO strategy reflects this: direct
|
||||
// member calls do NOT walk trait ancestry, so `c.trait_only()` below should
|
||||
// produce NO CALLS edge to `Parent::trait_only`.
|
||||
|
||||
pub trait Parent {
|
||||
fn trait_only(&self) -> &str {
|
||||
"parent-default"
|
||||
}
|
||||
}
|
||||
|
|
@ -1548,3 +1548,37 @@ describe('C++ Child extends Parent — inherited method resolution (SM-9)', () =
|
|||
expect(parentMethodCall!.source).toBe('run');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C++ Derived : A, B — diamond inheritance via leftmost-base MRO (SM-11)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-diamond-inheritance'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('detects Base, A, B, and Derived classes', () => {
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('Base');
|
||||
expect(classes).toContain('A');
|
||||
expect(classes).toContain('B');
|
||||
expect(classes).toContain('Derived');
|
||||
});
|
||||
|
||||
it('emits EXTENDS edges for both branches: A → Base, B → Base, Derived → A, Derived → B', () => {
|
||||
const extends_ = getRelationships(result, 'EXTENDS');
|
||||
const edges = edgeSet(extends_);
|
||||
expect(edges).toContain('A → Base');
|
||||
expect(edges).toContain('B → Base');
|
||||
expect(edges).toContain('Derived → A');
|
||||
expect(edges).toContain('Derived → B');
|
||||
});
|
||||
|
||||
it('resolves d.method() to Base::method via leftmost-base MRO walk', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const methodCall = calls.find(
|
||||
(c) => c.target === 'method' && c.targetFilePath.includes('Base.h'),
|
||||
);
|
||||
expect(methodCall).toBeDefined();
|
||||
expect(methodCall!.source).toBe('run');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1963,3 +1963,37 @@ describe('C# Child extends Parent — inherited method resolution (SM-9)', () =>
|
|||
expect(parentMethodCall!.source).toBe('Run');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SM-11: C# User : IValidator — interface default method via implements-split
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C# User implements IValidator — interface default method (SM-11)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'csharp-interface-default-method'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects IValidator interface and User class', () => {
|
||||
expect(getNodesByLabel(result, 'Interface')).toContain('IValidator');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
});
|
||||
|
||||
it('emits IMPLEMENTS edge: User → IValidator', () => {
|
||||
const impls = getRelationships(result, 'IMPLEMENTS');
|
||||
expect(edgeSet(impls)).toContain('User → IValidator');
|
||||
});
|
||||
|
||||
it('resolves user.Validate() to IValidator.Validate via implements-split MRO', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const validateCall = calls.find(
|
||||
(c) => c.target === 'Validate' && c.targetFilePath.includes('Validator.cs'),
|
||||
);
|
||||
expect(validateCall).toBeDefined();
|
||||
expect(validateCall!.source).toBe('Run');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2133,3 +2133,37 @@ describe('Java Child extends Parent — inherited method resolution (SM-9)', ()
|
|||
expect(parentMethodCall!.source).toBe('run');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SM-11: Java User implements Validator — interface default method (Java 8+)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Java User implements Validator — interface default method (SM-11)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'java-interface-default-method'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects Validator interface and User class', () => {
|
||||
expect(getNodesByLabel(result, 'Interface')).toContain('Validator');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
});
|
||||
|
||||
it('emits IMPLEMENTS edge: User → Validator', () => {
|
||||
const impls = getRelationships(result, 'IMPLEMENTS');
|
||||
expect(edgeSet(impls)).toContain('User → Validator');
|
||||
});
|
||||
|
||||
it('resolves user.validate() to Validator.validate via implements-split MRO', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const validateCall = calls.find(
|
||||
(c) => c.target === 'validate' && c.targetFilePath.includes('Validator.java'),
|
||||
);
|
||||
expect(validateCall).toBeDefined();
|
||||
expect(validateCall!.source).toBe('run');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2026,3 +2026,37 @@ describe('Kotlin Child extends Parent — inherited method resolution (SM-9)', (
|
|||
expect(parentMethodCall!.source).toBe('run');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SM-11: Kotlin User : Validator — interface default method via implements-split
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Kotlin User implements Validator — interface default method (SM-11)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'kotlin-interface-default-method'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects Validator interface and User class', () => {
|
||||
expect(getNodesByLabel(result, 'Interface')).toContain('Validator');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
});
|
||||
|
||||
it('emits IMPLEMENTS edge: User → Validator', () => {
|
||||
const impls = getRelationships(result, 'IMPLEMENTS');
|
||||
expect(edgeSet(impls)).toContain('User → Validator');
|
||||
});
|
||||
|
||||
it('resolves user.validate() to Validator.validate via implements-split MRO', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const validateCall = calls.find(
|
||||
(c) => c.target === 'validate' && c.targetFilePath.includes('Validator.kt'),
|
||||
);
|
||||
expect(validateCall).toBeDefined();
|
||||
expect(validateCall!.source).toBe('run');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2146,3 +2146,33 @@ describe('Python Child extends Parent — inherited method resolution (SM-9)', (
|
|||
expect(parentMethodCall!.source).toBe('run');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Python Grandchild→Child→Parent — 3-level C3 MRO walk (SM-11)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-multi-level-mro'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('detects Grandparent, Parent, and Child classes', () => {
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('Grandparent');
|
||||
expect(classes).toContain('Parent');
|
||||
expect(classes).toContain('Child');
|
||||
});
|
||||
|
||||
it('emits EXTENDS chain: Child → Parent, Parent → Grandparent', () => {
|
||||
const extends_ = getRelationships(result, 'EXTENDS');
|
||||
expect(edgeSet(extends_)).toContain('Child → Parent');
|
||||
expect(edgeSet(extends_)).toContain('Parent → Grandparent');
|
||||
});
|
||||
|
||||
it('resolves c.gp_method() to Grandparent.gp_method via 3-level C3 walk', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const gpCall = calls.find(
|
||||
(c) => c.target === 'gp_method' && c.targetFilePath.includes('grandparent.py'),
|
||||
);
|
||||
expect(gpCall).toBeDefined();
|
||||
expect(gpCall!.source).toBe('run');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1857,3 +1857,66 @@ describe('Rust abstract dispatch (Repository trait)', () => {
|
|||
expect(names).toEqual(['find', 'save']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SM-11: Rust Child extends Parent — qualified-syntax MRO
|
||||
//
|
||||
// Companion integration test for the unit-level Rust qualified-syntax tests
|
||||
// in symbol-table.test.ts. Validates end-to-end that:
|
||||
//
|
||||
// 1. Direct `impl` methods on a struct resolve through the D0 owner-scoped
|
||||
// path (`resolveMemberCall`) — the positive control.
|
||||
//
|
||||
// 2. Trait-inherited default methods are NOT reachable via direct
|
||||
// `obj.trait_method()` syntax. Rust requires the trait to be in scope
|
||||
// and uses qualified syntax for trait dispatch; the resolver correctly
|
||||
// treats direct member calls as opaque to trait ancestry.
|
||||
//
|
||||
// Previously this case emitted a false-positive CALLS edge via the
|
||||
// permissive tail-return in resolveCallTarget — Codex review finding
|
||||
// R3 (PR #744). The tail-return is now null-routed when D1-D4 receiver
|
||||
// filtering produces zero matches on both file and owner dimensions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Rust Child extends Parent — qualified-syntax MRO (SM-11)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-child-extends-parent'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('detects Child struct and Parent trait', () => {
|
||||
const structs = getNodesByLabel(result, 'Struct');
|
||||
expect(structs).toContain('Child');
|
||||
const traits = getNodesByLabel(result, 'Trait');
|
||||
expect(traits).toContain('Parent');
|
||||
});
|
||||
|
||||
it('resolves c.own_method() to Child::own_method via D0 owner-scoped path', () => {
|
||||
// Direct impl method — D0 short-circuits to lookupMethodByOwner which
|
||||
// returns Child::own_method without falling through to D1-D4 fuzzy.
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const ownCall = calls.find(
|
||||
(c) =>
|
||||
c.target === 'own_method' && c.source === 'run' && c.targetFilePath.includes('child.rs'),
|
||||
);
|
||||
expect(ownCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve c.trait_only() to Parent::trait_only via direct member call', () => {
|
||||
// Qualified-syntax MRO: direct member calls on structs do not walk trait
|
||||
// ancestry. `c.trait_only()` must null-route because `trait_only` is
|
||||
// defined on the trait, not on the Child struct.
|
||||
//
|
||||
// The resolveCallTarget tail-return tightening (R3) is what makes this
|
||||
// assertion testable: before the fix, resolveCallTarget would fall
|
||||
// through D1-D4 (zero file matches, zero owner matches) and silently
|
||||
// pick the single fuzzy candidate as a false-positive edge.
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const traitCall = calls.find(
|
||||
(c) =>
|
||||
c.target === 'trait_only' && c.source === 'run' && c.targetFilePath.includes('parent.rs'),
|
||||
);
|
||||
expect(traitCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1716,7 +1716,24 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
|
|||
expect(doWorkCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('D0 skipped: same scenario still resolves via D1-D4 when heritageMap is undefined', async () => {
|
||||
it('no heritageMap: inherited methods are unresolvable (null-routed, not false-positive)', async () => {
|
||||
// Without a HeritageMap, the resolver cannot know that Parent.parentMethod
|
||||
// belongs to Child's ancestry. The old D1-D4 tail-return would silently
|
||||
// pick the lone fuzzy candidate and emit a CALLS edge — but that was an
|
||||
// accidental match that happened to line up because `parentMethod`
|
||||
// was unique in the global index.
|
||||
//
|
||||
// After the R3 tail-return tightening (PR #744 Codex review), member
|
||||
// calls whose D1-D4 narrowing produces zero file-matched and zero
|
||||
// owner-matched candidates null-route instead of falling through.
|
||||
// The test now asserts the honest answer: without heritage information,
|
||||
// we cannot attribute `c.parentMethod()` to `Parent` and therefore
|
||||
// emit no edge.
|
||||
//
|
||||
// In the real ingestion pipeline, heritageMap is always threaded
|
||||
// through, so this scenario is only reachable in tests that explicitly
|
||||
// omit it. Keeping the test confirms the null-route behavior and
|
||||
// documents the invariant "no heritage → no inherited-method edges".
|
||||
const { parentMethodId, appFile, parentFile, childFile } = setupChildParent();
|
||||
|
||||
await processCalls(
|
||||
|
|
@ -1739,13 +1756,14 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
|
|||
],
|
||||
createASTCache(),
|
||||
ctx,
|
||||
// no heritageMap — D0 fast path must be skipped, D1-D4 must still resolve
|
||||
// no heritageMap — D0 MRO walk is unavailable, D1-D4 receiver filtering
|
||||
// also cannot link c.parentMethod() to Parent, so no edge is emitted.
|
||||
);
|
||||
|
||||
const parentMethodCalls = graph.relationships.filter(
|
||||
(r) => r.type === 'CALLS' && r.targetId === parentMethodId,
|
||||
);
|
||||
expect(parentMethodCalls).toHaveLength(1);
|
||||
expect(parentMethodCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('overloadHints guard: D0 skipped so literal-inferred overload disambiguation picks the right overload', async () => {
|
||||
|
|
|
|||
|
|
@ -798,8 +798,19 @@ describe('SymbolTable', () => {
|
|||
expect(table.lookupClassByName('Qux')).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes Trait in the class set (PHP use, Rust impl, Scala traits)', () => {
|
||||
// Traits are class-like for heritage resolution — they contribute
|
||||
// methods to the using/implementing type's hierarchy. buildHeritageMap
|
||||
// relies on this to resolve `use Trait;` edges in PHP, `impl Trait for
|
||||
// Struct` in Rust, etc. Added as part of PR #744 (SM-11 Codex review
|
||||
// fixes) after the PHP HasTimestamps trait walk gap was discovered.
|
||||
table.add('src/a.rs', 'Writer', 'trait:Writer', 'Trait');
|
||||
const results = table.lookupClassByName('Writer');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].nodeId).toBe('trait:Writer');
|
||||
});
|
||||
|
||||
it('does NOT include other type-like labels outside the allowed class set', () => {
|
||||
table.add('src/a.rs', 'User', 'trait:User', 'Trait');
|
||||
table.add('src/a.ts', 'User', 'type:User', 'Type');
|
||||
expect(table.lookupClassByName('User')).toEqual([]);
|
||||
});
|
||||
|
|
@ -1398,3 +1409,499 @@ describe('lookupMethodByOwnerWithMRO', () => {
|
|||
expect(result!.nodeId).toBe('method:User:getName');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveMemberCall — SM-11: owner-scoped + MRO member-call resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
_resolveCallTargetForTesting,
|
||||
resolveMemberCall,
|
||||
type OverloadHints,
|
||||
} from '../../src/core/ingestion/call-processor.js';
|
||||
|
||||
describe('resolveMemberCall', () => {
|
||||
let ctx: ResolutionContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createResolutionContext();
|
||||
});
|
||||
|
||||
it('resolves direct method on owner type', () => {
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
|
||||
returnType: 'void',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('method:User:save');
|
||||
expect(result!.returnType).toBe('void');
|
||||
expect(result!.confidence).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('resolves inherited method via MRO walk', () => {
|
||||
ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class');
|
||||
ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class');
|
||||
ctx.symbols.add('src/parent.java', 'validate', 'method:Parent:validate', 'Method', {
|
||||
returnType: 'boolean',
|
||||
ownerId: 'class:Parent',
|
||||
});
|
||||
ctx.importMap.set('src/app.java', new Set(['src/child.java', 'src/parent.java']));
|
||||
|
||||
const heritage: ExtractedHeritage[] = [
|
||||
{ filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' },
|
||||
];
|
||||
const map = buildHeritageMap(heritage, ctx);
|
||||
|
||||
const result = resolveMemberCall('Child', 'validate', 'src/app.java', ctx, map);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('method:Parent:validate');
|
||||
expect(result!.returnType).toBe('boolean');
|
||||
});
|
||||
|
||||
it('returns null for unknown owner type', () => {
|
||||
const result = resolveMemberCall('NonExistent', 'save', 'src/app.ts', ctx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown method on known owner', () => {
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
const result = resolveMemberCall('User', 'nonExistentMethod', 'src/app.ts', ctx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns result with correct confidence tier for same-file resolution', () => {
|
||||
ctx.symbols.add('src/app.ts', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/app.ts', 'save', 'method:User:save', 'Method', {
|
||||
returnType: 'void',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
|
||||
const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.confidence).toBe(0.95); // same-file tier
|
||||
expect(result!.reason).toBe('same-file');
|
||||
});
|
||||
|
||||
it('returns result with import-scoped tier for cross-file resolution', () => {
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
|
||||
returnType: 'void',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.confidence).toBe(0.9); // import-scoped tier
|
||||
expect(result!.reason).toBe('import-resolved');
|
||||
});
|
||||
|
||||
it('resolves with heritage map across C3 MRO chain (Python)', () => {
|
||||
ctx.symbols.add('src/a.py', 'A', 'class:A', 'Class');
|
||||
ctx.symbols.add('src/b.py', 'B', 'class:B', 'Class');
|
||||
ctx.symbols.add('src/c.py', 'C', 'class:C', 'Class');
|
||||
ctx.symbols.add('src/a.py', 'foo', 'method:A:foo', 'Method', {
|
||||
returnType: 'str',
|
||||
ownerId: 'class:A',
|
||||
});
|
||||
ctx.importMap.set('src/main.py', new Set(['src/a.py', 'src/b.py', 'src/c.py']));
|
||||
|
||||
const heritage: ExtractedHeritage[] = [
|
||||
{ filePath: 'src/c.py', className: 'C', parentName: 'B', kind: 'extends' },
|
||||
{ filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' },
|
||||
];
|
||||
const map = buildHeritageMap(heritage, ctx);
|
||||
|
||||
const result = resolveMemberCall('C', 'foo', 'src/main.py', ctx, map);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('method:A:foo');
|
||||
expect(result!.returnType).toBe('str');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Locks in the B2 semantic change: tier reflects how the OWNER TYPE was
|
||||
// resolved, not how the method name was resolved globally.
|
||||
// -------------------------------------------------------------------------
|
||||
it('uses owner-type tier: cross-file class resolution → import-scoped confidence', () => {
|
||||
// Scenario: owner class 'User' is defined in user.ts (imported from app.ts).
|
||||
// The method 'save' exists ONLY on User (no homonyms). Old behaviour would
|
||||
// have used the tier of resolving "save" globally; new behaviour uses the
|
||||
// tier of resolving "User". Both happen to yield import-scoped here —
|
||||
// the test locks that the reported tier tracks the class lookup.
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
|
||||
returnType: 'void',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.confidence).toBe(0.9); // import-scoped
|
||||
expect(result!.reason).toBe('import-resolved');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T2: Rust qualified-syntax — trait-inherited methods must return null
|
||||
// because they require `TraitName::method(obj)` call syntax, not `obj.method()`.
|
||||
// Only struct's OWN impl methods are reachable via direct member calls.
|
||||
// -------------------------------------------------------------------------
|
||||
it('Rust: returns null for trait-inherited method (qualified-syntax MRO)', () => {
|
||||
// Trait Writer defines `save`. Struct User has an impl_item but NO save
|
||||
// method of its own — save is only available via trait.
|
||||
ctx.symbols.add('src/writer.rs', 'Writer', 'trait:Writer', 'Trait');
|
||||
ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct');
|
||||
ctx.symbols.add('src/writer.rs', 'save', 'method:Writer:save', 'Method', {
|
||||
returnType: 'bool',
|
||||
ownerId: 'trait:Writer',
|
||||
});
|
||||
ctx.importMap.set('src/app.rs', new Set(['src/writer.rs', 'src/user.rs']));
|
||||
|
||||
const heritage: ExtractedHeritage[] = [
|
||||
// User implements Writer — in Rust this is `impl Writer for User`.
|
||||
{ filePath: 'src/user.rs', className: 'User', parentName: 'Writer', kind: 'implements' },
|
||||
];
|
||||
const map = buildHeritageMap(heritage, ctx);
|
||||
|
||||
// Rust's qualified-syntax strategy short-circuits trait inheritance walks,
|
||||
// so `user.save()` (direct call) does not resolve.
|
||||
const result = resolveMemberCall('User', 'save', 'src/app.rs', ctx, map);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('Rust: direct impl methods still resolve (distinction check for T2)', () => {
|
||||
// Positive control: a method defined directly on User (not via trait)
|
||||
// resolves normally — demonstrates the null in the previous test is
|
||||
// specifically due to the trait-inheritance path, not a broken fixture.
|
||||
ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct');
|
||||
ctx.symbols.add('src/user.rs', 'name', 'method:User:name', 'Method', {
|
||||
returnType: 'String',
|
||||
ownerId: 'struct:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.rs', new Set(['src/user.rs']));
|
||||
|
||||
const result = resolveMemberCall('User', 'name', 'src/app.rs', ctx);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('method:User:name');
|
||||
expect(result!.returnType).toBe('String');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T3: C/C++ leftmost-base diamond inheritance at the resolveMemberCall layer.
|
||||
// -------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
// Homonym disambiguation: when two class candidates share a name but only
|
||||
// ONE of them owns the method, resolveMemberCall should return that one
|
||||
// without falling through to the fuzzy D2 widening path. Absorbs what was
|
||||
// previously D4's ownerId-filtering job into the owner-scoped path.
|
||||
// -------------------------------------------------------------------------
|
||||
it('disambiguates homonym classes: only one owns the method', () => {
|
||||
// Two classes both named `User` — one in auth.py (has `save`), one in
|
||||
// legacy.py (has `archive` but no `save`). Both are imported from app.py.
|
||||
ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class');
|
||||
ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', {
|
||||
returnType: 'None',
|
||||
ownerId: 'class:auth:User',
|
||||
});
|
||||
ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class');
|
||||
ctx.symbols.add('src/legacy.py', 'archive', 'method:legacy:User:archive', 'Method', {
|
||||
returnType: 'None',
|
||||
ownerId: 'class:legacy:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/legacy.py']));
|
||||
|
||||
// `user.save()` is unambiguous — only auth.User has `save`.
|
||||
const saveResult = resolveMemberCall('User', 'save', 'src/app.py', ctx);
|
||||
expect(saveResult).not.toBeNull();
|
||||
expect(saveResult!.nodeId).toBe('method:auth:User:save');
|
||||
|
||||
// `user.archive()` is also unambiguous — only legacy.User has `archive`.
|
||||
const archiveResult = resolveMemberCall('User', 'archive', 'src/app.py', ctx);
|
||||
expect(archiveResult).not.toBeNull();
|
||||
expect(archiveResult!.nodeId).toBe('method:legacy:User:archive');
|
||||
});
|
||||
|
||||
it('returns null when homonym classes BOTH own the method (genuine ambiguity)', () => {
|
||||
// Both homonym Users define a `save` method — resolveMemberCall refuses
|
||||
// to pick one. The caller (resolveCallTarget) falls through to D1-D4 which
|
||||
// may or may not be able to narrow further.
|
||||
ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class');
|
||||
ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', {
|
||||
returnType: 'None',
|
||||
ownerId: 'class:auth:User',
|
||||
});
|
||||
ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class');
|
||||
ctx.symbols.add('src/legacy.py', 'save', 'method:legacy:User:save', 'Method', {
|
||||
returnType: 'None',
|
||||
ownerId: 'class:legacy:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/legacy.py']));
|
||||
|
||||
const result = resolveMemberCall('User', 'save', 'src/app.py', ctx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('homonym + shared ancestor: both walk MRO to the same method (dedups to 1)', () => {
|
||||
// Two homonym `User` classes in different files, both extending a common
|
||||
// `BaseUser` that owns `save`. Direct lookup on either User misses; MRO
|
||||
// walks both find BaseUser.save. Dedup by nodeId yields a single result.
|
||||
ctx.symbols.add('src/base.ts', 'BaseUser', 'class:BaseUser', 'Class');
|
||||
ctx.symbols.add('src/base.ts', 'save', 'method:BaseUser:save', 'Method', {
|
||||
returnType: 'void',
|
||||
ownerId: 'class:BaseUser',
|
||||
});
|
||||
ctx.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class');
|
||||
ctx.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class');
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/base.ts', 'src/a.ts', 'src/b.ts']));
|
||||
|
||||
const heritage: ExtractedHeritage[] = [
|
||||
{ filePath: 'src/a.ts', className: 'User', parentName: 'BaseUser', kind: 'extends' },
|
||||
{ filePath: 'src/b.ts', className: 'User', parentName: 'BaseUser', kind: 'extends' },
|
||||
];
|
||||
const map = buildHeritageMap(heritage, ctx);
|
||||
|
||||
const result = resolveMemberCall('User', 'save', 'src/app.ts', ctx, map);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('method:BaseUser:save');
|
||||
});
|
||||
|
||||
it('C++: resolves diamond inheritance via leftmost-base MRO', () => {
|
||||
// Diamond:
|
||||
// Base
|
||||
// / \
|
||||
// A B
|
||||
// \ /
|
||||
// Derived
|
||||
//
|
||||
// Both A and B inherit `method` from Base. Derived extends (A, B).
|
||||
// Leftmost-base strategy walks A's chain first → finds Base::method.
|
||||
ctx.symbols.add('src/base.h', 'Base', 'class:Base', 'Class');
|
||||
ctx.symbols.add('src/a.h', 'A', 'class:A', 'Class');
|
||||
ctx.symbols.add('src/b.h', 'B', 'class:B', 'Class');
|
||||
ctx.symbols.add('src/derived.h', 'Derived', 'class:Derived', 'Class');
|
||||
ctx.symbols.add('src/base.h', 'method', 'method:Base:method', 'Method', {
|
||||
returnType: 'int',
|
||||
ownerId: 'class:Base',
|
||||
});
|
||||
ctx.importMap.set(
|
||||
'src/app.cpp',
|
||||
new Set(['src/base.h', 'src/a.h', 'src/b.h', 'src/derived.h']),
|
||||
);
|
||||
|
||||
const heritage: ExtractedHeritage[] = [
|
||||
{ filePath: 'src/a.h', className: 'A', parentName: 'Base', kind: 'extends' },
|
||||
{ filePath: 'src/b.h', className: 'B', parentName: 'Base', kind: 'extends' },
|
||||
{ filePath: 'src/derived.h', className: 'Derived', parentName: 'A', kind: 'extends' },
|
||||
{ filePath: 'src/derived.h', className: 'Derived', parentName: 'B', kind: 'extends' },
|
||||
];
|
||||
const map = buildHeritageMap(heritage, ctx);
|
||||
|
||||
const result = resolveMemberCall('Derived', 'method', 'src/app.cpp', ctx, map);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('method:Base:method');
|
||||
expect(result!.returnType).toBe('int');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// L1: C# / Kotlin implements-split strategy through resolveMemberCall.
|
||||
// lookupMethodByOwnerWithMRO already has strategy-level coverage for these
|
||||
// languages; these tests add the resolveMemberCall layer (tier resolution
|
||||
// + class candidate iteration + MRO walk) on top.
|
||||
// -------------------------------------------------------------------------
|
||||
it('C#: walks implements-split to find inherited method via interface', () => {
|
||||
// C# uses implements-split MRO: class base chain walked first, then
|
||||
// interfaces. Here IService declares Save which is implemented by the
|
||||
// base class BaseService — MyService inherits Save through the class.
|
||||
ctx.symbols.add('src/iservice.cs', 'IService', 'interface:IService', 'Interface');
|
||||
ctx.symbols.add('src/base.cs', 'BaseService', 'class:BaseService', 'Class');
|
||||
ctx.symbols.add('src/my.cs', 'MyService', 'class:MyService', 'Class');
|
||||
ctx.symbols.add('src/base.cs', 'Save', 'method:BaseService:Save', 'Method', {
|
||||
returnType: 'void',
|
||||
ownerId: 'class:BaseService',
|
||||
});
|
||||
ctx.importMap.set('src/app.cs', new Set(['src/iservice.cs', 'src/base.cs', 'src/my.cs']));
|
||||
|
||||
const heritage: ExtractedHeritage[] = [
|
||||
{
|
||||
filePath: 'src/base.cs',
|
||||
className: 'BaseService',
|
||||
parentName: 'IService',
|
||||
kind: 'implements',
|
||||
},
|
||||
{ filePath: 'src/my.cs', className: 'MyService', parentName: 'BaseService', kind: 'extends' },
|
||||
];
|
||||
const map = buildHeritageMap(heritage, ctx);
|
||||
|
||||
const result = resolveMemberCall('MyService', 'Save', 'src/app.cs', ctx, map);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('method:BaseService:Save');
|
||||
expect(result!.returnType).toBe('void');
|
||||
});
|
||||
|
||||
it('Kotlin: walks implements-split to find inherited method via interface', () => {
|
||||
// Kotlin shares the implements-split MRO strategy with Java/C#. A class
|
||||
// inheriting from an interface that provides a default method should
|
||||
// resolve `obj.method()` to the interface's implementation.
|
||||
ctx.symbols.add('src/validator.kt', 'Validator', 'interface:Validator', 'Interface');
|
||||
ctx.symbols.add('src/user.kt', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/validator.kt', 'validate', 'method:Validator:validate', 'Method', {
|
||||
returnType: 'Boolean',
|
||||
ownerId: 'interface:Validator',
|
||||
});
|
||||
ctx.importMap.set('src/app.kt', new Set(['src/validator.kt', 'src/user.kt']));
|
||||
|
||||
const heritage: ExtractedHeritage[] = [
|
||||
{
|
||||
filePath: 'src/user.kt',
|
||||
className: 'User',
|
||||
parentName: 'Validator',
|
||||
kind: 'implements',
|
||||
},
|
||||
];
|
||||
const map = buildHeritageMap(heritage, ctx);
|
||||
|
||||
const result = resolveMemberCall('User', 'validate', 'src/app.kt', ctx, map);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('method:Validator:validate');
|
||||
expect(result!.returnType).toBe('Boolean');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T1: D0 skip-condition tests — verify resolveCallTarget bypasses the
|
||||
// resolveMemberCall fast path when overloadHints, preComputedArgTypes, or a
|
||||
// module alias is active.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveCallTarget D0 skip conditions (SM-11)', () => {
|
||||
let ctx: ResolutionContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createResolutionContext();
|
||||
});
|
||||
|
||||
it('module alias: picks alias-scoped class over homonym (D0 actually bypassed)', () => {
|
||||
// Python-style: `import auth; auth.User.save()` where BOTH auth.py and
|
||||
// other.py define a `User` class with a `save` method. The test proves:
|
||||
//
|
||||
// 1. Without the alias: resolveMemberCall sees two homonym Users,
|
||||
// both own `save`, and correctly returns null (refuses to guess).
|
||||
// 2. With the alias: D0 is skipped via `hasActiveModuleAlias`, and
|
||||
// D1-D4 — respecting the alias-narrowed filteredCandidates — picks
|
||||
// the auth.py User.save method.
|
||||
//
|
||||
// A regression where D0 silently ran would produce null (ambiguous)
|
||||
// instead of the correct answer, so this test actually exercises the
|
||||
// skip path rather than just verifying a single-candidate happy path.
|
||||
ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class');
|
||||
ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', {
|
||||
returnType: 'None',
|
||||
ownerId: 'class:auth:User',
|
||||
});
|
||||
ctx.symbols.add('src/other.py', 'User', 'class:other:User', 'Class');
|
||||
ctx.symbols.add('src/other.py', 'save', 'method:other:User:save', 'Method', {
|
||||
returnType: 'None',
|
||||
ownerId: 'class:other:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.py', new Set(['src/auth.py', 'src/other.py']));
|
||||
ctx.moduleAliasMap.set('src/app.py', new Map([['auth', 'src/auth.py']]));
|
||||
|
||||
// Control: without alias narrowing, resolveMemberCall sees both Users
|
||||
// own `save` and correctly refuses to pick one.
|
||||
const ambiguous = resolveMemberCall('User', 'save', 'src/app.py', ctx);
|
||||
expect(ambiguous).toBeNull();
|
||||
|
||||
// With alias narrowing active, D0 is skipped and D1-D4 picks auth.py's
|
||||
// User.save because the alias block already narrowed filteredCandidates
|
||||
// to auth.py (and the D2 widening step is gated on `!aliasNarrowed`).
|
||||
const aliased = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'save',
|
||||
callForm: 'member',
|
||||
receiverTypeName: 'User',
|
||||
receiverName: 'auth', // triggers hasActiveModuleAlias → D0 skipped
|
||||
},
|
||||
'src/app.py',
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(aliased).not.toBeNull();
|
||||
expect(aliased!.nodeId).toBe('method:auth:User:save');
|
||||
});
|
||||
|
||||
it('overloadHints present: D0 bypassed, D1-D4 handles resolution', () => {
|
||||
// When overloadHints is supplied, the D0 fast path must be skipped
|
||||
// because lookupMethodByOwner does not consider argument types and
|
||||
// would pick an arbitrary overload for same-return-type overloads.
|
||||
//
|
||||
// This test verifies that the skip does not break resolution: passing
|
||||
// a dummy overloadHints object should still yield the correct method
|
||||
// via the D1-D4 path.
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
|
||||
returnType: 'void',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
// Minimal stub; D1-D4 only calls tryOverloadDisambiguation when there are
|
||||
// multiple candidates, so an empty object is fine for single-candidate cases.
|
||||
const dummyHints = {} as OverloadHints;
|
||||
|
||||
const result = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'save',
|
||||
callForm: 'member',
|
||||
receiverTypeName: 'User',
|
||||
},
|
||||
'src/app.ts',
|
||||
ctx,
|
||||
{ overloadHints: dummyHints },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('method:User:save');
|
||||
});
|
||||
|
||||
it('preComputedArgTypes present: D0 bypassed, D1-D4 handles resolution', () => {
|
||||
// Analogous to the overloadHints case: when preComputedArgTypes is supplied
|
||||
// (worker path), D0 must be skipped so that type-based overload
|
||||
// disambiguation in D1-D4 is authoritative.
|
||||
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
|
||||
ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', {
|
||||
returnType: 'void',
|
||||
ownerId: 'class:User',
|
||||
});
|
||||
ctx.importMap.set('src/app.ts', new Set(['src/user.ts']));
|
||||
|
||||
const result = _resolveCallTargetForTesting(
|
||||
{
|
||||
calledName: 'save',
|
||||
callForm: 'member',
|
||||
receiverTypeName: 'User',
|
||||
argCount: 0,
|
||||
},
|
||||
'src/app.ts',
|
||||
ctx,
|
||||
{ preComputedArgTypes: [] },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nodeId).toBe('method:User:save');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue