From f0805640e93313f496da455997337a4004e1739f Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 21 Apr 2026 19:14:03 +0100 Subject: [PATCH] =?UTF-8?q?feat(csharp-scope):=20parity=20Unit=203b=20?= =?UTF-8?q?=E2=80=94=20constructor=20CALLS=20emission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 3 parity failures (25 → 22). Adds constructor-form CALLS edge emission + C# 12 primary constructor synthesis. Changes: - `scope-resolution/passes/free-call-fallback.ts`: when a site's callForm === 'constructor', look up the class def (not a callable) and pick its explicit Constructor def via workspaceIndex's memberByOwner — or fall back to the Class def itself for implicit constructors. Matches legacy behavior (targetLabel === 'Constructor' when explicit, 'Class' when implicit). - `scope-resolution/pipeline/run.ts`: pass workspaceIndex to the free-call fallback. - `languages/csharp/captures.ts`: synthesize @declaration.constructor for C# 12 primary constructors — `class User(string name, int age)` / `record Person(string First, string Last)`. The parameter_list is a named child of the class_declaration / record_declaration (not a separate constructor_declaration node). Skip the synthesis when the type already has an explicit constructor to avoid duplicates. Emits @declaration.parameter-count + required-parameter-count alongside. Legacy 175/175 green; 376/376 scope-resolution unit tests pass; 22 parity failures remain. --- .../ingestion/languages/csharp/captures.ts | 71 +++++++++++++++++++ .../passes/free-call-fallback.ts | 39 +++++++++- .../scope-resolution/pipeline/run.ts | 1 + 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index 82a39a6d9..110ddf247 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -144,11 +144,82 @@ export function emitCsharpScopeCaptures( } out.push(grouped); + + // Synthesize primary-constructor declarations on class/record + // declarations that carry a `parameter_list` child (C# 12 syntax + // `public class User(string name, int age) { ... }` or + // `public record Person(string FirstName, string LastName)`). + // Legacy `csharpMethodConfig.extractPrimaryConstructor` runs via + // the parse phase; the scope-resolution path needs its own emit so + // `new User(...)` resolves to a Constructor def in memberByOwner. + if ( + grouped['@declaration.class'] !== undefined || + grouped['@declaration.record'] !== undefined + ) { + const anchor = grouped['@declaration.class'] ?? grouped['@declaration.record']!; + const typeNode = + findNodeAtRange(tree.rootNode, anchor.range, 'class_declaration') ?? + findNodeAtRange(tree.rootNode, anchor.range, 'record_declaration'); + if (typeNode !== null) { + const synth = synthesizePrimaryConstructor(typeNode); + if (synth !== null) out.push(synth); + } + } } return out; } +/** C# 12 primary constructor: `class X(a, b) { }` / `record X(a, b)`. + * The parameters are a bare `parameter_list` named child of the type + * declaration (no `constructor_declaration` node). Emit a synthetic + * @declaration.constructor match so the extractor creates a + * Constructor def in memberByOwner — free-call-fallback's + * `pickConstructorOrClass` then targets it for `new X(...)` calls. */ +function synthesizePrimaryConstructor(typeNode: SyntaxNode): CaptureMatch | null { + // Skip types with an explicit constructor_declaration — that would + // create duplicate defs. + const body = typeNode.childForFieldName('body'); + if (body !== null) { + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (child !== null && child.type === 'constructor_declaration') return null; + } + } + let paramList: SyntaxNode | null = null; + for (let i = 0; i < typeNode.namedChildCount; i++) { + const child = typeNode.namedChild(i); + if (child !== null && child.type === 'parameter_list') { + paramList = child; + break; + } + } + if (paramList === null) return null; + + const nameNode = typeNode.childForFieldName('name'); + if (nameNode === null) return null; + + const paramCount = paramList.namedChildren.filter( + (c) => c !== null && c.type === 'parameter', + ).length; + + const m: Record = { + '@declaration.constructor': nodeToCapture('@declaration.constructor', paramList), + '@declaration.name': syntheticCapture('@declaration.name', nameNode, nameNode.text), + '@declaration.parameter-count': syntheticCapture( + '@declaration.parameter-count', + paramList, + String(paramCount), + ), + '@declaration.required-parameter-count': syntheticCapture( + '@declaration.required-parameter-count', + paramList, + String(paramCount), + ), + }; + return m; +} + type SyntaxNode = ReturnType['parse']>['rootNode']; /** Find the first C# function-like node at the given range. The diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index c35d2e3f3..ad8324062 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -17,12 +17,13 @@ * generalization plan. */ -import type { ParsedFile, Reference, ScopeId } from 'gitnexus-shared'; +import type { ParsedFile, Reference, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js'; -import { findCallableBindingInScope } from '../scope/walkers.js'; +import { findCallableBindingInScope, findClassBindingInScope } from '../scope/walkers.js'; export function emitFreeCallFallback( graph: KnowledgeGraph, @@ -31,6 +32,7 @@ export function emitFreeCallFallback( nodeLookup: GraphNodeLookup, _referenceIndex: { readonly bySourceScope: ReadonlyMap }, handledSites: Set, + workspaceIndex?: WorkspaceResolutionIndex, ): number { let emitted = 0; const seen = new Set(); @@ -40,7 +42,20 @@ export function emitFreeCallFallback( if (site.kind !== 'call') continue; if (site.explicitReceiver !== undefined) continue; - const fnDef = findCallableBindingInScope(site.inScope, site.name, scopes); + // Constructor form (`new User(...)`): resolve the class, then + // emit CALLS to its explicit Constructor def (when present) or + // to the Class node itself (implicit constructor). Legacy emits + // the same two targets; see test expectations. + let fnDef: SymbolDefinition | undefined; + if (site.callForm === 'constructor') { + const classDef = findClassBindingInScope(site.inScope, site.name, scopes); + if (classDef !== undefined) { + fnDef = pickConstructorOrClass(classDef, workspaceIndex); + } + } + if (fnDef === undefined) { + fnDef = findCallableBindingInScope(site.inScope, site.name, scopes); + } if (fnDef === undefined) continue; const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); @@ -69,3 +84,21 @@ export function emitFreeCallFallback( } return emitted; } + +/** For a constructor call `new X(...)`, return the X class's explicit + * Constructor def (by walking memberByOwner) or the Class def itself + * when no explicit Constructor exists. Matches legacy behavior — + * tests assert targetLabel === 'Class' for implicit ctors and + * targetLabel === 'Constructor' for explicit ones. */ +function pickConstructorOrClass( + classDef: SymbolDefinition, + workspaceIndex: WorkspaceResolutionIndex | undefined, +): SymbolDefinition { + if (workspaceIndex === undefined) return classDef; + const members = workspaceIndex.memberByOwner.get(classDef.nodeId); + if (members === undefined) return classDef; + for (const [, def] of members) { + if (def.type === 'Constructor') return def; + } + return classDef; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index ba46cb279..822be1358 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -180,6 +180,7 @@ export function runScopeResolution( nodeLookup, referenceIndex, handledSites, + workspaceIndex, ); const { emitted, skipped } = emitReferencesViaLookup( graph,