From 5e54fee523ac54d29391acb30b3f276b0a55e138 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 21 Apr 2026 21:11:48 +0100 Subject: [PATCH] =?UTF-8?q?feat(csharp-scope):=20parity=20Unit=206b=20?= =?UTF-8?q?=E2=80=94=20member-call=20dedup=20+=20handled-site=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../languages/csharp/scope-resolver.ts | 5 ++ .../contract/scope-resolver.ts | 9 +++ .../scope-resolution/graph-bridge/edges.ts | 10 ++- .../passes/receiver-bound-calls.ts | 73 +++++++++++-------- 4 files changed, 67 insertions(+), 30 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index e733245b9..75d99880a 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -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 }; diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index f79661e0c..1424c940d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts index 462ce61e1..080972691 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts @@ -68,6 +68,7 @@ export function tryEmitEdge( reason: string, seen: Set, 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); 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 975d268e0..2b169bfc3 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 @@ -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(); 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); } } }