Skip to content

fix(ruby): calls to !/? methods and calls on what .new made link their method - #3049

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

colbymchenry merged 1 commit into
mainfrom
claude/fix-ruby-bang-and-new-receivers

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

In Ruby, two kinds of call linked nothing:

  • A call to a method whose name ends in ! or ?, made on a receiver: writer.flush! or writer.ready? after writer = CacheWriter.new, and CacheWriter.create!(…) on the class.
  • A method called straight on a new object: CacheWriter.new(key).write_cache or Billing::Ledger.new.charge!(2).

Both now link. The class a .new builds is the one Ruby's constant lookup finds where the call is written, and the method is that class's own or one its ancestry declares. A class from outside the project links nothing: rack-protection's Request.new(env).host is rack's Rack::Request, not the project's Sinatra::Request.

Reimplements #684 by @ricobl, who found both gaps. This PR has our own implementation and tests in both engines, and the CHANGELOG entry credits them.

Root cause

  1. matchMethodCall's receiver pattern ^([\w.]+)\.(\w+:?(?:\w+:)*)$ takes no trailing ! or ?. Every such call on a receiver fell through the method-call strategies. A bare flush! already linked through the bare-name path.
  2. Both extractors built the callee from the receiver's raw text: the wasm extractor's Ruby branch in tree-sitter.ts, and extract_call in codegraph-kernel/src/ruby.rs. So Foo.new(args).bar was recorded as Foo.new(args).bar, with the arguments in it, and matched no strategy. A Rack::Builder.new do … end.to_app was recorded with the whole block's text as its name.

Fix

  • Extraction, both engines identically: a call whose receiver is Const.new(…), A::B.new or ::Foo.new is recorded as new Const().m. This is the shape a constructed receiver already has in TS/JS, Java and C#. A new on a value (klass.new.run, self.class.new(x).run) and later links of a chain keep the receiver's text.
  • Resolution, ruby-constant-paths.ts: this is the module fix(ruby): a call on a namespaced class links its caller to the class and its method #2827 added for A::B.m. A new Const().m call is resolved there, before the name pre-filter.
    • The class is found by Ruby constant lookup from the namespaces around the call, then the top level; a ::-rooted name is looked up at the top level only.
    • The method is the class's own instance method, else the first of its superclasses and mixins (rubyAncestry) that declares it.
    • When either is missing, the call gets no edge. No name-only fallback runs after it.
    • I used fix(ruby): a call on a namespaced class links its caller to the class and its method #2827's constantsNamed + rubyAncestry walk rather than resolveMethodOnType. The latter matches <type>::<method> by suffix and walks supertypes by simple name, so for a namespaced class it would find no inherited method, and for a top-level one it could take a nested namesake.
    • rubyAncestry no longer reads a module a call returns as a mixin of the constant the call starts at. Before, include ActionDispatch::Routing::RouteSet.new.tap { … }.url_helpers counted RouteSet as a mixin. In the first A/B that sent 61 of actionpack's W.new.url_for calls to RouteSet#url_for.
  • ! and ?: the method part of the Ruby dot pattern in matchMethodCall takes a trailing ! or ?. Such a call then goes through the same strategies as any other Ruby method call, with one exception: the last one. That strategy scores several project methods of the name by the words they share with a value receiver, and it doesn't run for these names.
    • The reason is the first A/B. That strategy produced 74 of the new edges, and 55 of them were wrong. Names like key?, empty?, respond_to?, is_a? and merge! are answered by Ruby's core classes on nearly every receiver, so a shared word doesn't say which project method is meant, or whether any is. For example, activesupport's options.key? on a Hash went to InheritableOptions#key?, and actionpack's body.respond_to? went to RackBody#respond_to?.
    • A constant receiver (MarshalWithFallback.dumped?) still names its owner, so it keeps the scoring.

Validation

Tests:

  • __tests__/ruby-constructed-receivers.test.ts runs in both engines. It covers:
    • a typed local's !/? call and a ! class method;
    • .new chains with and without arguments, and namespaced and ::-rooted classes;
    • constant lookup from the call's namespace (Card inside Billing::Ledger is Billing::Card, not Admin::Card) and an inherited method;
    • a class from outside the project;
    • a ? call on an untyped value with two project methods of the name, which gets no guess;
    • a class whose include names a module a call returns, which gives it no mixin;
    • a sync that adds the class.
  • __tests__/kernel-ruby-parity.test.ts gets a constructed-receiver case: kernel and wasm emit identical refs.
  • Red/green: with main's kernel and main's TS, 10 of the 22 tests in these two files fail; with the fix all 22 pass, in both engines. Dropping only the scoring gate fails its guard test, and so does dropping only the mixin fix (both engines).

Kernel parity: scripts/kernel-parity.mjs found byte-parity on every file, with none deferred to wasm: the fixtures 10/10, sinatra 147/147, and jekyll with the activesupport and actionpack subtrees 1,044/1,044.

A/B: kernel engine, main 37052b18 vs this change, natural-key edge diff. The Rails repos are subtrees of rails 793d744, each copied out as its own folder.

Repo Edges added Removed Wrong among the added (triage)
sinatra cb22afd 12 0 2
jekyll 97c3bdc 53 0 2
activesupport 398 0 0–1
actionpack 97 2 3
activerecord 1,012 0 about 13

No nodes changed anywhere. Two actionpack edges keep their target and only gain confidence (NoDependenciesController.new.view_cache_dependencies, 0.7 → 0.85). The unresolved-ref changes are the renamed new X().m refs. The controls, requests (Python) and cobra (Go), are byte-identical by dump-graph.

Triage: every group was checked; the large ones by distinct call → target pair.

  • Right by construction:

    • new X().m: the class comes from constant lookup and the method from the class or its ancestry.
    • Typed locals (0.9).
    • Class-method calls on a constant.

    Samples confirm them, for example Date.new(…).advance → Date#advance (activesupport reopens Date), Date.new.all_day → DateAndTime::Calculations#all_day (a mixin), and GenericEnumerable.new.exclude? → Enumerable#exclude?.

  • The wrong ones all come from the two name strategies that plain Ruby names also go through:

    • sinatra: rack-protection's bad_cookies.empty? ×2 → Sinatra::Cookies::Jar#empty?.
    • jekyll: cache.key? on the regenerator's Hash → Jekyll::Cache#key?, and document.asset_file? on a Page → Document#asset_file?.
    • actionpack: content_type.nil? ×2 → Mime::NullType#nil?, and cache_store.exist? → RailsEntityStore#exist?.
    • activerecord: about 13, for example self.class.respond_to? → AttributeMethods#respond_to?, sqlite3 code's column.auto_increment? → MySQL::Column, and type.serializable? → EnumType#serializable?.
  • The 2 removed actionpack edges were wrong on main. ActionController::Base.with(…) is ActiveSupport's Object#with; it reached AbstractController::Railties::RoutesHelpers.with only through the misread mixin extend ::AbstractController::Railties::RoutesHelpers.with(app.routes).

Index time: median of 3 interleaved runs: activesupport 5.35 s vs 4.63 s, jekyll 2.66 s vs 2.73 s. That is noise.

Full suite: on this tree (tested as c63b0411, on main b700a3ee; only the commit message changed since), kernel built, --maxWorkers=4. 797 files (791 passed, 6 skipped) and 9,578 tests: 9,493 passed, 0 failed, 85 skipped.

Left out

  • Two name strategies still guess for !/? names, as they do for plain names. One is a receiver named after its class (entry.expired? → Cache::Entry); the other is the one project method of the name when it shares a receiver word. The triage counts their few wrong edges. The receiver-word scoring is still on for plain Ruby names: on main it makes, for example, activesupport's Time.utc reach DateTime::utc 567 times. A Ruby standard-method stoplist, like the one fix(resolution): library-method calls on untyped receivers need receiver evidence (Rust, Go, Python, Kotlin, Scala, C#, Java) #1947 added for Python, Scala and Java, would address both, and is a separate change.
  • A call on an instance variable (@writer.flush) is not typed here. That is a follow-up PR.

🤖 Generated with Claude Code

…k their method

Two kinds of Ruby call linked nothing. A call to a method whose name ends
in `!` or `?` on a receiver (`writer.flush!`, `writer.ready?` after
`writer = CacheWriter.new`, `CacheWriter.create!(1)`) failed the method-call
pattern, whose method part took no `!` or `?`. A method called straight on
a new object (`CacheWriter.new(key).write_cache`, `Billing::Ledger.new.charge!`)
was recorded by both extractors with the receiver's raw text, arguments and
blocks included, which no strategy matched.

Both extractors now record a call on `Const.new(...)`, `A::B.new` or
`::Foo.new` as `new Const().m`, the constructed-receiver shape TS/JS, Java
and C# already use; the wasm extractor and the kernel's ruby.rs emit the
same refs. ruby-constant-paths resolves that shape before the name
pre-filter: the class by Ruby's constant lookup from the namespaces around
the call, the method as the class's own instance method or the first its
ancestry declares, and nothing when either is missing, so rack-protection's
`Request.new(env)` (rack's Rack::Request) links nothing. The Ruby dot pattern
in matchMethodCall takes a trailing `!` or `?`; the receiver-word scoring
among several project methods of the name stays off for such names on a
value receiver, where it was wrong on 55 of 74 new edges (`options.key?`
on a Hash -> InheritableOptions#key?). rubyAncestry no longer reads a
module a call returns (`include RouteSet.new.tap {...}.url_helpers`) as a
mixin of the constant the call starts at.

Validation, kernel engine, main 37052b1 vs this change:
- sinatra cb22afd +12, jekyll 97c3bdc +53, activesupport +398,
  actionpack +97 (2 removed: `ActionController::Base.with` had reached
  RoutesHelpers.with through a misread mixin), activerecord +1012
  (rails 793d744 subtrees). About 21 of 1,572 added edges are wrong, all
  from the class-name and single-method guesses that plain names get too.
- Controls requests (Python) and cobra (Go): dump-graph byte-identical.
- kernel-parity.mjs: fixtures 10/10, sinatra 147/147, jekyll + rails
  1044/1044 files byte-parity, none deferred.
- New tests: 10 fail with main's kernel and TS, all pass with the fix, in
  both engines. Dropping the scoring gate or the mixin fix fails its guard
  test.
- Index time, median of 3 interleaved: activesupport 5.35 vs 4.63 s,
  jekyll 2.66 vs 2.73 s (noise).

Reimplements PR #684 by @ricobl, who found both gaps.

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