Skip to content

fix(ruby): a call on a namespaced class links its caller to the class and its method - #2827

Merged
colbymchenry merged 1 commit into
mainfrom
claude/issue-2503-ruby-namespaced-receiver
Oct 23, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/issue-2503-ruby-namespaced-receiver

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

In Ruby, a call on a class written with its namespace, like Billing::ChargeCard.call(amount: 1) or ::Billing::ChargeCard.call(…), now links the caller to that class (a references edge, as ChargeCard.call(…) already had) and to the method it calls (a calls edge). Before, it recorded neither, so a class used only through such calls showed no callers and no dependents, which is the usual shape of service objects in Rails apps.

On the issue's three files, codegraph callers ChargeCard now lists both Checkout::Complete#call and Billing::Refund#call, and codegraph callees call -f checkout/complete.rb lists call (the def self.call at charge_card.rb:3) and ChargeCard [references].

Root cause

  • Extraction: both engines emitted the receiver's extra references ref only when the receiver node is a constant (codegraph-kernel/src/ruby.rs and src/extraction/tree-sitter.ts). A scope_resolution receiver got none.
  • Resolution: the call ref Billing::ChargeCard.call matched no strategy. The method-call patterns don't accept a :: in the receiver, and the qualified-name strategy looks for Billing::ChargeCard.call as a name. So the call stayed unresolved too, which the issue's callees output shows.

Fix

  • Extraction (kernel and wasm, the same rule): a receiver that is a constant path counts as a constant receiver. That means a constant, or a scope_resolution whose name is a constant and whose scope is absent or itself a constant path (A::B::C, ::A, ::A::B). The ref keeps the receiver as written, leading :: included. A path through a value (obj::Thing, self.class::LIMIT) emits nothing, and .new stays an instantiates ref.
  • Resolution (src/resolution/ruby-constant-paths.ts, new): a Ruby references ref written as a constant path, and a calls ref on one, resolve the way Ruby looks the constant up. The lookup starts with the class or module around the reference, then each namespace outside it, then the top level. A ::-rooted path is looked up at the top level only, so ::Web::PushSubscription inside Api::Web is the top-level class. A call reaches the class's or module's own method, else the first one its ancestry declares (rubyAncestry, now exported from the name matcher).
  • The step runs before the pre-filter, which dropped ::-rooted names. It is also exclusive. A path the project doesn't declare (ActiveRecord::Base.transaction), or a class without the method (Billing::ChargeCard.where), links nothing rather than a namesake found by its last name. Production code never reaches a class only a test declares, such as a spec's stand-in for Stripe::Charge.
  • A bare constant (ChargeCard.call) keeps its current path.
  • The kernel port checklist's step 6 is updated.

Validation

  • Tests: __tests__/ruby-namespaced-receiver.test.ts has 8 cases, each on the kernel and on wasm, plus a sync case where the class's file is added later. kernel-ruby-parity.test.ts gets a parity case for the receiver shapes.

    • On main, 16 of the 22 tests in these two files fail; the rest are guards and the old parity cases. All pass with the fix.
    • Reverting only the resolver step turns 13 red. Reverting only the wasm extractor turns 10 red.
  • A/B against main (default kernel path). The 3 timing rounds ran on main 23a80a8. A fourth round on 013b91c (fix(js,ts): a named class expression is a class with its own members #2825) gave the same deltas and byte-identical controls. The branch is rebased on 58eb57a. The two commits since then (fix(vue): a <script lang="tsx"> block is read with the TSX grammar #2824, Vue and Svelte script blocks; fix(nuxt): navigateTo('/x') links to the page it opens, as router.push does #2826, Nuxt navigateTo) don't touch Ruby, and their tests pass on this branch.

    repo files edges main → fix added removed
    sinatra/sinatra cb22afd7 292 2,646 → 2,669 23 0
    rubocop/rubocop b2ab0ce6 2,199 31,370 → 31,538 168 0
    mastodon/mastodon 559688e1 (app, lib, config, spec, db) 4,363 29,364 → 30,040 676 0
    expressjs/express 0dee2c52 (control) 214 byte-identical 0 0
    pallets/flask d086db85 (control) 236 byte-identical 0 0

    Nodes are identical on every repo. I triaged all 867 added edges, reading the target definition with tree-sitter and checking each lexical resolution against the call site's own nesting:

    • 866 right. 748 are references to the path as written or under an enclosing namespace: 18 on sinatra, 58 + 54 on rubocop, 618 on mastodon. 118 are calls: 96 reach a class method (def self.x or in class << self), 6 of them where the class also has an instance method of that name; 20 reach a module_function method (Mastodon::Version, RuboCop::Cop::Util); 2 reach an inherited class method (RSS::Item.with reaches RSS::Element.with).
    • 1 wrong. On mastodon, Paperclip::Transcoder.make(…) reaches the instance method make. The class method is the gem's Paperclip::Processor.make. A Ruby singleton method and an instance method share one qualified name, and both are stored as instance methods, so the resolver can't tell them apart. A bare Transcoder.make resolves the same way on main. I'm fixing that in a follow-up PR.
    • 306 new references refs stay unresolved on mastodon, 44 on rubocop and 22 on sinatra. They are library constants such as ActiveRecord::Base, Addressable::URI and Digest::SHA256. The only ones with a project namesake are ::Bundler, ::RSpec and ::Socket, written with :: to escape a nested RuboCop::Cop::Bundler, RuboCop::RSpec or Request::Socket.
  • Kernel parity (scripts/kernel-parity.mjs --lang ruby): sinatra 147/147, rubocop 1799/1801 (the same 2 files defer on main), mastodon 3333/3333, 0 diffs. The fix arm's kernel and wasm graphs are byte-identical on sinatra and rubocop.

  • Index time, median of 3 interleaved runs: sinatra +1.2%, rubocop +1.7%, mastodon +0.4%. That is the same as the controls (express +0.8%, flask +1.3%).

  • Full suite on the pushed commit (84ef2a2): 744 files, 8,822 passed, 7 failed, 82 skipped. All 7 failures were timeouts or a timing threshold under heavy machine load (git-index-currency 2, mcp-daemon 3, sync 1, ui-server-api 1), and all 4 files pass when rerun alone on the same commit. Before the last rebase, the identical diff on fd18f6a ran 8,822 passed, 0 failed, 82 skipped.

Fixes #2503

Contributor PR #2509 (@drakeo338) was read as guidance. Its idea of emitting the receiver ref under the qualified name, and its test shapes (Billing::Create vs Users::Create, ::Foo, a relative Deep::Op, ActiveRecord::Base), are kept here. Two things differ:

  • The leading :: is kept, and the name is resolved by Ruby's lookup rather than by the generic qualified-name match. That match would drop ::Foo at the pre-filter, reach a relative path by suffix (from inside Api::V2, Users::Create could land on Api::V1::Users::Create), and leave the calls edge unresolved.
  • The calls ref is resolved too.

Left out

  • Singleton and instance methods of one name. They share a qualified name, so Klass.m, the namespaced form included, picks the first m the class declares. That is right for the usual def self.call before def call, and is the one wrong edge above. Fixing it touches extraction in both engines and several resolver paths, so it is a separate PR.
  • Lookup through ancestors. Ruby also finds a constant through the class's ancestors; this lookup only walks the enclosing namespaces. For a compact class A::B it walks A as well, as the resolver already does for Ruby superclass names. On these repos every lexical resolution went through a namespace that really encloses the call.
  • NS::Widget.new still records instantiates by its last name, as before.

🤖 Generated with Claude Code

… and its method

A Ruby call on a constant written with its namespace, like
Billing::ChargeCard.call(...) or ::Foo.call, recorded no edge. Both
extractors (the kernel's ruby.rs and the wasm one) emitted the receiver's
references ref only for a bare `constant` receiver, so a scope_resolution
receiver got none. The call ref itself, Billing::ChargeCard.call, matched
no resolver strategy either: the method-call patterns reject a `::` in the
receiver. A class used only through such calls showed no callers and no
dependents.

Fix:
- Both engines treat a constant path (a constant, or a scope_resolution of
  constants, a leading `::` included) as a constant receiver and emit the
  references ref under the receiver as written. A path through a value
  (obj::Thing, self.class::LIMIT) still emits none, and .new stays an
  instantiation.
- A new resolver step, src/resolution/ruby-constant-paths.ts, resolves a
  Ruby references ref written as such a path, and a calls ref on one, the
  way Ruby looks the constant up: the class or module around the
  reference, then each namespace outside it, then the top level; a
  `::`-rooted path at the top level only. A call reaches the class's or
  module's own method, else the first one its ancestry declares
  (rubyAncestry, now exported). The step runs before the pre-filter,
  which dropped `::`-rooted names, and it is exclusive: a path the project
  doesn't declare (ActiveRecord::Base) or a class without the method links
  nothing, never a namesake by its last name, and production code never
  reaches a class only a test declares.

Validation:
- New __tests__/ruby-namespaced-receiver.test.ts (8 cases, each on the
  kernel and on wasm, plus a sync case) and a parity case in
  kernel-ruby-parity.test.ts. On main 16 of the 22 tests in the two files
  fail (the rest are guards and the old parity cases); all pass with the
  fix. Reverting only the resolver step, or only the wasm extractor,
  turns 13 and 10 of them red.
- A/B against main (kernel path), every edge triaged: sinatra +23 edges,
  rubocop +168, mastodon's Ruby tree (4,363 files) +676; 0 removed, nodes
  identical. 866 of 867 are right: references by the path as written or
  under an enclosing namespace (each such case checked against the call
  site's own nesting), calls to a class method, a module_function method,
  or an inherited class method. 1 is wrong: Paperclip::Transcoder.make
  reaches the instance method `make`, since a singleton method and an
  instance method share one qualified name today; a bare Transcoder.make
  does the same on main. Controls express and flask are byte-identical.
- kernel-parity.mjs --lang ruby: sinatra 147/147, rubocop 1799/1801 (the
  same 2 deferred on main), mastodon 3333/3333, 0 diffs. The fix arm's
  kernel and wasm graphs are byte-identical on sinatra and rubocop.
- Index time, median of 3 interleaved runs: +0.4% to +1.7%, the same as
  the controls (+0.8%, +1.3%).

Fixes #2503

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.

Ruby: namespaced-constant receivers (A::B.call) get no references edge, so the class has 0 callers

1 participant