fix(scope-resolution): fan out C# Record interface calls (#2904)
Some checks failed
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled

* fix(scope-resolution): fan out C# Record interface calls

Use the shared class-like predicate so canonical C# Record implementors participate in interface dispatch, and pin the missing call edge with a regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scope-resolution): preserve partial Record dispatch

Keep every scope definition that shares a graph node so interface fan-out is independent of partial declaration order, and strengthen C# dispatch controls.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
azizur100389 2026-08-09 18:39:47 +01:00 committed by GitHub
parent 81100e2c74
commit 49c5b7d81f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 95 additions and 12 deletions

View file

@ -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<string, SymbolDefinition>();
// 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<string, SymbolDefinition[]>();
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);
}
}
}

View file

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