feat(csharp-scope): parity Unit 3b — constructor CALLS emission

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.
This commit is contained in:
Gergo Magyar 2026-04-21 19:14:03 +01:00
parent cb8a268031
commit f0805640e9
3 changed files with 108 additions and 3 deletions

View file

@ -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<string, Capture> = {
'@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<ReturnType<typeof getCsharpParser>['parse']>['rootNode'];
/** Find the first C# function-like node at the given range. The

View file

@ -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<ScopeId, readonly Reference[]> },
handledSites: Set<string>,
workspaceIndex?: WorkspaceResolutionIndex,
): number {
let emitted = 0;
const seen = new Set<string>();
@ -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;
}

View file

@ -180,6 +180,7 @@ export function runScopeResolution(
nodeLookup,
referenceIndex,
handledSites,
workspaceIndex,
);
const { emitted, skipped } = emitReferencesViaLookup(
graph,