Skip to content

fix(ruby): a call on an instance variable links the class initialize gives it - #3053

Merged
colbymchenry merged 1 commit into
mainfrom
claude/fix-ruby-ivar-receivers
Oct 30, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/fix-ruby-ivar-receivers

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

In Ruby, a call on an instance variable, like @writer.flush, now links to the method of the class that the class's initialize gives the variable: @writer = CacheWriter.new(…) makes it a CacheWriter. Before, no call on an instance variable linked at all. On main every such call stays unresolved, for example 2,780 of them in activesupport and 3,239 in actionpack.

T5's triage of #684 found this gap; there is no issue or contributor PR for it. It builds on #3049's receiver code.

Root cause

Both extractors record such a call as @writer.flush. No strategy reads an instance variable's type. matchMethodCall's receiver pattern doesn't even admit the @, so the call ended as a failed reference.

Fix

ruby-constant-paths.ts resolves @name.m the same way #3049 resolves a call on what .new made:

  • Where it applies: the call must be in an instance method (not def self.x, not class << self) of a class. A module's methods run on whatever includes it, so they are left out.
  • Reading initialize: every assignment to @name in that class's initialize (= or ||=) must start a statement and be a lone Const.new, A::B.new(…) or ::Foo.new …. Arguments in or out of parentheses, a block or a statement modifier may follow. All of them must name one constant.
  • When it gives no type: anything else leaves the variable untyped, and the call goes to the other strategies, as before. That covers a conditional, two classes, .tap, writer || Foo.new, @a, @b = …, or a chain continued on the next line.
  • The class and method: Ruby's constant lookup from initialize gives the class. The method is its own instance method, else the first ancestor's (rubyAncestry). A class the project doesn't declare (@logger = Logger.new($stdout)) links nothing.
  • Only initialize is read, as the triage asked. A reassignment in another method doesn't untype the variable (see the triage).

There is no extraction change, so the kernel is unchanged.

Validation

Tests: __tests__/ruby-ivar-receivers.test.ts runs in both engines. It covers:

  • typed calls, including ||= with arguments over several lines, a namespaced class built with a block, and an inherited method;
  • untyped cases: a conditional, two classes, .tap, a multiple assignment, and a variable that initialize never assigns;
  • a class from outside the project;
  • a class method's and a module's variables.

With main's resolver the positive test fails in both engines, and the 6 untyped and outside-class tests pass. With the fix all 8 pass.

A/B: kernel engine, main 3a4cd3bf vs this change, natural-key edge diff. The Rails repos are subtrees of rails 793d744. The branch was then rebased onto 25144678, whose JVM changes (java.rs, kotlin.rs, JVM-gated resolution) don't touch Ruby. The suite ran on the rebased commit.

Repo Edges added Removed Right
sinatra cb22afd 0 0 –
jekyll 97c3bdc 8 0 8
activesupport 19 0 19
actionpack 14 0 14
activerecord 30 0 27

Every edge was checked against its class's initialize. Some examples:

  • jekyll's @liquid_renderer.stats_table → LiquidRenderer;
  • activesupport's @registry.add → Fanout::Registry, and @lock.* → Concurrency::ShareLock;
  • actionpack's @scanner.* → Journey::Scanner, and @router.serve → Journey::Router;
  • activerecord's @available.poll → ConnectionPool::Queue (inherited by ConnectionLeasingQueue), and @state.finalized? → TransactionState.

The 3 that are wrong in practice are AbstractAdapter's @pool.* calls. initialize sets @pool = NullPool.new, so they go to NullPool, but pool= later replaces it with the real ConnectionPool.

The controls, requests (Python) and cobra (Go), are byte-identical by dump-graph.

Index time: median of 3 interleaved runs: activesupport 4.72 s vs 4.79 s, activerecord 11.54 s vs 11.54 s.

Full suite: on this commit (e186fd6a, on main 19b74d10), after npm ci, kernel built, --maxWorkers=4. 801 files (795 passed, 6 skipped) and 9,606 tests: 9,520 passed, 0 failed, 86 skipped.

Left out

  • A variable typed only through a setter or a lazy getter (def writer; @writer ||= Foo.new; end) stays untyped, since only initialize is read. So does one set in a superclass's initialize.
  • A variable that another method reassigns keeps its initialize type. Making any assignment outside initialize untype it would fix the NullPool case, but it would also drop right edges such as activesupport's @registry = @registry.add(…), which is still a Registry.

🤖 Generated with Claude Code

…gives it

Both extractors record a Ruby call on an instance variable as
`@writer.flush`, and no strategy read the variable's type, so every such
call stayed unresolved: 2,780 in activesupport and 3,239 in actionpack on
main, for example.

ruby-constant-paths now resolves `@name.m` written in an instance method of
a class (not a class method, not a module) by the class's `initialize`.
When every assignment to `@name` there starts a statement and is a lone
`Const.new(...)`, `A::B.new` or `::Foo.new ...` of one constant
(arguments with or without parentheses, a block or a statement modifier
may follow), the class is the one Ruby's constant lookup finds for it from
`initialize`, and the method is that class's own instance method or the
first its ancestry declares. Anything else leaves the variable untyped for
the other strategies, as before: a conditional, two classes, `.tap`,
`writer || Foo.new`, `@a, @b = ...`, a chain continued on the next line. A
class the project doesn't declare (`Logger.new($stdout)`) links nothing.

Validation, kernel engine, main 3a4cd3b vs this change:
- jekyll 97c3bdc +8, activesupport +19, actionpack +14, activerecord +30
  (rails 793d744 subtrees), sinatra cb22afd 0; nothing removed. 68 of the
  71 are right; 3 in activerecord follow `@pool = NullPool.new` from
  `initialize`, which `pool=` later replaces with the real pool.
- Controls requests (Python) and cobra (Go): dump-graph byte-identical.
- New tests fail with main's resolver (the positive case, in both engines)
  and pass with the fix; the untyped and outside-class cases pass on both.
- Index time, median of 3 interleaved: activesupport 4.72 vs 4.79 s,
  activerecord 11.54 vs 11.54 s.

The gap was found in the triage of PR #684.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant