diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index deb58e9e9..a739c8441 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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; +/** @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; diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index 086148f2f..b8d39170c 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -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[] => { diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/A.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/A.h new file mode 100644 index 000000000..314c3556f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/A.h @@ -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 { +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/B.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/B.h new file mode 100644 index 000000000..7a9b90fff --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/B.h @@ -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 { +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Base.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Base.h new file mode 100644 index 000000000..2b8382711 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Base.h @@ -0,0 +1,6 @@ +#pragma once + +class Base { +public: + int method() { return 42; } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Derived.h new file mode 100644 index 000000000..2b6e89b46 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/Derived.h @@ -0,0 +1,6 @@ +#pragma once +#include "A.h" +#include "B.h" + +class Derived : public A, public B { +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/app.cpp new file mode 100644 index 000000000..8bc9b4969 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/app.cpp @@ -0,0 +1,6 @@ +#include "Derived.h" + +void run() { + Derived d; + d.method(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/App.cs new file mode 100644 index 000000000..2e516fd93 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/App.cs @@ -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(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/User.cs new file mode 100644 index 000000000..3d380a83f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/User.cs @@ -0,0 +1,11 @@ +namespace InterfaceDefault; + +public class User : IValidator +{ + public string Name { get; } + + public User(string name) + { + Name = name; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/Validator.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/Validator.cs new file mode 100644 index 000000000..620e0a207 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/Validator.cs @@ -0,0 +1,6 @@ +namespace InterfaceDefault; + +public interface IValidator +{ + bool Validate() => true; +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/App.java b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/App.java new file mode 100644 index 000000000..57564f7d2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/App.java @@ -0,0 +1,6 @@ +public class App { + public static void run() { + User user = new User("alice"); + user.validate(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/User.java b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/User.java new file mode 100644 index 000000000..ceab78b1c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/User.java @@ -0,0 +1,7 @@ +public class User implements Validator { + private String name; + + public User(String name) { + this.name = name; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/Validator.java b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/Validator.java new file mode 100644 index 000000000..40796bc53 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-interface-default-method/Validator.java @@ -0,0 +1,5 @@ +public interface Validator { + default boolean validate() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/App.kt new file mode 100644 index 000000000..42e0897c7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/App.kt @@ -0,0 +1,6 @@ +package example + +fun run() { + val user = User("alice") + user.validate() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/User.kt new file mode 100644 index 000000000..279e5a6fa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/User.kt @@ -0,0 +1,3 @@ +package example + +class User(val name: String) : Validator diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/Validator.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/Validator.kt new file mode 100644 index 000000000..7ddd1631f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-default-method/src/Validator.kt @@ -0,0 +1,5 @@ +package example + +interface Validator { + fun validate(): Boolean = true +} diff --git a/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/app.py b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/app.py new file mode 100644 index 000000000..30e1be47a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/app.py @@ -0,0 +1,6 @@ +from child import Child + + +def run() -> None: + c = Child() + c.gp_method() diff --git a/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/child.py b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/child.py new file mode 100644 index 000000000..be1852fa6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/child.py @@ -0,0 +1,5 @@ +from parent import Parent + + +class Child(Parent): + pass diff --git a/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/grandparent.py b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/grandparent.py new file mode 100644 index 000000000..675d7a412 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/grandparent.py @@ -0,0 +1,3 @@ +class Grandparent: + def gp_method(self) -> str: + return "grandparent" diff --git a/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/parent.py b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/parent.py new file mode 100644 index 000000000..6a24add39 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-multi-level-mro/parent.py @@ -0,0 +1,5 @@ +from grandparent import Grandparent + + +class Parent(Grandparent): + pass diff --git a/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/child.rs b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/child.rs new file mode 100644 index 000000000..1e9755c90 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/child.rs @@ -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 {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/main.rs new file mode 100644 index 000000000..bac6fd160 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/main.rs @@ -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(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/parent.rs b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/parent.rs new file mode 100644 index 000000000..e14a6f86c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-child-extends-parent/src/parent.rs @@ -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" + } +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 630c86696..0839d59f0 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 5db630bf2..af2dbb786 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 8a09030ed..d9bae90bf 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 1163538e4..056a2514a 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index f2219463e..b7d60b20e 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 496f15e9b..5e03da39b 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 81dc9f042..b0c2b04eb 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -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 () => { diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index b2ba7e288..1ad33417a 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -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'); + }); +});