diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 59920a54e..d6ea81b3c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -326,17 +326,22 @@ export function emitReceiverBoundCalls( isBuiltInName: options.isBuiltInName, }; - // Build an interface → implementors map from IMPLEMENTS edges. - // Maps Interface graph-id → list of implementor class scope-def-ids. - // We translate graph-ids back to scope-resolution DefIds via - // `parsedFiles.localDefs` lookup so downstream `findOwnedMember` - // (which keys by DefId) can find the implementor's members. - const graphIdToClassDef = new Map(); + // Maps class-like graph ids back to ALL scope definitions that resolved to + // them. Same-file partial declarations share one graph id but keep distinct + // DefIds, and `pickOverload` keys member lookup by those DefIds. Preserving + // every part makes dispatch independent of declaration order. + const graphIdToClassDefs = new Map(); for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { - if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; + if (!isClassLike(def.type)) continue; const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); - if (graphId !== undefined) graphIdToClassDef.set(graphId, def); + if (graphId === undefined) continue; + let defs = graphIdToClassDefs.get(graphId); + if (defs === undefined) { + defs = []; + graphIdToClassDefs.set(graphId, defs); + } + defs.push(def); } } // Direct subtypes of a type, keyed by the SUPERtype's def id. @@ -360,10 +365,12 @@ export function emitReceiverBoundCalls( }; for (const relType of ['IMPLEMENTS', 'EXTENDS'] as const) { for (const rel of graph.iterRelationshipsByType(relType)) { - const superDef = graphIdToClassDef.get(rel.targetId); - const subDef = graphIdToClassDef.get(rel.sourceId); - if (superDef === undefined || subDef === undefined) continue; - addSubtype(superDef.nodeId, subDef); + const superDefs = graphIdToClassDefs.get(rel.targetId); + const subDefs = graphIdToClassDefs.get(rel.sourceId); + if (superDefs === undefined || subDefs === undefined) continue; + for (const superDef of superDefs) { + for (const subDef of subDefs) addSubtype(superDef.nodeId, subDef); + } } } diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index fa5e0cc09..606eb485f 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -2353,6 +2353,82 @@ describe('C# record base resolution (record inheritance + base.Save)', () => { } }, 60000); + it('fans interface calls out to an implementing Record method (#2884)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-record-dispatch-')); + try { + writeFixtureRepo(root, { + 'INamed.cs': 'namespace Probe; public interface INamed { string Name(); }', + 'User.cs': `namespace Probe; + public record User(string Value) : INamed { + public string Name() => Value; + }`, + 'Reader.cs': `namespace Probe; + public class Reader { + public string Read(INamed value) => value.Name(); + public string ReadConcrete(User value) => value.Name(); + }`, + }); + + const linked = await runPipelineFromRepo(root, () => {}); + const calls = getRelationships(linked, 'CALLS'); + const primary = calls.filter( + (edge) => + edge.source === 'Read' && + edge.target === 'Name' && + edge.rel.reason !== 'interface-dispatch', + ); + const fanout = calls.filter( + (edge) => + edge.source === 'Read' && + edge.target === 'Name' && + edge.rel.reason === 'interface-dispatch', + ); + const concreteFanout = calls.filter( + (edge) => + edge.source === 'ReadConcrete' && + edge.target === 'Name' && + edge.rel.reason === 'interface-dispatch', + ); + + expect(primary.map((edge) => `${edge.targetLabel}:${edge.targetFilePath}`)).toEqual([ + 'Method:INamed.cs', + ]); + expect(fanout.map((edge) => `${edge.targetLabel}:${edge.targetFilePath}`)).toEqual([ + 'Method:User.cs', + ]); + expect(concreteFanout).toEqual([]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + + it('fans out through reversed same-file partial Record declarations (#2884)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-partial-record-dispatch-')); + try { + writeFixtureRepo(root, { + 'All.cs': `namespace Probe; + public interface INamed { string Name(); } + public partial record User { public string Name() => "u"; } + public partial record User : INamed { } + public class Reader { public string Read(INamed value) => value.Name(); }`, + }); + + const linked = await runPipelineFromRepo(root, () => {}); + const fanout = getRelationships(linked, 'CALLS').filter( + (edge) => + edge.source === 'Read' && + edge.target === 'Name' && + edge.rel.reason === 'interface-dispatch', + ); + + expect(fanout.map((edge) => `${edge.targetLabel}:${edge.targetFilePath}`)).toEqual([ + 'Method:All.cs', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + it('resolves base.Save() inside UserRecord.Save to BaseEntity.Save (not self)', () => { const calls = getRelationships(result, 'CALLS'); const baseSave = calls.find(