feat(csharp-scope): Unit 5 — arity metadata synthesis + compatibility

Adversarial review flagged overload narrowing as a blocker for the Unit
7 flip. This lands the declaration-side metadata; callsite-side arity
synthesis is a separate gap we'll address if the parity gate surfaces
overload misresolution.

- `arity-metadata.ts` — reads `csharpMethodConfig.extractParameters`
  and produces `{ parameterCount, requiredParameterCount,
  parameterTypes }`. `params` variadic collapses parameterCount to
  undefined (matches Python's `*args` treatment) and appends a literal
  `'params'` marker to parameterTypes so the compatibility hook can
  detect it without re-reading the AST. Default-valued parameters
  contribute to optionalCount → requiredParameterCount = total − optional.
- `arity.ts` — `csharpArityCompatibility(def, callsite)` returns
  compatible / incompatible / unknown. Mirrors Python's three-verdict
  shape so the central registry's arity filter works without adapter
  logic per-verdict.
- `captures.ts` — on every @declaration.method / @declaration.constructor
  / @declaration.function match, synthesize
  @declaration.parameter-count, @declaration.required-parameter-count,
  and @declaration.parameter-types captures. Covers method_declaration,
  constructor_declaration, destructor_declaration, operator_declaration,
  conversion_operator_declaration, and local_function_statement.

12 new tests: 5 on captures-side synthesis (method + params + types +
variadic + constructor + local function), 7 on the compatibility hook.
66/66 C# scope-resolution unit tests pass; tsc clean.
This commit is contained in:
Gergo Magyar 2026-04-21 17:27:36 +01:00
parent 3c79058088
commit ac8b55ba2c
5 changed files with 275 additions and 1 deletions

View file

@ -0,0 +1,54 @@
/**
* Extract C# arity metadata from a method-like tree-sitter node —
* `method_declaration`, `constructor_declaration`, `destructor_declaration`,
* `operator_declaration`, `conversion_operator_declaration`, or
* `local_function_statement`.
*
* Reuses `csharpMethodConfig.extractParameters` so scope-extracted defs
* carry the same arity semantics as the legacy parse-worker path:
* - `params` variadic collapses `parameterCount` to `undefined`,
* which `csharpArityCompatibility` then treats as "max unknown" —
* the candidate stays eligible at `argCount >= required`.
* - Defaulted parameters (`= expr`) contribute to `optionalCount`;
* `requiredParameterCount = total − optionalCount`.
* - `parameterTypes` collects declared type names (with `ref`/`out`/
* `in` prefix) for overload narrowing; a literal `'params'` marker
* is appended for variadic methods so `csharpArityCompatibility`
* can detect them without re-reading the AST.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { csharpMethodConfig } from '../../method-extractors/configs/csharp.js';
interface CsharpArityMetadata {
readonly parameterCount: number | undefined;
readonly requiredParameterCount: number | undefined;
readonly parameterTypes: readonly string[] | undefined;
}
export function computeCsharpArityMetadata(fnNode: SyntaxNode): CsharpArityMetadata {
const params = csharpMethodConfig.extractParameters?.(fnNode) ?? [];
let hasVariadic = false;
let optionalCount = 0;
const types: string[] = [];
for (const p of params) {
if (p.isVariadic) hasVariadic = true;
else if (p.isOptional) optionalCount++;
if (p.type !== null) types.push(p.type);
}
if (hasVariadic) types.push('params');
const total = params.length;
// `params int[] args` declares one formal param but accepts any arg
// count ≥ required — mirror Python's treatment of `*args` and leave
// `parameterCount` undefined so the registry treats max as unknown.
const parameterCount = hasVariadic ? undefined : total;
const requiredParameterCount = hasVariadic ? undefined : total - optionalCount;
return {
parameterCount,
requiredParameterCount,
parameterTypes: types.length > 0 ? types : undefined,
};
}

View file

@ -0,0 +1,44 @@
/**
* C# arity check, accommodating `params` variadic and default parameters.
*
* The `def` metadata we care about (synthesized by `arity-metadata.ts`):
* - `parameterCount` — total formal parameters; `undefined`
* when the method has `params T[]` variadic.
* - `requiredParameterCount` — min required (excludes defaulted params
* and `params` variadic).
* - `parameterTypes` — declared type strings; contains the
* literal `'params'` when the method is
* variadic.
*
* Verdicts:
* - `'compatible'` — `requiredParameterCount <= argCount <= parameterCount`,
* OR the def takes `params` (then any `argCount >= required`).
* - `'incompatible'` — argCount is below required, OR above max with no variadic.
* - `'unknown'` — metadata is absent / incomplete.
*
* `'incompatible'` is a soft signal in `Registry.lookup` (penalized but
* still considered when no compatible candidate exists), per RFC §4.
*/
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
export function csharpArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
const argCount = callsite.arity;
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
const hasVarArgs =
def.parameterTypes !== undefined &&
def.parameterTypes.some((t) => t === 'params' || t.startsWith('params '));
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
return 'compatible';
}

View file

@ -17,11 +17,29 @@
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { findNodeAtRange, nodeToCapture } from '../../utils/ast-helpers.js';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitUsingDirective } from './import-decomposer.js';
import { computeCsharpArityMetadata } from './arity-metadata.js';
import { getCsharpParser, getCsharpScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = [
'@declaration.method',
'@declaration.constructor',
'@declaration.function',
] as const;
/** tree-sitter-c-sharp node types that the method extractor accepts. */
const FUNCTION_NODE_TYPES = [
'method_declaration',
'constructor_declaration',
'destructor_declaration',
'operator_declaration',
'conversion_operator_declaration',
'local_function_statement',
] as const;
export function emitCsharpScopeCaptures(
sourceText: string,
_filePath: string,
@ -72,8 +90,55 @@ export function emitCsharpScopeCaptures(
continue;
}
// Synthesize arity metadata on function-like declarations so the
// registry can narrow overloads (C# relies heavily on this). Mirrors
// Python's captures.ts pattern — one anchor per match, so we find
// the first tag that matches.
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
if (declTag !== undefined) {
const anchor = grouped[declTag]!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
const arity = computeCsharpArityMetadata(fnNode);
if (arity.parameterCount !== undefined) {
grouped['@declaration.parameter-count'] = syntheticCapture(
'@declaration.parameter-count',
fnNode,
String(arity.parameterCount),
);
}
if (arity.requiredParameterCount !== undefined) {
grouped['@declaration.required-parameter-count'] = syntheticCapture(
'@declaration.required-parameter-count',
fnNode,
String(arity.requiredParameterCount),
);
}
if (arity.parameterTypes !== undefined) {
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
}
}
out.push(grouped);
}
return out;
}
type SyntaxNode = ReturnType<ReturnType<typeof getCsharpParser>['parse']>['rootNode'];
/** Find the first C# function-like node at the given range. The
* declaration anchor range covers the whole method/constructor/etc.
* node, but the tag alone doesn't tell us which node type. */
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
for (const nodeType of FUNCTION_NODE_TYPES) {
const n = findNodeAtRange(rootNode, range, nodeType);
if (n !== null) return n as SyntaxNode;
}
return null;
}

View file

@ -213,6 +213,59 @@ describe('emitCsharpScopeCaptures — type bindings', () => {
});
});
describe('emitCsharpScopeCaptures — arity metadata synthesis', () => {
it('synthesizes parameter-count + required-parameter-count on method declarations', () => {
const m = findMatch(
'class A { public void M(int a, int b = 1) { } }',
(t) =>
t.includes('@declaration.method') &&
t.includes('@declaration.parameter-count') &&
t.includes('@declaration.required-parameter-count'),
);
expect(m).toBeDefined();
expect(m!['@declaration.parameter-count'].text).toBe('2');
expect(m!['@declaration.required-parameter-count'].text).toBe('1');
});
it('synthesizes parameter-types on method declarations', () => {
const m = findMatch(
'class A { public void M(User u, int n) { } }',
(t) => t.includes('@declaration.method') && t.includes('@declaration.parameter-types'),
);
expect(m).toBeDefined();
const types = JSON.parse(m!['@declaration.parameter-types'].text);
expect(types).toEqual(['User', 'int']);
});
it('leaves parameter-count undefined for `params` variadic methods', () => {
const m = findMatch('class A { public void M(params int[] xs) { } }', (t) =>
t.includes('@declaration.method'),
);
expect(m).toBeDefined();
expect(m!['@declaration.parameter-count']).toBeUndefined();
expect(m!['@declaration.required-parameter-count']).toBeUndefined();
const types = JSON.parse(m!['@declaration.parameter-types'].text);
expect(types).toContain('params');
});
it('synthesizes arity on constructor declarations', () => {
const m = findMatch('class A { public A(int a, int b) { } }', (t) =>
t.includes('@declaration.constructor'),
);
expect(m).toBeDefined();
expect(m!['@declaration.parameter-count'].text).toBe('2');
expect(m!['@declaration.required-parameter-count'].text).toBe('2');
});
it('synthesizes arity on local function declarations', () => {
const m = findMatch('class A { void M() { void Local(int x) { } } }', (t) =>
t.includes('@declaration.function'),
);
expect(m).toBeDefined();
expect(m!['@declaration.parameter-count'].text).toBe('1');
});
});
describe('emitCsharpScopeCaptures — references', () => {
it('captures free call invocations', () => {
const m = findMatch('class A { void M() { Foo(); } }', (t) =>

View file

@ -17,8 +17,10 @@ import {
csharpReceiverBinding,
} from '../../../../src/core/ingestion/languages/csharp/simple-hooks.js';
import { csharpMergeBindings } from '../../../../src/core/ingestion/languages/csharp/merge-bindings.js';
import { csharpArityCompatibility } from '../../../../src/core/ingestion/languages/csharp/arity.js';
import type {
BindingRef,
Callsite,
CaptureMatch,
ParsedImport,
Scope,
@ -132,6 +134,62 @@ describe('csharpMergeBindings — shadowing precedence', () => {
});
});
describe('csharpArityCompatibility', () => {
const callsite = (arity: number): Callsite => ({ arity });
const def = (o: Partial<SymbolDefinition> = {}): SymbolDefinition =>
({ nodeId: 'd1', filePath: 't.cs', type: 'Function', ...o }) as SymbolDefinition;
it('unknown when both parameter counts are missing', () => {
expect(csharpArityCompatibility(def(), callsite(2))).toBe('unknown');
});
it('compatible inside [required, total]', () => {
expect(
csharpArityCompatibility(def({ parameterCount: 3, requiredParameterCount: 1 }), callsite(2)),
).toBe('compatible');
});
it('incompatible below required', () => {
expect(
csharpArityCompatibility(def({ parameterCount: 3, requiredParameterCount: 2 }), callsite(1)),
).toBe('incompatible');
});
it('incompatible above max without variadic', () => {
expect(
csharpArityCompatibility(def({ parameterCount: 2, requiredParameterCount: 0 }), callsite(5)),
).toBe('incompatible');
});
it('compatible above declared params when def has `params` variadic', () => {
expect(
csharpArityCompatibility(
def({ parameterCount: undefined, requiredParameterCount: 0, parameterTypes: ['params'] }),
callsite(7),
),
).toBe('compatible');
});
it('compatible above declared params when variadic token prefixes', () => {
expect(
csharpArityCompatibility(
def({
parameterCount: undefined,
requiredParameterCount: 1,
parameterTypes: ['string', 'params int[]'],
}),
callsite(4),
),
).toBe('compatible');
});
it('unknown for negative arity (defensive)', () => {
expect(
csharpArityCompatibility(def({ parameterCount: 3, requiredParameterCount: 1 }), callsite(-1)),
).toBe('unknown');
});
});
describe('csharpReceiverBinding', () => {
it('returns the `this` type binding for an instance method scope', () => {
const binding: TypeRef = { rawName: 'User', source: 'self' } as unknown as TypeRef;