From 55d370793d54f48c7383b061ac0a2629d7db7c2b Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 3 Aug 2026 13:03:40 +0000 Subject: [PATCH] fix(ruby): infer an instance variable's type from the constructor it calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@service = UserService.new` in `initialize` bound nothing, so `@service.inner` had no receiver type and the fold declined the whole chain — the Ruby half of #2807. An instance variable is the ONLY way a Ruby object gets a field, and Ruby has no annotations, so this was the single shape that could have worked and did not: the existing constructor-inferred patterns bind a local (`x = Foo.new`) and a constant (`SERVICE = Foo.new`), never an ivar. Adds the plain and `Foo::Bar` qualified ivar forms. `@type-binding.name` is captured on the `instance_variable` node so the bound name keeps its `@` sigil and matches the receiver text at the call site verbatim — the resolver compares spellings, and `service` would never have matched `@service`. `rubyBindingScopeFor` gains a Class hoist gated on a narrow `@type-binding.ivar-field` marker riding the same node: an ivar declares a field of the enclosing class, so the binding must live on the Class scope or no other method can see it. Gated on the dedicated marker, never on `@type-binding.constructor` at large, which also fires for `x = Foo.new` locals that must stay in their own method. Measured: `AssignedField.run` now emits BOTH chain links, exact parity with the local-const control. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) --- .../core/ingestion/languages/ruby/query.ts | 26 +++++++++++++++++++ .../ingestion/languages/ruby/simple-hooks.ts | 14 +++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/ingestion/languages/ruby/query.ts b/gitnexus/src/core/ingestion/languages/ruby/query.ts index 4e9494c41..f371448b8 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/query.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/query.ts @@ -178,6 +178,32 @@ const RUBY_SCOPE_QUERY = ` method: (identifier) @_new_method2 (#eq? @_new_method2 "new"))) @type-binding.constructor +;; Instance-variable constructor: \`@service = UserService.new\` (#2807). +;; The patterns above bind locals and constants; an instance variable — the +;; only way a Ruby object gets a field at all — bound nothing, so \`@service.run\` +;; had no receiver type and the fold declined the whole chain. +;; +;; \`@type-binding.name\` is captured on the \`instance_variable\` node, so the +;; bound name keeps its \`@\` sigil and matches the receiver text at the call +;; site verbatim. The narrow \`@type-binding.ivar-field\` marker rides the same +;; node for \`rubyBindingScopeFor\` to hoist on; anchorCaptureFor takes the +;; broadest range, so the assignment stays the anchor and the source stays +;; \`constructor-inferred\`. + +(assignment + left: (instance_variable) @type-binding.name @type-binding.ivar-field + right: (call + receiver: (constant) @type-binding.type + method: (identifier) @_new_ivar + (#eq? @_new_ivar "new"))) @type-binding.constructor + +(assignment + left: (instance_variable) @type-binding.name @type-binding.ivar-field + right: (call + receiver: (scope_resolution) @type-binding.type + method: (identifier) @_new_ivar_q + (#eq? @_new_ivar_q "new"))) @type-binding.constructor + ;; Constant constructor: SERVICE = UserService.new (left is constant, not identifier) (assignment diff --git a/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts index b09bd2616..5f6935587 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts @@ -8,17 +8,29 @@ import type { NodeLabel, } from 'gitnexus-shared'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { walkToScope } from '../typescript/simple-hooks.js'; export function rubyBindingScopeFor( decl: CaptureMatch, innermost: Scope, - _tree: ScopeTree, + tree: ScopeTree, ): ScopeId | null { // Keep self typeBindings in the method's Function scope so // populateClassOwnedMembers can match Method defs to their receiver types. if (decl['@type-binding.self'] !== undefined) { return innermost.id; } + // `@ivar = Foo.new` in `initialize` (or any method) declares a FIELD of the + // enclosing class, so its type binding belongs on the Class scope — the only + // place `typeOfMemberOnClass` reads it. Left on the method's own Function + // scope it would be invisible to every other method (#2807). + // + // Gated on the marker that pattern emits, never on `@type-binding.constructor` + // at large: that capture also fires for `x = Foo.new` locals, and hoisting + // those to the class would leak a method local into every sibling method. + if (decl['@type-binding.ivar-field'] !== undefined) { + return walkToScope(innermost, tree, 'Class'); + } return null; }