diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 1cbb8bd65..6a37acc2e 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; @@ -1353,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) { @@ -1361,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. @@ -1375,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; + } } } } @@ -1424,8 +1438,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, @@ -1694,22 +1713,33 @@ const resolveMethodByOwner = ( const language = heritageMap ? getLanguageFromFilename(filePath) : null; const canWalkMRO = heritageMap != null && language != null; - // Iterate ALL class-like candidates. Unique hits (by nodeId) land in `matches`: - // matches.size === 0 → owner-scoped resolution found nothing - // matches.size === 1 → unambiguous answer - // matches.size > 1 → genuine homonym ambiguity — refuse to pick one - const matches = new Map(); + // 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 + 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) : ctx.symbols.lookupMethodByOwner(candidate.nodeId, methodName); - if (def) matches.set(def.nodeId, def); + if (!def) continue; + if (!firstDef) { + firstDef = def; + } else if (def.nodeId !== firstDef.nodeId) { + ambiguous = true; + break; + } } - if (matches.size !== 1) return undefined; - const [def] = matches.values(); - return { def: def!, tier: typeResolved.tier }; + if (!firstDef || ambiguous) return undefined; + return { def: firstDef, tier: typeResolved.tier }; }; // --------------------------------------------------------------------------- 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..2c45b4518 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/A.h @@ -0,0 +1,5 @@ +#pragma once +#include "Base.h" + +class A : 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..3c7524ffd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond-inheritance/src/B.h @@ -0,0 +1,5 @@ +#pragma once +#include "Base.h" + +class B : 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..dfb955e17 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-default-method/App.cs @@ -0,0 +1,10 @@ +namespace InterfaceDefault; + +public class App +{ + public static void Run() + { + User 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..d1c8c0fd2 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -1857,3 +1857,46 @@ describe('Rust abstract dispatch (Repository trait)', () => { expect(names).toEqual(['find', 'save']); }); }); + +// --------------------------------------------------------------------------- +// SM-11: Rust Child struct — direct impl method resolution via D0 +// +// Companion integration test for the unit-level Rust qualified-syntax tests +// in symbol-table.test.ts. Validates end-to-end that Rust direct-impl methods +// resolve through the owner-scoped D0 path (`resolveMemberCall`). +// +// NOTE on trait-inherited methods: Rust's qualified-syntax MRO strategy in +// `lookupMethodByOwnerWithMRO` correctly returns null for trait-inherited +// methods at the unit level. However, in the current pipeline, Rust trait +// default methods are captured as `Function` nodes (not `Method` with +// ownerId), so the owner-scoped index does not contain them. This means +// direct `obj.trait_method()` calls currently fall through to D1-D4 fuzzy +// widening rather than being correctly null-routed. Rust trait capture as +// Method-with-ownerId is a Phase 5 (SM-16) fix — out of SM-11 scope. +// --------------------------------------------------------------------------- + +describe('Rust Child direct-impl method resolution (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(); + }); +}); diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index da5ea76ae..df30b2300 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1406,6 +1406,7 @@ describe('lookupMethodByOwnerWithMRO', () => { import { _resolveCallTargetForTesting, resolveMemberCall, + type OverloadHints, } from '../../src/core/ingestion/call-processor.js'; describe('resolveMemberCall', () => { @@ -1702,6 +1703,72 @@ describe('resolveMemberCall', () => { 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'); + }); }); // --------------------------------------------------------------------------- @@ -1717,23 +1784,41 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { ctx = createResolutionContext(); }); - it('module alias: resolution succeeds when hasActiveModuleAlias triggers D0 skip', () => { - // Python-style: `import auth; auth.User.save()`. The `receiverName='auth'` - // matches a moduleAliasMap entry, which sets `hasActiveModuleAlias=true` - // and bypasses the D0 fast path. The test verifies that D1-D4 still - // produces the correct result in this skip scenario — a regression here - // (e.g. D0 being called when it shouldn't) would silently pick a homonym - // from another file, and an unintentional skip would cause resolution to - // fail entirely. + 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.importMap.set('src/app.py', new Set(['src/auth.py'])); + 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']])); - const result = _resolveCallTargetForTesting( + // 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', @@ -1744,26 +1829,8 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { ctx, ); - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:auth:User:save'); - }); - - it('module alias: resolveMemberCall called directly still works (control)', () => { - // Control case: calling resolveMemberCall directly (the path D0 would have - // taken) produces the same result. Demonstrates that the skip is a safety - // measure for the D2-widening interaction with alias narrowing, not because - // resolveMemberCall itself is broken here. - 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.importMap.set('src/app.py', new Set(['src/auth.py'])); - - const result = resolveMemberCall('User', 'save', 'src/app.py', ctx); - - expect(result).not.toBeNull(); - expect(result!.nodeId).toBe('method:auth:User:save'); + expect(aliased).not.toBeNull(); + expect(aliased!.nodeId).toBe('method:auth:User:save'); }); it('overloadHints present: D0 bypassed, D1-D4 handles resolution', () => { @@ -1783,13 +1850,7 @@ describe('resolveCallTarget D0 skip conditions (SM-11)', () => { // 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 unknown as Parameters< - typeof _resolveCallTargetForTesting - >[3] extends infer O - ? O extends { overloadHints?: infer H } - ? H - : never - : never; + const dummyHints = {} as OverloadHints; const result = _resolveCallTargetForTesting( {