A qualifier that names a CLASS anchors on its package: Rack::MockRequest.new would bind to Rack::Protection::new — so Ruby constant-receiver calls stay unresolved #296

Open
opened 2026-09-23 17:24:14 +02:00 by buildagent · 0 comments
Member

Found while fixing #294, measured on the ruby-sinatra corpus. The honest status today is unresolved and disclosed, not wrong — this issue is about why the obvious fix is wrong.

The gap

Auth.resolve_user(t) and Outer::Auth.resolve_user(t) — a call on a constant receiver, i.e. a class/module method — are emitted as an unqualified method_call with no receiver captured. They do not resolve, and name_fallback_shape_excluded says so, which is the disclosure working. The reporter measured 12 such calls to one method, all unresolved, and review_diff then raised "untested change" on methods whose tests call them exactly this way.

The obvious fix, and what it did

Emit them as Rust's Foo::bar() is emitted: CALL, qualified: true, qualifier = the receiver as written. Measured on ruby-sinatra, joined bind-for-bind against the clean build:

  • constant-receiver calls: 801; resolved 11 -> 112.

  • 4 of the old 11 were phantoms that the qualifier correctly removed (Regexp.escape -> Rack::Protection::EscapedParams::escape, File.unlink x2 -> Test::unlink, ChildProcess.build -> RackTest::build). Good.

  • But roughly 60 of the new binds were phantoms, through TIER1Q_PASS1 (40):

    Rack::MockRequest.new       -> Rack::Protection::new        (x15+)
    Rack::Request.new           -> Rack::Protection::new
    Rack::Builder.new           -> Rack::Protection::new
    Sinatra::Base.respond_to?   -> Sinatra::Request::AcceptEntry::respond_to?
    Sinatra::Base.app_file      -> Sinatra::Runner::app_file
    

precision_gate stayed 7/7 throughout: none of these sites is a declared decoy.

Why

Tier 1Q reads a qualifier as a module path — right for Rust crate::util::foo(), where the target may sit anywhere under the path. It anchors Rack::MockRequest on the package Rack and then takes the unique new beneath it. In Ruby a constant receiver names a class, and the target must be defined on that class (or its ancestors). new is the pathological name: Ruby classes almost never define it, so the rare def self.new absorbs every X.new in its namespace.

And one correct bind was lost: Server = IntegrationHelper::BaseServer; Server.all_async — a constant alias, the alias_qualified_refs vacuity CountBasis already names.

What would close it

A qualifier that names a container binds only a member whose parent is that container — parent.qualified_name equals the qualifier, or ends with it at a :: boundary. That is one clause on a structural fact, and it is not Ruby-specific: PHP Foo::bar(), C# Foo.Bar() and Rust Type::method() have the same shape. It must not replace path anchoring for module qualifiers (crate::util::f), so the producer has to say which it is — probably a role bit on the ref, the way TYPE_POSITION is carried.

Acceptance, per the usual bar:

  1. The ~60 phantoms above do not come back; Auth.resolve_user resolves to Auth::resolve_user.
  2. Measured bind-for-bind on all seven languages' corpus repos, not just ruby-sinatra, since tier 1Q is shared.
  3. ruby_package_parity holds: the guest must be able to express the same bit.
  4. Inheritance stays honest: Sub.parent_method where parent_method is on a superclass stays unresolved rather than binding by name.

Related: #294 (the fix that found this), #194 (Ruby's receiver-locality exclusion).

Found while fixing #294, measured on the ruby-sinatra corpus. The honest status today is **unresolved and disclosed**, not wrong — this issue is about why the obvious fix is wrong. ## The gap `Auth.resolve_user(t)` and `Outer::Auth.resolve_user(t)` — a call on a constant receiver, i.e. a class/module method — are emitted as an unqualified `method_call` with no receiver captured. They do not resolve, and `name_fallback_shape_excluded` says so, which is the disclosure working. The reporter measured 12 such calls to one method, all unresolved, and `review_diff` then raised "untested change" on methods whose tests call them exactly this way. ## The obvious fix, and what it did Emit them as Rust's `Foo::bar()` is emitted: `CALL`, `qualified: true`, qualifier = the receiver as written. Measured on ruby-sinatra, joined bind-for-bind against the clean build: * constant-receiver calls: 801; resolved 11 -> 112. * 4 of the old 11 were **phantoms that the qualifier correctly removed** (`Regexp.escape` -> `Rack::Protection::EscapedParams::escape`, `File.unlink` x2 -> `Test::unlink`, `ChildProcess.build` -> `RackTest::build`). Good. * But roughly **60 of the new binds were phantoms**, through `TIER1Q_PASS1` (40): ``` Rack::MockRequest.new -> Rack::Protection::new (x15+) Rack::Request.new -> Rack::Protection::new Rack::Builder.new -> Rack::Protection::new Sinatra::Base.respond_to? -> Sinatra::Request::AcceptEntry::respond_to? Sinatra::Base.app_file -> Sinatra::Runner::app_file ``` `precision_gate` stayed 7/7 throughout: none of these sites is a declared decoy. ## Why Tier 1Q reads a qualifier as a **module path** — right for Rust `crate::util::foo()`, where the target may sit anywhere under the path. It anchors `Rack::MockRequest` on the package `Rack` and then takes the unique `new` beneath it. In Ruby a constant receiver names a **class**, and the target must be defined *on that class* (or its ancestors). `new` is the pathological name: Ruby classes almost never define it, so the rare `def self.new` absorbs every `X.new` in its namespace. And one correct bind was lost: `Server = IntegrationHelper::BaseServer; Server.all_async` — a constant alias, the `alias_qualified_refs` vacuity `CountBasis` already names. ## What would close it A qualifier that names a **container** binds only a member whose parent is that container — `parent.qualified_name` equals the qualifier, or ends with it at a `::` boundary. That is one clause on a structural fact, and it is not Ruby-specific: PHP `Foo::bar()`, C# `Foo.Bar()` and Rust `Type::method()` have the same shape. It must not replace path anchoring for module qualifiers (`crate::util::f`), so the producer has to say which it is — probably a role bit on the ref, the way `TYPE_POSITION` is carried. Acceptance, per the usual bar: 1. The ~60 phantoms above do not come back; `Auth.resolve_user` resolves to `Auth::resolve_user`. 2. Measured bind-for-bind on all seven languages' corpus repos, not just ruby-sinatra, since tier 1Q is shared. 3. `ruby_package_parity` holds: the guest must be able to express the same bit. 4. Inheritance stays honest: `Sub.parent_method` where `parent_method` is on a superclass stays unresolved rather than binding by name. Related: #294 (the fix that found this), #194 (Ruby's receiver-locality exclusion).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#296
No description provided.