From 8f5f7cf228919f408274bd7e06fc20b88d7f7be9 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 21 Apr 2026 16:47:06 +0100 Subject: [PATCH] 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. --- .../core/ingestion/languages/csharp/query.ts | 26 +++++++++++-- .../csharp/csharp-captures.test.ts | 38 ++++++++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index 61d52098e..32278f92e 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -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 diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts index f997b4b73..5e0292723 100644 --- a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts @@ -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', () => {