From 9cec7d16a29fc9f69b9bceee20ee5e408d27ce60 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 21 Apr 2026 16:06:00 +0100 Subject: [PATCH] =?UTF-8?q?feat(csharp-scope):=20unit=201=20=E2=80=94=20sc?= =?UTF-8?q?ope=20query=20+=20captures=20orchestrator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the C# scope-resolution migration (issue #934, RFC #909 Ring 3). Closes `Unit 1` of docs/plans/2026-04-21-004-feat-csharp-scope-resolution-plan.md. Adds: - src/core/ingestion/languages/csharp/query.ts — tree-sitter scope query covering compilation_unit, namespace (block + file-scoped), class-like (class/interface/struct/record/enum), method-like (method/constructor/destructor/local_function/operator), property and field declarations, using directives, type bindings (parameter annotations, local variable annotations, constructor inference, invocation alias), and references (free call, member call including null-conditional, constructor call, member write). - src/core/ingestion/languages/csharp/captures.ts — pass-through orchestrator mirroring python/captures.ts. Import decomposition (Unit 2), receiver-type-binding synthesis (Unit 3), and arity metadata synthesis (Unit 5) stub out for future units. - src/core/ingestion/languages/csharp/cache-stats.ts — PROF instrumentation mirror of python/cache-stats.ts. Design notes: - Return-type / field-type / property-type captures deferred. tree-sitter-c-sharp does not expose these under a clean named field that pattern-matches. When Unit 7 parity gate surfaces a gap, add positional patterns or a post-hoc extractor lookup. - object_creation_expression with qualified_name type — the qualified name itself is the reference text; captured as a whole via a dedicated tag so interpretation in later units can split namespace + name. - Null-conditional calls use positional descendant patterns because tree-sitter-c-sharp's member_binding_expression and conditional_access_expression don't expose named fields. Coverage: - 23/23 new unit tests in test/unit/scope-resolution/csharp/csharp-captures.test.ts cover every capture tag. Confirmed against tree-sitter-c-sharp via the probe-script loop during development; grammar drift would surface as a capture-shape assertion failure. - tsc --noEmit clean. No changes to shared infrastructure. Resolver wiring + registration land in Unit 6. --- .../ingestion/languages/csharp/cache-stats.ts | 30 +++ .../ingestion/languages/csharp/captures.ts | 54 ++++ .../core/ingestion/languages/csharp/query.ts | 244 ++++++++++++++++++ .../csharp/csharp-captures.test.ts | 226 ++++++++++++++++ 4 files changed, 554 insertions(+) create mode 100644 gitnexus/src/core/ingestion/languages/csharp/cache-stats.ts create mode 100644 gitnexus/src/core/ingestion/languages/csharp/captures.ts create mode 100644 gitnexus/src/core/ingestion/languages/csharp/query.ts create mode 100644 gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts diff --git a/gitnexus/src/core/ingestion/languages/csharp/cache-stats.ts b/gitnexus/src/core/ingestion/languages/csharp/cache-stats.ts new file mode 100644 index 000000000..c3bf9f1f1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/cache-stats.ts @@ -0,0 +1,30 @@ +/** + * Dev-mode counters for the cross-phase scope-captures parse cache + * (C# mirror of `languages/python/cache-stats.ts`). + * + * Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every + * increment into dead code via the module-level `PROF` constant, so + * the hot path in `captures.ts` stays branch-free. + */ + +const PROF = process.env.PROF_SCOPE_RESOLUTION === '1'; + +let CACHE_HITS = 0; +let CACHE_MISSES = 0; + +export function recordCacheHit(): void { + if (PROF) CACHE_HITS++; +} + +export function recordCacheMiss(): void { + if (PROF) CACHE_MISSES++; +} + +export function getCsharpCaptureCacheStats(): { hits: number; misses: number } { + return { hits: CACHE_HITS, misses: CACHE_MISSES }; +} + +export function resetCsharpCaptureCacheStats(): void { + CACHE_HITS = 0; + CACHE_MISSES = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts new file mode 100644 index 000000000..12d4df513 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -0,0 +1,54 @@ +/** + * `emitScopeCaptures` for C#. + * + * Drives the C# scope query against tree-sitter-c-sharp and groups + * raw matches into `CaptureMatch[]` for the central extractor. + * + * Unit 1 shape: pure pass-through — each tree-sitter match becomes + * one grouped `CaptureMatch`. Import decomposition (Unit 2), + * receiver-type-binding synthesis (Unit 3), and arity metadata + * synthesis (Unit 5) layer on top later. + * + * Pure given the input source text. No I/O, no globals consulted. + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture } from '../../utils/ast-helpers.js'; +import { getCsharpParser, getCsharpScopeQuery } from './query.js'; +import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; + +export function emitCsharpScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + // Skip the parse when the caller (parse phase's scopeTreeCache) + // already produced a Tree for this source. Cache miss = re-parse, + // same as before. The cachedTree parameter is typed as `unknown` at + // the LanguageProvider contract layer; cast here at the use site. + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = getCsharpParser().parse(sourceText); + recordCacheMiss(); + } else { + recordCacheHit(); + } + + const rawMatches = getCsharpScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + + for (const m of rawMatches) { + // Group captures by their tag name. Tree-sitter strips the leading + // `@`; we put it back so the central extractor's prefix lookups + // (`@scope.`, `@declaration.`, …) work. + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + out.push(grouped); + } + + return out; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts new file mode 100644 index 000000000..61d52098e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -0,0 +1,244 @@ +/** + * Tree-sitter query for C# scope captures (RFC §5.1). + * + * Captures the structural skeleton the generic scope-resolution + * pipeline consumes: scopes (module/namespace/class/function), + * declarations (class-likes, method-likes, properties, variables, + * local functions), imports (using directives), type bindings + * (parameter annotations, variable annotations, constructor + * inference), and references (call sites, member writes). + * + * C# specifics that shape this query: + * + * - Both block-scoped (`namespace X { }`) and file-scoped + * (`namespace X;`) namespaces. tree-sitter-c-sharp emits them + * under distinct node types (`namespace_declaration` vs + * `file_scoped_namespace_declaration`); both map to + * `@scope.namespace` since the scope semantics are identical. + * - `partial class X` splits a Class def across files. Each file + * emits its own `@declaration.class`; cross-file resolution is + * handled at the graph-bridge layer via the qualified-name key. + * - `using X = Y;` aliases and `using static X;` are interpreted in + * `interpret.ts` via the `@import.*` captures. All three using + * flavors share the same anchor (`@import.statement`). + * - Explicit interface implementations (`void IFoo.Bar() { }`) + * expose the qualified name via the existing `@declaration.name` + * — the extractor's `csharpMethodConfig.extractQualifiedName` + * picks up the explicit qualifier from the method declaration + * node. + * + * Exposes lazy `Parser` and `Query` singletons so callers don't pay + * tree-sitter init cost per file. + */ + +import Parser from 'tree-sitter'; +import CSharp from 'tree-sitter-c-sharp'; + +const CSHARP_SCOPE_QUERY = ` +;; Scopes +(compilation_unit) @scope.module + +(namespace_declaration) @scope.namespace +(file_scoped_namespace_declaration) @scope.namespace + +(class_declaration) @scope.class +(interface_declaration) @scope.class +(struct_declaration) @scope.class +(record_declaration) @scope.class +(enum_declaration) @scope.class + +(method_declaration) @scope.function +(constructor_declaration) @scope.function +(destructor_declaration) @scope.function +(local_function_statement) @scope.function +(operator_declaration) @scope.function +;; Property accessors are blocks within a property; not scoped here. +;; Anonymous methods / lambdas are not scoped — out of scope per plan. + +;; Declarations — types +(class_declaration + name: (identifier) @declaration.name) @declaration.class + +(interface_declaration + name: (identifier) @declaration.name) @declaration.interface + +(struct_declaration + name: (identifier) @declaration.name) @declaration.struct + +(record_declaration + name: (identifier) @declaration.name) @declaration.record + +(enum_declaration + name: (identifier) @declaration.name) @declaration.enum + +;; Declarations — methods / constructors / properties +(method_declaration + name: (identifier) @declaration.name) @declaration.method + +(constructor_declaration + name: (identifier) @declaration.name) @declaration.constructor + +(destructor_declaration + name: (identifier) @declaration.name) @declaration.method + +(local_function_statement + name: (identifier) @declaration.name) @declaration.function + +(property_declaration + name: (identifier) @declaration.name) @declaration.property + +(indexer_declaration) @declaration.property + +;; Fields — \`int x;\` at class scope. variable_declarator inside +;; field_declaration carries the name. +(field_declaration + (variable_declaration + (variable_declarator + name: (identifier) @declaration.name))) @declaration.variable + +;; Local variables — \`int x = 1;\` inside a method body +(local_declaration_statement + (variable_declaration + (variable_declarator + name: (identifier) @declaration.name))) @declaration.variable + +;; Imports — single anchor per directive; interpretCsharpImport classifies +(using_directive) @import.statement + +;; Type bindings — parameter annotations: \`void F(User u)\` +(parameter + type: (identifier) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + +(parameter + type: (generic_name) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + +(parameter + type: (qualified_name) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + +(parameter + type: (nullable_type) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + +;; Type bindings — local variable annotations: \`User u = new User();\` +;; Typed local with identifier type + \`new X()\` initializer — shape +;; matters so \`u\` binds to \`X\` (the constructor call's type), not the +;; declared type alias (which is usually the same, but \`new DerivedUser()\` +;; would be distinct). +(local_declaration_statement + (variable_declaration + type: (identifier) @type-binding.type + (variable_declarator + name: (identifier) @type-binding.name))) @type-binding.annotation + +(local_declaration_statement + (variable_declaration + type: (generic_name) @type-binding.type + (variable_declarator + name: (identifier) @type-binding.name))) @type-binding.annotation + +(local_declaration_statement + (variable_declaration + type: (qualified_name) @type-binding.type + (variable_declarator + name: (identifier) @type-binding.name))) @type-binding.annotation + +;; Type bindings — \`var u = new User();\` — constructor-inferred. +;; Captures object_creation_expression's type as the binding type. +;; variable_declarator wraps the \`= \` directly; tree-sitter-c-sharp +;; does not surface an equals_value_clause wrapper here. +(local_declaration_statement + (variable_declaration + (variable_declarator + name: (identifier) @type-binding.name + (object_creation_expression + type: (identifier) @type-binding.type)))) @type-binding.constructor + +(local_declaration_statement + (variable_declaration + (variable_declarator + name: (identifier) @type-binding.name + (object_creation_expression + type: (generic_name) @type-binding.type)))) @type-binding.constructor + +(local_declaration_statement + (variable_declaration + (variable_declarator + name: (identifier) @type-binding.name + (object_creation_expression + type: (qualified_name) @type-binding.type)))) @type-binding.constructor + +;; Type bindings — \`var u = factory();\` alias (chain-follow picks up +;; factory's return type via propagateImportedReturnTypes) +(local_declaration_statement + (variable_declaration + (variable_declarator + name: (identifier) @type-binding.name + (invocation_expression + function: (identifier) @type-binding.type)))) @type-binding.alias + +;; Return-type captures on method_declaration / property_declaration / +;; field_declaration are deferred — tree-sitter-c-sharp does not expose +;; the return/field type under a simple named field that pattern-matches +;; cleanly. When Unit 7's parity gate surfaces a gap requiring these +;; bindings, revisit with a positional pattern or a post-hoc lookup via +;; csharpMethodConfig.extractReturnType / csharpFieldConfig.extractType. + +;; References — free calls: \`Foo()\` +(invocation_expression + function: (identifier) @reference.name) @reference.call.free + +;; References — member calls: \`obj.Method()\` +(invocation_expression + function: (member_access_expression + expression: (_) @reference.receiver + name: (identifier) @reference.name)) @reference.call.member + +;; References — null-conditional member calls: \`obj?.Method()\` +;; Positional descendants — conditional_access_expression wraps a +;; receiver followed by a member_binding_expression containing an +;; identifier. tree-sitter-c-sharp doesn't expose named fields here. +(invocation_expression + function: (conditional_access_expression + (member_binding_expression + (identifier) @reference.name))) @reference.call.member + +;; References — constructor calls: \`new User(...)\` +(object_creation_expression + type: (identifier) @reference.name) @reference.call.constructor + +(object_creation_expression + type: (generic_name + (identifier) @reference.name)) @reference.call.constructor + +(object_creation_expression + type: (qualified_name) @reference.call.constructor.qualified) @reference.call.constructor + +;; References — field/property writes: \`obj.Name = "x"\` emits a write +;; ACCESSES edge from the enclosing method to the field/property on +;; obj's class. +(assignment_expression + left: (member_access_expression + expression: (_) @reference.receiver + name: (identifier) @reference.name)) @reference.write.member +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getCsharpParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(CSharp as Parameters[0]); + } + return _parser; +} + +export function getCsharpScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(CSharp as Parameters[0], CSHARP_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts new file mode 100644 index 000000000..f997b4b73 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts @@ -0,0 +1,226 @@ +/** + * Unit 1 coverage for the C# scope query + captures orchestrator. + * + * Pins the capture-tag vocabulary + range shape for every construct + * the scope-resolution pipeline reads. Runs against tree-sitter-c-sharp + * so it catches grammar drift (node renames, field-name changes) + * before the integration parity gate does. + */ + +import { describe, it, expect } from 'vitest'; +import { emitCsharpScopeCaptures } from '../../../../src/core/ingestion/languages/csharp/captures.js'; + +function tagsFor(src: string): string[][] { + const matches = emitCsharpScopeCaptures(src, 'test.cs'); + return matches.map((m) => Object.keys(m).sort()); +} + +function findMatch(src: string, predicate: (tags: string[]) => boolean) { + const matches = emitCsharpScopeCaptures(src, 'test.cs'); + return matches.find((m) => predicate(Object.keys(m))); +} + +describe('emitCsharpScopeCaptures — scopes', () => { + it('captures the compilation unit as @scope.module', () => { + const all = tagsFor('class A { }'); + expect(all.some((t) => t.includes('@scope.module'))).toBe(true); + }); + + it('captures block-scoped namespaces as @scope.namespace', () => { + const all = tagsFor('namespace Foo.Bar { class A { } }'); + expect(all.some((t) => t.includes('@scope.namespace'))).toBe(true); + }); + + it('captures file-scoped namespaces as @scope.namespace', () => { + const all = tagsFor('namespace Foo.Bar;\nclass A { }'); + expect(all.some((t) => t.includes('@scope.namespace'))).toBe(true); + }); + + it('captures classes, interfaces, structs, records, enums as @scope.class', () => { + // All four class-like kinds collapse to @scope.class at the scope + // layer because they share the same scope semantics (body is a + // member-holding scope). Declaration tags distinguish them. + const src = ` + class A { } + interface B { } + struct C { } + record D(int x); + enum E { V1, V2 } + `; + const all = tagsFor(src); + const scopeClassCount = all.filter((t) => t.includes('@scope.class')).length; + expect(scopeClassCount).toBe(5); + }); + + it('captures methods, constructors, destructors, local functions as @scope.function', () => { + const src = ` + class A { + public A() { } + ~A() { } + public void M() { + void Local() { } + } + } + `; + const all = tagsFor(src); + const scopeFnCount = all.filter((t) => t.includes('@scope.function')).length; + expect(scopeFnCount).toBe(4); + }); +}); + +describe('emitCsharpScopeCaptures — declarations', () => { + it('captures class declarations with @declaration.class + @declaration.name', () => { + const m = findMatch('class User { }', (t) => t.includes('@declaration.class')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('User'); + }); + + it('captures interface declarations distinctly from class declarations', () => { + const m = findMatch('interface IUser { }', (t) => t.includes('@declaration.interface')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('IUser'); + }); + + it('captures struct, record, enum with their own declaration tags', () => { + expect(findMatch('struct Point { }', (t) => t.includes('@declaration.struct'))).toBeDefined(); + expect(findMatch('record R(int x);', (t) => t.includes('@declaration.record'))).toBeDefined(); + expect(findMatch('enum E { V }', (t) => t.includes('@declaration.enum'))).toBeDefined(); + }); + + it('captures method declarations with their name', () => { + const m = findMatch('class A { public void Save() { } }', (t) => + t.includes('@declaration.method'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Save'); + }); + + it('captures constructor declarations under @declaration.constructor', () => { + const m = findMatch('class A { public A() { } }', (t) => + t.includes('@declaration.constructor'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('A'); + }); + + it('captures property declarations', () => { + const m = findMatch('class A { public int Age { get; set; } }', (t) => + t.includes('@declaration.property'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Age'); + }); + + it('captures field declarations as @declaration.variable', () => { + const m = findMatch('class A { private int _x; }', (t) => t.includes('@declaration.variable')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('_x'); + }); + + it('captures local function declarations', () => { + const m = findMatch('class A { void M() { void Local() { } } }', (t) => + t.includes('@declaration.function'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Local'); + }); +}); + +describe('emitCsharpScopeCaptures — imports', () => { + it('captures each `using` directive as @import.statement', () => { + const src = ` + using System; + using System.Collections.Generic; + using Dict = System.Collections.Generic.Dictionary; + using static System.Math; + `; + const all = tagsFor(src); + const importCount = all.filter((t) => t.includes('@import.statement')).length; + expect(importCount).toBe(4); + }); +}); + +describe('emitCsharpScopeCaptures — type bindings', () => { + it('captures parameter annotations (object types)', () => { + // `int id` does NOT fire (predefined_type is not identifier) — + // only object-type parameters do. That's intentional: receiver- + // bound dispatch doesn't need primitives. + const m = findMatch('class A { void M(User u) { } }', (t) => + t.includes('@type-binding.parameter'), + ); + expect(m).toBeDefined(); + expect(m!['@type-binding.name'].text).toBe('u'); + expect(m!['@type-binding.type'].text).toBe('User'); + }); + + it('captures local variable annotations', () => { + const m = findMatch('class A { void M() { User u; } }', (t) => + t.includes('@type-binding.annotation'), + ); + expect(m).toBeDefined(); + expect(m!['@type-binding.name'].text).toBe('u'); + expect(m!['@type-binding.type'].text).toBe('User'); + }); + + it('captures constructor-inferred `var u = new User();`', () => { + const m = findMatch('class A { void M() { var u = new User(); } }', (t) => + t.includes('@type-binding.constructor'), + ); + expect(m).toBeDefined(); + expect(m!['@type-binding.name'].text).toBe('u'); + expect(m!['@type-binding.type'].text).toBe('User'); + }); + + it('captures alias `var u = Factory();`', () => { + const m = findMatch('class A { void M() { var u = Factory(); } }', (t) => + t.includes('@type-binding.alias'), + ); + expect(m).toBeDefined(); + expect(m!['@type-binding.name'].text).toBe('u'); + expect(m!['@type-binding.type'].text).toBe('Factory'); + }); +}); + +describe('emitCsharpScopeCaptures — references', () => { + it('captures free call invocations', () => { + const m = findMatch('class A { void M() { Foo(); } }', (t) => + t.includes('@reference.call.free'), + ); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('Foo'); + }); + + it('captures member call invocations with receiver + name', () => { + const m = findMatch('class A { void M() { obj.Save(); } }', (t) => + t.includes('@reference.call.member'), + ); + expect(m).toBeDefined(); + expect(m!['@reference.receiver'].text).toBe('obj'); + expect(m!['@reference.name'].text).toBe('Save'); + }); + + it('captures null-conditional member calls `obj?.Save()`', () => { + const m = findMatch('class A { void M(User obj) { obj?.Save(); } }', (t) => + t.includes('@reference.call.member'), + ); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('Save'); + }); + + it('captures object-creation expressions as constructor calls', () => { + const m = findMatch('class A { void M() { var u = new User(); } }', (t) => + t.includes('@reference.call.constructor'), + ); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('User'); + }); + + it('captures member writes `obj.Name = "x"`', () => { + const m = findMatch('class A { void M(User obj) { obj.Name = "x"; } }', (t) => + t.includes('@reference.write.member'), + ); + expect(m).toBeDefined(); + expect(m!['@reference.receiver'].text).toBe('obj'); + expect(m!['@reference.name'].text).toBe('Name'); + }); +});