diff --git a/gitnexus/src/core/ingestion/languages/csharp/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/csharp/arity-metadata.ts new file mode 100644 index 000000000..92b377352 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/arity-metadata.ts @@ -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, + }; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/arity.ts b/gitnexus/src/core/ingestion/languages/csharp/arity.ts new file mode 100644 index 000000000..b80dc602e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/arity.ts @@ -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'; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index 860cc8307..834214aae 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -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['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; +} diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts index 5e0292723..6dca1f6fb 100644 --- a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts @@ -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) => diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-hooks.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-hooks.test.ts index eeb81ba0a..92075895c 100644 --- a/gitnexus/test/unit/scope-resolution/csharp/csharp-hooks.test.ts +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-hooks.test.ts @@ -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 => + ({ 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;