GitNexus/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts
rcarmel999 30e0c7c726
fix(csharp): include generic typed properties in context and impact (#1399)
* Fix C# context and impact for generic typed properties

* Address C# typed-property review feedback

---------

Co-authored-by: Richard Carmel <rcarmel@seitel.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 09:07:24 +01:00

468 lines
18 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');
});
it('captures member reads `obj.Name`', () => {
const m = findMatch('class A { void M(User obj) { var name = obj.Name; } }', (t) =>
t.includes('@reference.read.member'),
);
expect(m).toBeDefined();
expect(m!['@reference.receiver'].text).toBe('obj');
expect(m!['@reference.name'].text).toBe('Name');
});
it('does not capture member calls as member reads', () => {
const matches = emitCsharpScopeCaptures(
'class A { void M(User obj) { obj.Save(); } }',
'test.cs',
);
expect(matches.some((m) => '@reference.call.member' in m)).toBe(true);
expect(matches.some((m) => '@reference.read.member' in m)).toBe(false);
});
it('captures generic type arguments as type references', () => {
const matches = emitCsharpScopeCaptures(
'class A : IEntityTypeConfiguration<USER_INFO> { public Task<List<USER_INFO>> Load(List<USER_INFO> users) => null!; }',
'test.cs',
);
const names = matches
.filter((m) => '@reference.type' in m)
.map((m) => m['@reference.name'].text);
expect(names).toContain('USER_INFO');
expect(names).not.toContain('string');
});
it('captures call-site generic type arguments as type references', () => {
const matches = emitCsharpScopeCaptures(
'class A { void M(IRepo repo) { repo.Get<USER_INFO>(); } }',
'test.cs',
);
const names = matches
.filter((m) => '@reference.type' in m)
.map((m) => m['@reference.name'].text);
expect(names).toContain('USER_INFO');
});
});