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

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>
This commit is contained in:
Colby Mchenry
2026-10-23 05:20:21 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5b47bc4963
commit dcb985f1d2
9 changed files with 529 additions and 13 deletions
+11 -7
View File
@@ -429,13 +429,17 @@ field_expression machinery, which ruby therefore NEVER runs. Steps:
normalized to `()`**, unlike java/php chain encodings); literal receivers
are NOT filtered (`"literal".upcase` → `"literal".upcase`, `5.times` →
`5.times` — unresolvable noise, PRESERVE).
6. Receiver node TYPE exactly `constant` (and not skipped) → ADDITIONAL
`references` ref {name: receiverName, line/col: **the RECEIVER's**
position} — `Klass.static_call` emits calls `Klass.static_call` +
references `Klass`; fires for VALUE constants too (`RETRY_MAX.times {…}`
→ calls `RETRY_MAX.times` + references `RETRY_MAX` — pinned). A
`scope_resolution` receiver (`Foo::Bar.baz`) gets NO references ref
(type ≠ constant). PRESERVE.
6. Receiver a CONSTANT PATH (and not skipped) → ADDITIONAL `references`
ref {name: receiverName, line/col: **the RECEIVER's** position} —
`Klass.static_call` emits calls `Klass.static_call` + references `Klass`;
fires for VALUE constants too (`RETRY_MAX.times {…}` → calls
`RETRY_MAX.times` + references `RETRY_MAX` — pinned). A constant path is
a `constant`, or a `scope_resolution` whose `name` is a `constant` and
whose `scope` is absent or itself a constant path (#2503): `Foo::Bar.baz`
→ references `Foo::Bar`, `::Foo.baz` → references `::Foo` (the leading
`::` kept — the resolver reads it as "from the top level"). A path
through a value (`obj::Thing.go`, `self.class::LIMIT.times`) gets NONE.
Both engines: `is_constant_path` / `isRubyConstantPath`.
7. return — inner receiver calls are ALSO visited afterwards (the body
walker recurses children at 5277 — extractCall does not consume its
subtree), so `chained.first_call.second_call` emits BOTH