feat(csharp-scope): unit 1 — scope query + captures orchestrator

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.
This commit is contained in:
Gergo Magyar 2026-04-21 16:06:00 +01:00
parent ff4ae89aaa
commit 9cec7d16a2
4 changed files with 554 additions and 0 deletions

View file

@ -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;
}

View file

@ -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<ReturnType<typeof getCsharpParser>['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<string, Capture> = {};
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;
}

View file

@ -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 \`= <expr>\` 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<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getCsharpScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(CSharp as Parameters<Parser['setLanguage']>[0], CSHARP_SCOPE_QUERY);
}
return _query;
}

View file

@ -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<string, int>;
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');
});
});