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', () => {