GitNexus/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts
Gergő Magyar 98ee665889
Some checks are pending
CI / quality (push) Waiting to run
CI / tests (push) Waiting to run
CI / e2e (push) Waiting to run
CI / scope-parity (push) Waiting to run
CI / Save PR Metadata (push) Blocked by required conditions
CI / CI Gate (push) Blocked by required conditions
Release Candidate / Check if release candidate should run (push) Waiting to run
Release Candidate / ci (push) Blocked by required conditions
Release Candidate / Publish release candidate to npm (push) Blocked by required conditions
Release Candidate / Build & Push RC Docker images (push) Blocked by required conditions
fix(ingestion): two-channel binding lifecycle (closes #1066) + scope-resolution I8 hardening (#1082)
* fix(csharp): adaptive tree-sitter buffer + frozen-bucket clone for cross-namespace siblings (#1066)

Two coupled regressions surfaced when analyzing real-world C# repos with
large source files (issue #1066):

1. Tree-sitter `parser.parse()` is hard-coded to a 32 KB buffer by
   default. Any file exceeding that threshold throws `Invalid argument`
   on the worker re-parse path of `populateCsharpNamespaceSiblings`
   (and the analogous Python / TypeScript captures fallbacks).
2. After the buffer fix unblocks the AST walk, the hook tries to
   `push()` onto the inner `BindingRef[]` array fetched from
   `indexes.bindings` — but `materializeBindings` froze that array via
   `Object.freeze(refs.slice())`. Result: `Cannot add property N,
   object is not extensible`.

Fixes:

- `csharp/captures.ts`, `python/captures.ts`, `typescript/captures.ts`:
  pass `bufferSize: getTreeSitterBufferSize(sourceText.length)` to
  `parser.parse()` on the cache-miss path so multi-MB files parse.
- `csharp/namespace-siblings.ts`: introduce `cloneBindingBucket` to
  copy the frozen array before mutating, then `set()` the new array
  back. This is a working but architecturally compromised workaround
  (#1050 follow-up will replace it with an explicit augmentation
  channel — see docs/plans/2026-04-26-001 plan).

Tests:

- New `csharp-large-cache-miss-resolution` fixture (Models/Services/
  Other layout, ~77 KB padded UserService.cs) drives the buffer-size
  failure end-to-end through worker mode.
- `csharp.test.ts`: 4 new regression assertions covering both the
  parse-time buffer-size failure and the freeze workaround.
- Per-language captures unit tests gain "large cache-miss file uses
  adaptive buffer" coverage (TS, Python, C#).
- `csharp-hooks.test.ts`: in-memory freeze regression test that
  reproduces the `Cannot add property` crash without invoking the C#
  parser at all.

Made-with: Cursor

* refactor(scope-resolution): add bindingAugmentations channel to indexes

Step 1 of the binding-augmentation-channel refactor (issue #1066
follow-up). Pure shape change — no consumers yet.

Adds a new `readonly bindingAugmentations` field to
`ScopeResolutionIndexes` initialized as an empty `Map` by
`finalizeScopeModel`. The new channel is the dedicated post-finalize
write target for hooks like `populateCsharpNamespaceSiblings`, so
`indexes.bindings` can stay frozen and finalize-owned.

Behavior unchanged: nothing reads or writes the new field yet. tsc and
the full unit suite remain green.

Plan: docs/plans/2026-04-26-001-binding-augmentation-channel.md (local
only — `docs/plans/` is gitignored).

Made-with: Cursor

* feat(scope-resolution): add lookupBindingsAt dual-source helper

Step 2 of the binding-augmentation-channel refactor. Introduces a
single primitive every walker uses to read both the finalize-owned
`indexes.bindings` channel and the post-finalize
`indexes.bindingAugmentations` channel.

Contract:
- Finalized refs come first (preserves existing precedence).
- Augmented refs append, deduped by `def.nodeId`.
- Empty input on both channels returns a shared frozen empty array.
- Single-channel hits return the bucket by reference (no allocation).

No consumers are wired yet — Step 3 routes the existing walker
primitives through this helper. Augmentations remain empty for every
language; behavior of the full suite is unchanged.

8 unit tests pin precedence, dedup, identity for single-channel hits,
and the shared-empty-frozen-array sentinel.

Made-with: Cursor

* refactor(scope-resolution): route binding lookups through lookupBindingsAt

Step 3 of the binding-augmentation-channel refactor. Every direct
`indexes.bindings.get(...)` consumer in the post-finalize phase is
now routed through `lookupBindingsAt` (per-name) or `namesAtScope`
+ `lookupBindingsAt` (bulk iteration).

Routed sites:
- `findClassBindingInScope` (walkers.ts) — class-receiver lookups.
- `findCallableBindingInScope` (walkers.ts) — free-call lookups.
- `findExportedDefByName` (walkers.ts) — module-scope-fallback
  callable lookups.
- `propagateImportedReturnTypes` (passes/imported-return-types.ts)
  — bulk iteration over an importer's binding entries; switched to
  `namesAtScope` + per-name `lookupBindingsAt` so post-finalize
  augmentations are visible to import-derived typeBinding mirrors.

Behavior unchanged: augmentations are empty across the suite (Step 4
populates them for C# `populateNamespaceSiblings`). 587
scope-resolution unit tests + 50 integration resolver suites green
(4 pre-existing Swift method-implements failures unrelated to this
work).

Adds `namesAtScope` companion helper for the bulk-iteration callers.

Made-with: Cursor

* refactor(csharp): write namespace siblings to bindingAugmentations channel

Step 4 of the binding-augmentation-channel refactor. The C#
`populateNamespaceSiblings` hook is the only consumer that needed
to inject cross-file bindings post-finalize, and prior to this
change it cloned the (frozen) finalized `BindingRef[]` arrays
through a `cloneBindingBucket` helper, then `set()`-back the new
array — a workaround for the `Object.freeze` applied by
`finalize-algorithm.ts` (issue #1066 root cause).

Architecturally that violated `ScopeResolver` Invariant I8 (which
permits post-finalize modifications but not in-place mutation of
finalized buckets). It also forced read-side consumers to be aware
of the workaround.

This change:
* Switches the three C# write sites to append into
  `indexes.bindingAugmentations` via `getAugmentationBucket`. The
  augmentation channel was added in Step 1 and is mutable by
  contract: inner `BindingRef[]` arrays here are NEVER frozen.
* Deletes `cloneBindingBucket` and `getMutableScopeBindings`
  (workaround helpers no longer needed).
* `lookupBindingsAt` (Step 2) merges the two channels transparently
  for every walker (Step 3), so behavior is unchanged for callers.
* Updates the unit test to assert against both channels: finalized
  bucket stays frozen and untouched, cross-file siblings show up in
  augmentations only. Renamed the test accordingly.

Validation:
* `npx tsc --noEmit` clean.
* csharp hooks unit + walkers-augmentations unit + csharp integration
  resolver suite all green (236/236).
* Wider `test/unit/scope-resolution test/integration/resolvers`
  suite: 2507 pass, only 4 pre-existing Swift METHOD_IMPLEMENTS
  failures remain (unrelated to this work, present on baseline).

Refs: issue #1066, ADR-pending binding-augmentation-channel.
Made-with: Cursor

* feat(scope-resolution): tighten I8 + add validateBindingsImmutability dev guard

Step 5 of the binding-augmentation-channel refactor. Captures the
new two-channel binding lifecycle in the contract docs and adds a
dev-mode runtime validator so a future hook cannot silently drift
back into mutating `indexes.bindings`.

Contract changes:
* `contract/scope-resolver.ts` — rewrote Invariant I8 to describe
  the two channels (`indexes.bindings` is finalize-output and
  immutable post-finalize; `indexes.bindingAugmentations` is the
  append-only post-finalize channel populated by hooks like
  `populateNamespaceSiblings`). Documented `lookupBindingsAt` as
  the read-side merger and pointed at the new validator as the
  enforcement mechanism.
* `gitnexus-shared/src/scope-resolution/types.ts` — extended the
  module-header lifecycle contract to call out
  `bindingAugmentations` alongside `ReferenceIndex` as the two
  structures populated after the freeze.

Validator:
* New `pipeline/validate-bindings-immutability.ts` mirrors the
  shape of `validateOwnershipParity` (#909): runs only when
  `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`,
  emits via `onWarn`, never throws. Asserts (a) every inner
  `BindingRef[]` in `indexes.bindings` is `Object.isFrozen`, and
  (b) every inner array in `indexes.bindingAugmentations` is NOT
  frozen.
* Wired into `pipeline/run.ts` after both
  `populateNamespaceSiblings` and `propagateImportedReturnTypes`,
  before `resolveReferenceSites`. One sweep covers the full
  post-finalize surface.

Tests:
* `validate-bindings-immutability.test.ts` — 6 cases pinning happy
  path, both drift directions, multi-violation accumulation, and
  both production no-op gates.

All scope-resolution + csharp resolver tests green (242/242 in the
focused run; matches the wider Step 4 baseline).

Made-with: Cursor

* fix(ingestion): size tree-sitter buffers from UTF-8 bytes

Tree-sitter buffer sizing is byte-based, so computing adaptive buffers from JavaScript string length under-sized UTF-8-heavy files. Make getTreeSitterBufferSize accept source text directly and compute Buffer.byteLength internally, then update all parse call sites and max-buffer skip checks to use byte length.

Add multibyte cache-miss and cap regressions for C#, Python, TypeScript, and the C# namespace-sibling fallback parse path.

Made-with: Cursor

* test(scope-resolution): pin augmentation read paths

Add focused unit coverage for augmented-only binding reads across the routed walker helpers and imported-return-type propagation path. Clarify I8 wording around lexical Scope.bindings versus post-finalize index channels, and document the intentional local-only behavior of findExportedDef.

Also switch the immutability validator tests to Vitest env stubs, document one intentional validator blind spot, and split C# namespace-sibling tests so UTF-8 parsing and augmentation-channel behavior are asserted independently.

Made-with: Cursor

* test(scope-resolution): avoid slow parser stress fixtures

Replace high-cardinality large-file capture fixtures with large padding plus a trailing declaration. This still proves adaptive tree-sitter buffers parse beyond large ASCII and UTF-8-heavy input, without making query matching process thousands of declarations and risking timeouts.

Made-with: Cursor

* test(scope-resolution): add python and typescript cache-miss resolver regressions

Add worker-mode resolver integration coverage mirroring the C# #1066 scenario for Python and TypeScript. Each test builds a temp fixture with large ASCII and UTF-8-heavy source padding, then asserts trailing declarations and call edges still resolve after scope-resolution cache-miss reparsing.

Made-with: Cursor

* refactor(scope-resolution): gate I8 validator and fast-path namesAtScope

Addresses SPARC reviewer feedback on the binding-augmentation channel:

- Validator gate is now opt-in outside development. Extract
  isSemanticModelValidatorEnabled() in utils/env.ts as the single
  predicate; both validateBindingsImmutability and phase.ts's warn
  handler share it. Default CLI runs no longer pay the O(binding-buckets)
  scan, and explicit VALIDATE_SEMANTIC_MODEL=1 now emits warnings even
  when NODE_ENV is unset.
- namesAtScope returns Iterable<string> and zero-allocates when at most
  one channel is populated (returns Map.keys() directly), only
  materializing a Set when both channels carry names. The caller-side
  branching and EMPTY_NAMES escape hatch in propagateImportedReturnTypes
  are gone -- both helpers handle the empty-augmentation case internally.
- C# namespace-siblings header/JSDoc, model JSDoc, I8 contract prose, and
  the #1066 integration-test header rewritten to say post-finalize fanout
  appends only to bindingAugmentations; finalized refs come first and win
  duplicate def.nodeId metadata; local lexical Scope.bindings remains the
  first-tier shadowing channel.

Validator unit-test setup deduplicated via beforeEach and extended with
default-CLI no-op + explicit-opt-in cases.

Made-with: Cursor
2026-04-26 12:16:09 +01:00

425 lines
16 KiB
TypeScript

/**
* 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('parses large cache-miss files with the adaptive tree-sitter buffer', () => {
const padding = 'x'.repeat(600 * 1024);
const match = findMatch(
`namespace Large;\n// ${padding}\nclass Big { public void AfterPadding() { } }`,
(t) => t.includes('@declaration.method'),
);
expect(match).toBeDefined();
expect(match!['@declaration.name'].text).toBe('AfterPadding');
});
it('parses UTF-8-heavy cache-miss files with a byte-sized buffer', () => {
const padding = '漢'.repeat(190_000);
const match = findMatch(
`namespace Large;\n// ${padding}\nclass Big { public void AfterPadding() { } }`,
(t) => t.includes('@declaration.method'),
);
expect(match).toBeDefined();
expect(match!['@declaration.name'].text).toBe('AfterPadding');
});
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 operator declarations as @declaration.method with the operator token as name', () => {
// Caller attribution walks ownedDefs looking for method owners.
// Without this, calls inside `operator +` bodies get attributed to
// the enclosing class instead of the operator.
const m = findMatch(
'class T { public static T operator +(T a, T b) { return a; } }',
(t) => t.includes('@declaration.method') && !t.includes('@scope.class'),
);
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('+');
});
it('captures conversion operator declarations with the target type as name', () => {
const m = findMatch('class T { public static explicit operator int(T x) { return 0; } }', (t) =>
t.includes('@declaration.method'),
);
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('int');
});
it('captures operator + conversion-operator as @scope.function', () => {
const src = `
class T {
public static T operator +(T a, T b) { return a; }
public static explicit operator int(T x) { return 0; }
}
`;
const all = tagsFor(src);
const fnScopes = all.filter((t) => t.includes('@scope.function')).length;
expect(fnScopes).toBe(2);
});
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 — 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 — receiver-binding synthesis (`this` / `base`)', () => {
it('emits `this` for an instance method inside a class', () => {
const m = findMatch('class User { public void M() { } }', (t) =>
t.includes('@type-binding.self'),
);
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('this');
expect(m!['@type-binding.type'].text).toBe('User');
});
it('emits both `this` and `base` when the class has a base class', () => {
const matches = emitCsharpScopeCaptures(
'class User : BaseModel { public void M() { base.Save(); } }',
'test.cs',
);
const receiverMatches = matches.filter((m) => '@type-binding.self' in m);
const names = receiverMatches.map((m) => m['@type-binding.name'].text).sort();
expect(names).toEqual(['base', 'this']);
const baseMatch = receiverMatches.find((m) => m['@type-binding.name'].text === 'base');
expect(baseMatch!['@type-binding.type'].text).toBe('BaseModel');
});
it('does not emit `this` or `base` for static methods', () => {
const matches = emitCsharpScopeCaptures('class User { public static void M() { } }', 'test.cs');
const receiverMatches = matches.filter((m) => '@type-binding.self' in m);
expect(receiverMatches).toHaveLength(0);
});
it('does not emit `base` for structs (they cannot inherit classes)', () => {
const matches = emitCsharpScopeCaptures('struct Point { public void M() { } }', 'test.cs');
const names = matches
.filter((m) => '@type-binding.self' in m)
.map((m) => m['@type-binding.name'].text);
expect(names).toEqual(['this']);
});
it('does not emit `base` for interface methods', () => {
const matches = emitCsharpScopeCaptures('interface IFoo { void M() { } }', 'test.cs');
const names = matches
.filter((m) => '@type-binding.self' in m)
.map((m) => m['@type-binding.name'].text);
expect(names).toEqual(['this']);
});
it('does not emit receiver bindings for free local functions (no enclosing type)', () => {
// Local functions inside a method still have `this` from the
// enclosing class — that's a normal method + local combination.
// Test the pure free case: a local function at namespace level is
// not legal C#, so we exercise the adjacent "top-level statement"
// variant: a method inside a class works fine, but the local
// function *inside* that method also sees `this` from the class.
// This test confirms synthesis doesn't produce duplicate bindings.
const matches = emitCsharpScopeCaptures(
'class User { public void M() { void Local() { } } }',
'test.cs',
);
const thisMatches = matches.filter(
(m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'this',
);
// Expect two: one for M() and one for Local() — both see `this`
// from the enclosing User class.
expect(thisMatches).toHaveLength(2);
for (const tm of thisMatches) {
expect(tm['@type-binding.type'].text).toBe('User');
}
});
it('emits `this` on constructors with the enclosing class name', () => {
const matches = emitCsharpScopeCaptures('class User { public User() { } }', 'test.cs');
const thisMatch = matches.find(
(m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'this',
);
expect(thisMatch).toBeDefined();
expect(thisMatch!['@type-binding.type'].text).toBe('User');
});
it('emits `this` with innermost type for nested class methods', () => {
const matches = emitCsharpScopeCaptures(
'class Outer { class Inner { public void M() { } } }',
'test.cs',
);
const thisMatches = matches.filter(
(m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'this',
);
// M is the only instance method; its `this` binds to Inner.
expect(thisMatches).toHaveLength(1);
expect(thisMatches[0]['@type-binding.type'].text).toBe('Inner');
});
});
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()` with a receiver', () => {
// Regression guard: without the receiver capture, receiver-bound
// resolution downgrades to free-call fallback and can mis-link to
// an imported `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');
expect(m!['@reference.receiver'].text).toBe('obj');
});
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');
});
});