feat(csharp-scope): parity Unit 6b — member-call dedup + handled-site fix

Closes 1 parity failure (9 → 8). Adds the missing legacy-parity
behavior: collapse multiple member-call sites from the same caller
to the same target into one CALLS edge.

Changes:
- `scope-resolution/contract/scope-resolver.ts`: new optional
  `collapseMemberCallsByCallerTarget` flag. Default false (preserves
  the per-site invariant); C# sets it true.
- `scope-resolution/graph-bridge/edges.ts`: dedup key drops
  `line:col` when `collapseByCallerTarget` is on AND edgeType is
  `CALLS` (ACCESSES writes keep per-site granularity).
- `scope-resolution/passes/receiver-bound-calls.ts`: plumbs
  `collapse` through every `tryEmitEdge` call, and crucially marks
  `handledSites.add(siteKey)` whenever a resolved def was found —
  not only when the edge was freshly emitted. Otherwise the site
  leaked through to `emitReferencesViaLookup` which re-emitted a
  per-site edge, defeating the collapse.
- `languages/csharp/scope-resolver.ts`: opt in to the collapse.

Python parity 204/204 on both flag paths; legacy C# 175/175 green;
8 C# parity failures remain.
This commit is contained in:
Gergo Magyar 2026-04-21 21:11:48 +01:00
parent 2de104150e
commit 5e54fee523
4 changed files with 67 additions and 30 deletions

View file

@ -71,6 +71,11 @@ const csharpScopeResolver: ScopeResolver = {
// since signatures are authoritative.
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: true,
// C# matches legacy DAG by collapsing member-call CALLS edges to
// `(caller, target)` — multiple `g.Greet(...)` sites from Main
// yield ONE edge, not one per site.
collapseMemberCallsByCallerTarget: true,
};
export { csharpScopeResolver };

View file

@ -210,6 +210,15 @@ export interface ScopeResolver {
*/
readonly fieldFallbackOnMethodLookup?: boolean;
/**
* Collapse member-call CALLS edges by `(caller, target)` rather
* than per-site. Default `false` (scope-resolution's contract
* invariant is per-site dedup). C# enables this to match the
* legacy DAG's member-call collapsing where multiple call sites
* from the same caller to the same target yield one edge.
*/
readonly collapseMemberCallsByCallerTarget?: boolean;
/**
* Optional post-finalize hook to inject cross-file bindings that
* aren't modeled via explicit imports. C# uses this to make every

View file

@ -68,6 +68,7 @@ export function tryEmitEdge(
reason: string,
seen: Set<string>,
confidence = 0.85,
collapseByCallerTarget = false,
): boolean {
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
@ -76,7 +77,14 @@ export function tryEmitEdge(
if (targetGraphId === undefined) return false;
if (edgeType === undefined) return false;
const dedupKey = `${edgeType}:${callerGraphId}->${targetGraphId}:${site.atRange.startLine}:${site.atRange.startCol}`;
// CALLS edges may collapse to `(caller, target)` granularity when
// the provider opts in (C# matches legacy DAG behavior this way).
// Write/read ACCESSES keep per-site dedup so multiple writes to the
// same field on different lines produce distinct edges.
const useCollapsed = collapseByCallerTarget && edgeType === 'CALLS';
const dedupKey = useCollapsed
? `${edgeType}:${callerGraphId}->${targetGraphId}`
: `${edgeType}:${callerGraphId}->${targetGraphId}:${site.atRange.startLine}:${site.atRange.startCol}`;
if (seen.has(dedupKey)) return false;
seen.add(dedupKey);

View file

@ -51,7 +51,7 @@ import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
* refactors lighter — callers only need to populate what we read. */
type ReceiverBoundProviderSubset = Pick<
ScopeResolver,
'isSuperReceiver' | 'fieldFallbackOnMethodLookup'
'isSuperReceiver' | 'fieldFallbackOnMethodLookup' | 'collapseMemberCallsByCallerTarget'
>;
export function emitReceiverBoundCalls(
@ -69,6 +69,7 @@ export function emitReceiverBoundCalls(
// from the reference index — see Contract Invariant I5.
const seen = new Set<string>();
const fieldFallback = provider.fieldFallbackOnMethodLookup ?? true;
const collapse = provider.collapseMemberCallsByCallerTarget === true;
for (const parsed of parsedFiles) {
const namespaceTargets = collectNamespaceTargets(parsed, scopes);
@ -100,11 +101,15 @@ export function emitReceiverBoundCalls(
memberDef,
'scope-resolution: super-receiver',
seen,
0.85,
collapse,
);
if (ok) {
emitted++;
handledSites.add(siteKey);
}
if (ok) emitted++;
// Always mark handled when the site was resolved, even
// if the edge was deduplicated (collapse mode), so
// `emitReferencesViaLookup` doesn't re-emit from the
// reference index.
handledSites.add(siteKey);
continue;
}
}
@ -135,11 +140,15 @@ export function emitReceiverBoundCalls(
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
seen,
0.85,
collapse,
);
if (ok) {
emitted++;
handledSites.add(siteKey);
}
if (ok) emitted++;
// Always mark handled when the site was resolved, even
// if the edge was deduplicated (collapse mode), so
// `emitReferencesViaLookup` doesn't re-emit from the
// reference index.
handledSites.add(siteKey);
continue;
}
}
@ -158,11 +167,11 @@ export function emitReceiverBoundCalls(
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
seen,
0.85,
collapse,
);
if (ok) {
emitted++;
handledSites.add(siteKey);
}
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
}
@ -185,11 +194,11 @@ export function emitReceiverBoundCalls(
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
seen,
0.85,
collapse,
);
if (ok) {
emitted++;
handledSites.add(siteKey);
}
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
}
@ -254,11 +263,15 @@ export function emitReceiverBoundCalls(
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
seen,
0.85,
collapse,
);
if (ok) {
emitted++;
handledSites.add(siteKey);
}
if (ok) emitted++;
// Always mark handled when the site was resolved, even
// if the edge was deduplicated (collapse mode), so
// `emitReferencesViaLookup` doesn't re-emit from the
// reference index.
handledSites.add(siteKey);
continue;
}
}
@ -294,11 +307,14 @@ export function emitReceiverBoundCalls(
reason,
seen,
confidence,
collapse,
);
if (ok) {
emitted++;
handledSites.add(siteKey);
}
if (ok) emitted++;
// Always mark handled when the site was resolved, even
// if the edge was deduplicated (collapse mode), so
// `emitReferencesViaLookup` doesn't re-emit from the
// reference index.
handledSites.add(siteKey);
continue;
}
}
@ -336,11 +352,10 @@ export function emitReceiverBoundCalls(
reason,
seen,
confidence,
collapse,
);
if (ok) {
emitted++;
handledSites.add(siteKey);
}
if (ok) emitted++;
handledSites.add(siteKey);
}
}
}