fix(csharp-scope): capture null-conditional receiver + operator decls

Adversarial review surfaced two Unit 1 bugs that would silently
corrupt the graph once C# is flipped on the scope-resolution path:

- `obj?.Save()` only emitted @reference.name, so receiver-bound
  resolution downgraded to the free-call fallback and could mis-link
  to an imported `Save`. Capture the conditional_access_expression
  receiver under @reference.receiver.
- `operator_declaration` had @scope.function but no @declaration.method
  owner, so calls inside operator bodies were attributed to the
  enclosing class and the operator itself disappeared from method
  lookup. Capture the operator token as @declaration.name (downstream
  csharpMethodConfig normalizes to op_Addition etc.).
- `conversion_operator_declaration` was missing from both scope and
  declaration sets. Added with the target type as the name anchor.

Arity metadata for overload resolution remains deferred to Unit 5 and
gated behind Unit 7's parity flip, as documented in captures.ts.
This commit is contained in:
Gergo Magyar 2026-04-21 16:47:06 +01:00
parent 9cec7d16a2
commit 8f5f7cf228
2 changed files with 60 additions and 4 deletions

View file

@ -52,6 +52,7 @@ const CSHARP_SCOPE_QUERY = `
(destructor_declaration) @scope.function
(local_function_statement) @scope.function
(operator_declaration) @scope.function
(conversion_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.
@ -84,6 +85,21 @@ const CSHARP_SCOPE_QUERY = `
(local_function_statement
name: (identifier) @declaration.name) @declaration.function
;; Operator declarations \`public static T operator +(T a, T b)\`.
;; tree-sitter-c-sharp exposes the operator token under the \`operator:\`
;; field (an anonymous node like \`+\`, \`-\`, \`==\`). Capture the whole
;; node under @declaration.name so the extractor reads the operator
;; symbol as the declared name; downstream csharpMethodConfig can
;; normalize it (e.g. to \`op_Addition\`) when it runs.
(operator_declaration
operator: _ @declaration.name) @declaration.method
;; Conversion operators \`public static explicit operator int(T x)\`.
;; No operator token; the target type (\`int\`) identifies the conversion
;; and serves as the name anchor.
(conversion_operator_declaration
type: _ @declaration.name) @declaration.method
(property_declaration
name: (identifier) @declaration.name) @declaration.property
@ -197,11 +213,15 @@ const CSHARP_SCOPE_QUERY = `
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.
;; conditional_access_expression wraps a receiver followed by a
;; member_binding_expression. Capture the receiver explicitly so
;; receiver-bound resolution doesn't silently downgrade the call to
;; a free-call (which would misresolve to an imported \`Save\`).
;; tree-sitter-c-sharp doesn't expose named fields here, so use
;; positional wildcards.
(invocation_expression
function: (conditional_access_expression
(_) @reference.receiver
(member_binding_expression
(identifier) @reference.name))) @reference.call.member

View file

@ -117,6 +117,38 @@ describe('emitCsharpScopeCaptures — declarations', () => {
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'),
@ -199,12 +231,16 @@ describe('emitCsharpScopeCaptures — references', () => {
expect(m!['@reference.name'].text).toBe('Save');
});
it('captures null-conditional member calls `obj?.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', () => {