fix(ruby): infer an instance variable's type from the constructor it calls

`@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) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-03 13:03:40 +00:00
parent 6c99f5488c
commit 55d370793d
2 changed files with 39 additions and 1 deletions

View file

@ -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

View file

@ -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;
}