From debb7d7f0588e77d8e0f88f2a1be80ab897d9a07 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 25 Sep 2026 21:33:43 +0200 Subject: [PATCH 1/3] conformance: make the should-proxy stop answering its own predicates (#655) `ShouldProxy` inherited Object, so every predicate Object already answers was dispatched on the PROXY instead of the receiver and its result thrown away. `x.should.equal?(y)` therefore asserted nothing at all: it compared the proxy with y, got false, discarded it, and the example passed whatever the values were. 765 `.should.equal?` uses across 328 files of language/ + core/ could not fail. Same for `.should.eql?`, `.should.is_a?`, `.should.kind_of?`, `.should.instance_of?`, `.should.nil?`, `.should.frozen?`, `.should.respond_to?` and `.should.instance_variable_defined?` -- nine spellings in all. Upstream mspec is immune by construction: its operator matchers subclass BasicObject (mspec/lib/mspec/matchers/base.rb, `class SpecPositiveOperatorMatcher < BasicObject`), which answers only ==, !=, !, equal?, __send__, __id__, instance_eval, instance_exec and method_missing. mspec spells out those it must and lets every other predicate fall through #method_missing onto the real receiver, where its truthiness is asserted. Do the same: `class ShouldProxy < BasicObject`, with `equal?` spelled out beside the existing `==`/`!=`, and the deprecated matcher spellings `equal`/`eql` (mspec/lib/mspec/matchers/equal.rb) kept because the receiver has no such method to forward to. This also covers spellings the corpus has not used yet: a predicate is broken exactly when the proxy answers it. Every constant inside the class is now ::-qualified. From a BasicObject subclass MRI does not reach top-level constants, so a bare `Exception` raised `NameError: uninitialized constant ShouldProxy::Exception` under MRI 4.0.5 -- while rbgo resolved it. The shim has to run under MRI too (it is the judge that separates a real rbgo defect from an inexpressible assertion), so the difference would have broken the judge and not us. Co-Authored-By: Claude Opus 5 --- scripts/conformance/rubyspec/spec_helper.rb | 61 +++++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/scripts/conformance/rubyspec/spec_helper.rb b/scripts/conformance/rubyspec/spec_helper.rb index 80261fd4..4f7ffca0 100644 --- a/scripts/conformance/rubyspec/spec_helper.rb +++ b/scripts/conformance/rubyspec/spec_helper.rb @@ -262,12 +262,34 @@ def max_long; 0x7fff_ffff_ffff_ffff; end def min_long; -0x8000_0000_0000_0000; end # ---------------- should proxy ---------------- -class ShouldProxy +# The proxy must inherit as LITTLE as possible. +# +# `x.should.equal?(y)` is mspec's spelling of an identity assertion. If the +# proxy answers `equal?` ITSELF the assertion is never made: Object#equal? +# compares the PROXY with y, returns false, and the example passes whatever the +# two values are (go-embedded-ruby/ruby#655 -- 765 `.should.equal?` uses across +# 328 files of language/ + core/, not one of which could fail). The same holds +# for every other predicate Object already answers: eql?, nil?, is_a?, +# kind_of?, instance_of?, frozen?, respond_to?, instance_variable_defined?. +# +# Upstream mspec is immune by construction: its operator matchers subclass +# BasicObject (mspec/lib/mspec/matchers/base.rb -- +# `class SpecPositiveOperatorMatcher < BasicObject`). BasicObject answers only +# ==, !=, !, equal?, __send__, __id__, instance_eval, instance_exec and +# method_missing, so mspec spells out ==, != and equal? explicitly and EVERY +# other predicate falls through #method_missing onto the real receiver, where +# mspec checks its truthiness (`SpecExpectation.fail_predicate`, see +# mspec/lib/mspec/expectations/expectations.rb). +# +# Subclassing BasicObject here reproduces that, and keeps reproducing it for +# spellings the corpus has not used yet: a predicate is broken exactly when the +# proxy already answers it, so the proxy must answer as little as possible. +class ShouldProxy < BasicObject def initialize(o, neg); @o = o; @neg = neg; end def _chk(cond, desc) cond = !cond if @neg unless cond - ::Kernel.raise(SpecFail, "expected #{@o.inspect} #{@neg ? 'not ' : ''}to #{desc}") + ::Kernel.raise(::SpecFail, "expected #{@o.inspect} #{@neg ? 'not ' : ''}to #{desc}") end @o end @@ -278,29 +300,41 @@ def >(x); _chk(@o > x, "> #{x.inspect}"); end def <=(x); _chk(@o <= x, "<= #{x.inspect}"); end def >=(x); _chk(@o >= x, ">= #{x.inspect}"); end def =~(x); _chk((@o =~ x) ? true : false, "=~ #{x.inspect}"); end + # BasicObject defines ==, != and equal?, so those three -- and only those + # three -- have to be spelled out; every other predicate reaches + # #method_missing and is asserted there. + def equal?(x); _chk(@o.equal?(x), "equal? #{x.inspect}"); end + # `equal` and `eql` without the question mark are mspec's deprecated MATCHER + # spellings (mspec/lib/mspec/matchers/equal.rb); the receiver has no such + # method to forward to, so they stay explicit. def equal(x); _chk(@o.equal?(x), "equal #{x.inspect}"); end def eql(x); _chk(@o.eql?(x), "eql #{x.inspect}"); end + # Every constant below is ::-qualified: from inside a BasicObject subclass, + # MRI does NOT reach the top-level (Object) constants, so a bare `Exception` + # here raises NameError: uninitialized constant ShouldProxy::Exception. rbgo + # happens to resolve it, which is exactly why the shim has to be run under + # MRI as well -- an unqualified constant would work here and break the judge. def raise(*args, &blk) raised = nil begin @o.call - rescue Exception => e + rescue ::Exception => e raised = e end if @neg - ::Kernel.raise(SpecFail, "expected no exception, got #{raised.class}: #{raised.message}") if raised + ::Kernel.raise(::SpecFail, "expected no exception, got #{raised.class}: #{raised.message}") if raised return nil end - ::Kernel.raise(SpecFail, "expected to raise #{args[0]}, nothing raised") if raised.nil? + ::Kernel.raise(::SpecFail, "expected to raise #{args[0]}, nothing raised") if raised.nil? if args[0] unless raised.is_a?(args[0]) - ::Kernel.raise(SpecFail, "expected #{args[0]}, got #{raised.class}: #{raised.message}") + ::Kernel.raise(::SpecFail, "expected #{args[0]}, got #{raised.class}: #{raised.message}") end end if args[1] m = args[1] - ok = m.is_a?(Regexp) ? !!(raised.message =~ m) : (raised.message == m) - ::Kernel.raise(SpecFail, "wrong message: got #{raised.message.inspect}, want #{m.inspect}") unless ok + ok = m.is_a?(::Regexp) ? !!(raised.message =~ m) : (raised.message == m) + ::Kernel.raise(::SpecFail, "wrong message: got #{raised.message.inspect}, want #{m.inspect}") unless ok end # `-> { ... }.should.raise(Klass) { |e| ... }` inspects the captured exception. blk.call(raised) if blk @@ -310,7 +344,10 @@ def method_missing(name, *args, &blk) res = @o.__send__(name, *args, &blk) _chk(res, "#{name}(#{args.map { |a| a.inspect }.join(', ')})") end - def respond_to_missing?(*); true; end + # No #respond_to_missing? here: under BasicObject the proxy does not answer + # #respond_to? at all, so `x.should.respond_to?(:foo)` now forwards to x -- + # which is the assertion the spec is making. Defining respond_to_missing? + # would only re-introduce a method the proxy answers itself. end class Object @@ -320,7 +357,7 @@ def should(matcher = MSPEC_NOMATCH) ShouldProxy.new(self, false) else unless matcher.matches?(self) - ::Kernel.raise(SpecFail, matcher.respond_to?(:failure_message) ? matcher.failure_message : "matcher failed") + ::Kernel.raise(::SpecFail, matcher.respond_to?(:failure_message) ? matcher.failure_message : "matcher failed") end self end @@ -330,7 +367,7 @@ def should_not(matcher = MSPEC_NOMATCH) ShouldProxy.new(self, true) else if matcher.matches?(self) - ::Kernel.raise(SpecFail, "expected not to match") + ::Kernel.raise(::SpecFail, "expected not to match") end self end @@ -446,7 +483,7 @@ def forbid!; @qual = :exactly; @limit = 0; @forbidden = true; self; end def invoke(*a, &b) @count += 1 if @forbidden - ::Kernel.raise(SpecFail, "mock received forbidden #{@sym}") + ::Kernel.raise(::SpecFail, "mock received forbidden #{@sym}") end ::Kernel.raise(@raise) if @raise if @yield && b; b.call(*@yield); end From cd303cb01c82a35847d8b007886ffd537b05a07f Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 25 Sep 2026 22:11:25 +0200 Subject: [PATCH 2/3] conformance: forward `!~`, and lower FLOOR to 22136 -- measured, not regressed Two parts. 1. `x.should !~ /re/` (11 sites in core/exception/full_message_spec.rb). Object#!~ is defined as `!(x =~ y)`, so the spelling ran the proxy's POSITIVE `=~` assertion and threw the result away: it asserted the exact INVERSE of what the spec wrote. Spelling `!~` out on the proxy asserts the right thing under MRI. Measured caveat, in the file: rbgo does not dispatch !~ as a method at all -- it compiles `a !~ b` into `!(a =~ b)`, and inlines `!` the same way -- so under rbgo the definition is dead code and those 11 sites stay inverted until the VM sends :!~. That divergence is rbgo's, not the shim's, and it moves no number here. 2. FLOOR 22345 -> 22136. THE NUMBER FELL BECAUSE THE MEASUREMENT GOT STRICTER, NOT BECAUSE THE VM REGRESSED. No Go code changed in this branch. Measured on one host, serially (an 8-way sweep of this corpus is not reproducible: core/kernel/require_spec.rb reads 69 alone and 68 under load), three runs each, same corpus 87b16319, same rbgo binary: before the shim fix : 22340 / 22338 / 22339 (median 22339) after : 22136 / 22137 / 22136 (median 22136) Note the frozen floor of 22345 was already 6 above what this host measures serially for the UNCHANGED shim, so 22136 is the lowest honest reading of the four post-fix runs rather than their median. The movement, per example: 205 examples newly FAIL, 2 newly PASS, and core/random/bytes_spec.rb drifts +-1 between any two runs: 2 - 205 = -203, and the -202 median-to-median difference is that flake. 106 files moved, 103 down and 3 up (one of the three is the flake). (Counting note: matching failures by (kind, description) first read 207 and 4, because two examples changed KIND from error to fail -- a NoMethodError downstream of a vacuous assertion became the assertion's own failure -- and a kind change looks like one loss plus one gain. Keyed on description alone the pair is 205 and 2.) All 205 newly-failing examples were classified with MRI 4.0.5 run through the SAME fixed shim as the per-file ceiling: 205 of 205 are real rbgo defects that the vacuous assertion was hiding, and 0 are assertions the shim cannot express. Examples: core/io/internal_encoding_spec.rb 79 -> 67, MRI 79: IO#internal_encoding returns nil where MRI returns IBM866 core/rational/exponent_spec.rb 24 -> 17, MRI 24: Rational#eql? says false for (1/1).eql?((1/1)) core/builtin_constants/... 17 -> 11, MRI 17: RUBY_VERSION and friends are not frozen core/hash/constructor_spec.rb 20 -> 16, MRI 20: Hash.[] returns Hash where a subclass is required core/numeric/step_spec.rb 208 -> 202, MRI 208: float step yields 12.700000000000001 for 12.7 Both honest gains are examples the shadowing made FAIL, not pass: `x.should === y` reached Object#=== -> the proxy's `==`, comparing a class with an instance (core/exception/case_compare_spec.rb, core/range/case_compare_spec.rb). Co-Authored-By: Claude Opus 5 --- scripts/conformance/rubyspec/FLOOR | 2 +- scripts/conformance/rubyspec/spec_helper.rb | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/conformance/rubyspec/FLOOR b/scripts/conformance/rubyspec/FLOOR index 85a1bae5..83e2038f 100644 --- a/scripts/conformance/rubyspec/FLOOR +++ b/scripts/conformance/rubyspec/FLOOR @@ -1 +1 @@ -22345 +22136 diff --git a/scripts/conformance/rubyspec/spec_helper.rb b/scripts/conformance/rubyspec/spec_helper.rb index 4f7ffca0..bbbf7c00 100644 --- a/scripts/conformance/rubyspec/spec_helper.rb +++ b/scripts/conformance/rubyspec/spec_helper.rb @@ -300,6 +300,19 @@ def >(x); _chk(@o > x, "> #{x.inspect}"); end def <=(x); _chk(@o <= x, "<= #{x.inspect}"); end def >=(x); _chk(@o >= x, ">= #{x.inspect}"); end def =~(x); _chk((@o =~ x) ? true : false, "=~ #{x.inspect}"); end + # `x.should !~ /re/` -- 11 sites in core/exception/full_message_spec.rb. + # Object#!~ is defined as `!(x =~ y)`, so before this commit the spelling ran + # the proxy's POSITIVE `=~` assertion and threw the result away: it asserted + # the exact INVERSE of what the spec wrote. Spelling it out asserts the right + # thing and prints the right message. + # + # Measured caveat: rbgo does not dispatch !~ as a method at all -- it compiles + # `a !~ b` into `!(a =~ b)` (it inlines `!` the same way), where MRI 4.0.5 + # sends :!~ and reaches this definition. So under rbgo those 11 sites still + # run the positive `=~` assertion and this definition is dead code until the + # VM sends :!~. The shim cannot paper over that from here; it is an rbgo/MRI + # divergence of its own, and it is why this line moves no number. + def !~(x); _chk(!(@o =~ x), "!~ #{x.inspect}"); end # BasicObject defines ==, != and equal?, so those three -- and only those # three -- have to be spelled out; every other predicate reaches # #method_missing and is asserted there. @@ -309,9 +322,9 @@ def equal?(x); _chk(@o.equal?(x), "equal? #{x.inspect}"); end # method to forward to, so they stay explicit. def equal(x); _chk(@o.equal?(x), "equal #{x.inspect}"); end def eql(x); _chk(@o.eql?(x), "eql #{x.inspect}"); end - # Every constant below is ::-qualified: from inside a BasicObject subclass, - # MRI does NOT reach the top-level (Object) constants, so a bare `Exception` - # here raises NameError: uninitialized constant ShouldProxy::Exception. rbgo + # Every constant in this class is ::-qualified: from inside a BasicObject + # subclass MRI does NOT reach the top-level (Object) constants, so a bare + # `Exception` here raises NameError: uninitialized constant ShouldProxy::Exception. rbgo # happens to resolve it, which is exactly why the shim has to be run under # MRI as well -- an unqualified constant would work here and break the judge. def raise(*args, &blk) From 95f08ccec1344fa5d3d60725e974eda7b7febf18 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 25 Sep 2026 22:25:33 +0200 Subject: [PATCH 3/3] conformance: set FLOOR from the lane that enforces it -- 22170, not 22136 The first reading came from one developer host, serially. The ratchet lane then measured 22170 on the same commit -- 34 above it. That gap is the environment, not the change: the previous floor of 22345 was a CI number too, and on this host the UNCHANGED shim read 22339, six below it. Comparing like with like, the honest statement of what this branch did to the gate is 22345 -> 22170 on CI, a drop of 175, all of it measurement: this host, serially before 22340/22338/22339 after 22136/22137/22136 ruby/spec ratchet CI before 22345 (frozen) after 22170 (measured) A floor 34 below what the enforcing lane reads is slack, not safety, so take the lane's own number -- which is how README.md says to re-freeze it. Co-Authored-By: Claude Opus 5 --- scripts/conformance/rubyspec/FLOOR | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/conformance/rubyspec/FLOOR b/scripts/conformance/rubyspec/FLOOR index 83e2038f..38853c1d 100644 --- a/scripts/conformance/rubyspec/FLOOR +++ b/scripts/conformance/rubyspec/FLOOR @@ -1 +1 @@ -22136 +22170