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); } } }