From 1e32e7a460ba5b202d72de2d2cc7449dc1e4ca2d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 8 Oct 2026 19:59:00 +0200 Subject: [PATCH 1/4] internal/vm: freeze refuses `def`, not only the reflective definers `freeze` is how a library says "do not monkey-patch me after boot". rbgo implemented the frozen check on every REFLECTIVE definer -- define_method, attr_accessor, remove_method, const_set, ivar set -- and not on the `def` keyword, which is the way anyone actually monkey-patches. So the hardening could be TESTED green with define_method while being absent in practice. Measured, both engines, against MRI 4.0.7: o = Object.new.freeze; def o.foo; end was: no raise o.define_singleton_method(:foo) {} was: no raise c = Class.new.freeze; c.class_eval { def z; end } was: no raise class << c; def bar; end; end was: no raise c.instance_eval("def self.foo; end") was: no raise all five now raise FrozenError with MRI's message, while the eight rows that already agreed (String/Array/Hash mutation, ivar set, define_method, attr_accessor, remove_method, const_set) are unchanged. Two details the measurement settled and reading would not have: PLACEMENT. Every immediate reports frozen, and MRI answers `def 1.foo` with TypeError "can't define singleton", not FrozenError -- so the frozen test runs AFTER the can-this-have-a-singleton test. WHICH FLAG. For a singleton class the flag that counts belongs to the thing it stands for, and rbgo records that in two different fields: `attached` for a per-object singleton, `metaOf` for a class's metaclass. The first version of checkDefineFrozen handled only `attached`, and `class << frozen_class` went straight through it; the test now carries that case explicitly. Closes #798. Co-Authored-By: Claude Opus 5 --- internal/vm/builtins.go | 46 ++++++++++++++++++++++++ internal/vm/frozen_define_test.go | 58 +++++++++++++++++++++++++++++++ internal/vm/object_model.go | 2 ++ internal/vm/singleton.go | 2 ++ internal/vm/vm.go | 2 ++ 5 files changed, 110 insertions(+) create mode 100644 internal/vm/frozen_define_test.go diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 9673ee7f..f5d63154 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -11250,6 +11250,52 @@ func (vm *VM) checkArrayFrozen(a *object.Array) { } } +// checkDefineFrozen refuses a method definition into a frozen definee, which is +// MRI's rb_class_modify_check (eval.c) on the method-definition path. rbgo had +// the frozen check on every REFLECTIVE definer -- define_method, attr_accessor, +// remove_method, const_set -- and not on `def` itself, so the one way everybody +// actually monkey-patches went through a freeze untouched (#798). +// +// For a singleton class the flag that matters belongs to the thing it stands +// for, and MRI names that thing rather than the anonymous singleton. rbgo +// records it in two different fields depending on which kind of singleton it +// is, and missing either one leaves a live bypass: +// +// attached a per-object singleton's object -- `def o.foo` on a frozen o +// metaOf a class's metaclass -- `class << c; def m; end; end` +// +// Measured on 4.0.7: the first says "can't modify frozen Object: #", +// the second "can't modify frozen Class: #". A first version of this +// helper handled only `attached`, and `class << frozen_class` went straight +// through it. +func (vm *VM) checkDefineFrozen(definee *RClass) { + target := object.Value(definee) + switch { + case definee.attached != nil: + target = definee.attached + case definee.metaOf != nil: + target = definee.metaOf + } + if isFrozen(target) { + vm.raiseFrozen(target) + } +} + +// checkSingletonDefineFrozen refuses `def recv.name` / define_singleton_method +// on a frozen receiver. It is the receiver-side counterpart of +// checkDefineFrozen: here the object is in hand and no singleton class need be +// consulted. +// +// Callers run it AFTER the can-this-have-a-singleton test, not before: every +// immediate reports frozen, and MRI answers those with TypeError. Measured on +// 4.0.7, `1.define_singleton_method(:f){}` raises TypeError "can't define +// singleton" while a frozen Object raises FrozenError. +func (vm *VM) checkSingletonDefineFrozen(recv object.Value) { + if isFrozen(recv) { + vm.raiseFrozen(recv) + } +} + // checkHashFrozen is the Hash counterpart of checkArrayFrozen. func (vm *VM) checkHashFrozen(h *object.Hash) { if h.Frozen { diff --git a/internal/vm/frozen_define_test.go b/internal/vm/frozen_define_test.go new file mode 100644 index 00000000..b9dcf7b2 --- /dev/null +++ b/internal/vm/frozen_define_test.go @@ -0,0 +1,58 @@ +package vm_test + +import "testing" + +// TestFreezeStopsDef: freeze is a hardening boundary -- a library freezes a +// module to say "do not monkey-patch me after boot" -- and rbgo refused every +// REFLECTIVE definer (define_method, attr_accessor, remove_method, const_set) +// while letting the `def` keyword straight through (#798). A test of the +// protection written with define_method therefore passed while the protection +// was absent, which is why these cases are written with `def`. +// +// Every expectation here was measured against MRI +// `ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin25]`. +func TestFreezeStopsDef(t *testing.T) { + checkCases(t, []runCase{ + // def on a frozen object's singleton: MRI names the OBJECT, not the + // anonymous singleton class. + {`o = Object.new.freeze +begin; o.instance_eval("def foo; end"); rescue FrozenError => e; puts e.message.split(":").first; end`, + "can't modify frozen Object\n"}, + {`o = Object.new.freeze +begin; o.define_singleton_method(:foo) {}; rescue FrozenError => e; puts e.message.split(":").first; end`, + "can't modify frozen Object\n"}, + // def into a frozen class, through the class body. + {`c = Class.new.freeze +begin; c.class_eval { def z; end }; rescue FrozenError => e; puts e.message.split(":").first; end`, + "can't modify frozen Class\n"}, + // `class << c` reaches the metaclass, whose own frozen flag is false: the + // flag that counts is the class's, through metaOf. A first version of the + // fix handled only the per-object `attached` field and this went through. + {`c = Class.new.freeze +begin; class << c; def bar; end; end; rescue FrozenError => e; puts e.message.split(":").first; end`, + "can't modify frozen Class\n"}, + // def self.foo on a frozen class. + {`c = Class.new.freeze +begin; c.instance_eval("def self.foo; end"); rescue FrozenError => e; puts e.message.split(":").first; end`, + "can't modify frozen Class\n"}, + // A frozen module reports "Module", which is MRI 4.0's capitalised + // wording (3.x said "module"; ruby/spec carries the distinction as + // `ruby_version_is("4.0") ? "Module" : "module"`). + {`m = Module.new.freeze +begin; m.module_eval { def z; end }; rescue FrozenError => e; puts e.message.split(":").first; end`, + "can't modify frozen Module\n"}, + // The precedence that makes the check's PLACEMENT matter: every immediate + // reports frozen, and MRI answers those with TypeError, so the frozen test + // has to run after the can-this-have-a-singleton test, not before. + {`begin; 1.define_singleton_method(:f) {}; rescue => e; p e.class; end`, "TypeError\n"}, + {`begin; :s.define_singleton_method(:f) {}; rescue => e; p e.class; end`, "TypeError\n"}, + // Unfrozen definition still works -- a check that refused everything would + // also pass every case above. + {`c = Class.new +c.class_eval { def z; 7; end } +p c.new.z`, "7\n"}, + {`o = Object.new +def o.foo; 8; end +p o.foo`, "8\n"}, + }) +} diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index e486495f..f53b693a 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -524,6 +524,7 @@ func (vm *VM) singletonClass(o *RObject) *RClass { // lands on main's singleton class — matching MRI — rather than on the definee. func (vm *VM) defineSingletonMethod(recv object.Value, name string, iseq *bytecode.ISeq) { if t, ok := recv.(*RClass); ok { + vm.checkSingletonDefineFrozen(t) t.smethods[name] = &Method{name: name, iseq: iseq, owner: t} bumpMethodSerial() vm.fireSingletonMethodHook(recv, "singleton_method_added", name) @@ -538,6 +539,7 @@ func (vm *VM) defineSingletonMethod(recv object.Value, name string, iseq *byteco if !ok { raise("TypeError", "can't define singleton method %q for %s", name, vm.classOf(recv).name) } + vm.checkSingletonDefineFrozen(recv) sc.methods[name] = &Method{name: name, iseq: iseq, owner: sc} bumpMethodSerial() // adding a singleton method can change what a cached send resolves to vm.fireSingletonMethodHook(recv, "singleton_method_added", name) diff --git a/internal/vm/singleton.go b/internal/vm/singleton.go index 5aae55b2..f8fca7b6 100644 --- a/internal/vm/singleton.go +++ b/internal/vm/singleton.go @@ -44,6 +44,7 @@ func (vm *VM) registerSingleton() { } name := args[0].ToS() if t, ok := self.(*RClass); ok { + vm.checkSingletonDefineFrozen(t) t.smethods[name] = &Method{name: name, proc: body, owner: t} bumpMethodSerial() vm.fireSingletonMethodHook(self, "singleton_method_added", name) @@ -53,6 +54,7 @@ func (vm *VM) registerSingleton() { if !ok { raise("TypeError", "can't define singleton method %q for %s", name, vm.classOf(self).name) } + vm.checkSingletonDefineFrozen(self) sc.methods[name] = &Method{name: name, proc: body, owner: sc} bumpMethodSerial() vm.fireSingletonMethodHook(self, "singleton_method_added", name) diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 7f77f377..7dd7eb4c 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -2754,6 +2754,8 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, push(vm.dispatchSend(recv, bname, callArgs, bblk)) case bytecode.OpDefineMethod: name := iseq.Names[in.A] + // Before anything is written: a frozen definee refuses the def. + vm.checkDefineFrozen(methodDefinee) // methodDefinee is the definee, except under instance_eval/instance_exec // where the def lands on the receiver's singleton class instead. m := &Method{name: name, iseq: iseq.Children[in.B], owner: methodDefinee, vis: methodDefinee.defaultVis} From 496c39df80ad4b0055c91a8e991de6f79b7f53d6 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 8 Oct 2026 20:06:13 +0200 Subject: [PATCH 2/4] internal/vm: a definition guard needs the FLAG, not Ruby's frozen? The conformance ratchet refused the first version at +26 net examples with THREE regressions, all the same shape: core/false/singleton_method_spec.rb: 0 passing, baseline 1 core/nil/singleton_method_spec.rb: 0 passing, baseline 1 core/true/singleton_method_spec.rb: 0 passing, baseline 1 nil, true and false answer `frozen?` with true -- they are immutable -- and MRI nonetheless ALLOWS a singleton method on them, because it lands on NilClass/TrueClass/FalseClass. isFrozen answers Ruby's question, so a guard built on it refuses exactly those three. What a definition guard needs is "was this frozen by someone", which is a flag. explicitlyFrozen reads the flag and nothing else; the values that cannot carry one are not its business, since whether they may have a singleton class is ensureSingleton's question and MRI answers it with TypeError. Measured on MRI 4.0.7 while settling it, including one row that looked like a contradiction and was not: nil/true/false .define_singleton_method OK 1 / :s / 1.5 TypeError: can't define singleton "str".freeze TypeError <- an interned fstring, String.new("ab").freeze FrozenError not a frozen-ness rule [].freeze / {}.freeze / Object.new.freeze FrozenError The three shapes are now negative controls in the test, so the regression cannot come back silently. Co-Authored-By: Claude Opus 5 --- internal/vm/builtins.go | 37 +++++++++++++++++++++++++++++-- internal/vm/frozen_define_test.go | 14 ++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index f5d63154..6afdc34f 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -11276,11 +11276,44 @@ func (vm *VM) checkDefineFrozen(definee *RClass) { case definee.metaOf != nil: target = definee.metaOf } - if isFrozen(target) { + if explicitlyFrozen(target) { vm.raiseFrozen(target) } } +// explicitlyFrozen is isFrozen minus the values that are frozen BY NATURE. +// isFrozen answers Ruby's `frozen?`, where an Integer, a Symbol and nil all say +// true because they are immutable -- and a definition guard built on it refuses +// `def nil.foo`, which MRI ALLOWS (nil/true/false take singleton methods on +// NilClass/TrueClass/FalseClass; measured on 4.0.7, and ruby/spec asserts it in +// core/{nil,true,false}/singleton_method_spec.rb). The first version of this +// guard used isFrozen and cost exactly those three files. +// +// What a definition guard needs is "was this frozen by someone", which is a +// flag, so this reads the flag and nothing else. The values that cannot carry +// one are not refused here at all: whether they may have a singleton class is +// ensureSingleton's question, and MRI answers it with TypeError. +func explicitlyFrozen(v object.Value) bool { + switch x := v.(type) { + case *object.String: + return x.Frozen + case *object.Array: + return x.Frozen + case *object.Hash: + return x.Frozen + case *Regexp: + return x.frozen + case *RObject: + return x.frozen + case *RClass: + return x.frozen + } + if b, ok := v.(boxed); ok { + return b.state().frozen + } + return false +} + // checkSingletonDefineFrozen refuses `def recv.name` / define_singleton_method // on a frozen receiver. It is the receiver-side counterpart of // checkDefineFrozen: here the object is in hand and no singleton class need be @@ -11291,7 +11324,7 @@ func (vm *VM) checkDefineFrozen(definee *RClass) { // 4.0.7, `1.define_singleton_method(:f){}` raises TypeError "can't define // singleton" while a frozen Object raises FrozenError. func (vm *VM) checkSingletonDefineFrozen(recv object.Value) { - if isFrozen(recv) { + if explicitlyFrozen(recv) { vm.raiseFrozen(recv) } } diff --git a/internal/vm/frozen_define_test.go b/internal/vm/frozen_define_test.go index b9dcf7b2..064aca18 100644 --- a/internal/vm/frozen_define_test.go +++ b/internal/vm/frozen_define_test.go @@ -46,6 +46,20 @@ begin; m.module_eval { def z; end }; rescue FrozenError => e; puts e.message.spl // has to run after the can-this-have-a-singleton test, not before. {`begin; 1.define_singleton_method(:f) {}; rescue => e; p e.class; end`, "TypeError\n"}, {`begin; :s.define_singleton_method(:f) {}; rescue => e; p e.class; end`, "TypeError\n"}, + // nil, true and false report frozen? == true -- they are immutable -- and + // MRI nonetheless ALLOWS a singleton method on them: it lands on + // NilClass/TrueClass/FalseClass. A guard built on Ruby's `frozen?` refuses + // these, and the first version of this one did: it cost + // core/{nil,true,false}/singleton_method_spec.rb, three files the + // conformance ratchet caught at 0 passing against a baseline of 1. What a + // definition guard needs is "frozen by SOMEONE", which is a flag, not + // "frozen by nature", which is a type. + {`nil.define_singleton_method(:zz) { 42 } +p nil.zz`, "42\n"}, + {`true.define_singleton_method(:zz) { 42 } +p true.zz`, "42\n"}, + {`def (false).zz; 42; end +p false.zz`, "42\n"}, // Unfrozen definition still works -- a check that refused everything would // also pass every case above. {`c = Class.new From c21d11841b43617af5ebe650a9af07d182d7fc57 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 9 Oct 2026 08:26:07 +0200 Subject: [PATCH 3/4] conformance: lock in the +29 the frozen-def fix earned, and stop the release badge rotting The ratchet reported 20 files improved and nothing else moved -- no regression, no new file, no change of loading -- so BASELINE records them and the README's three claims follow: 23,483 -> 23,512. HOW it was measured matters here. This machine was carrying a desktop load of 12, and scripts/conformance/rubyspec/README.md says in its own words to record the baseline on an unloaded machine, because a heavy file that would pass can exceed the per-file TIMEOUT and be recorded as not loading -- which bakes the load into the gate. The load was not mine to clear (Transmission, glpi-agent, a peer session), so the twenty deltas come from the CI run on 496c39d (ubuntu-latest, TIMEOUT=60) and were applied here rather than re-measured. Three checks before writing: the pre-edit sum matched the 23,483 the README claimed, every one of CI's "baseline" columns matched the value recorded in this file, and the post-edit sum equals the 23,512 CI reported. Separately, the release badge said v0.1.0 while the latest release was v0.2.0, cut four days ago, and no test watched it. Rather than retype the number, the badge now asks shields.io for the latest release: a figure a human maintains by hand is a figure that rots, which is the same reason the example count has a test. That one is guarded, and the guard was checked in both directions -- it fails for the badge and for each of the two prose claims. Co-Authored-By: Claude Opus 5 --- README.md | 8 ++-- scripts/conformance/rubyspec/BASELINE | 53 +++++++++++++++++---------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 8424f8b7..7ed1ee46 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,15 @@ [![Docs](https://img.shields.io/badge/docs-mkdocs--material-9B1C2E)](https://go-embedded-ruby.github.io/docs/) [![License](https://img.shields.io/badge/license-BSD--3--Clause-blue)](LICENSE) [![Go](https://img.shields.io/badge/go-1.27.1%2B-00ADD8)](https://go.dev/dl/) -[![Release](https://img.shields.io/badge/release-v0.1.0-blue)](https://github.com/go-embedded-ruby/ruby/releases/tag/v0.1.0) -[![ruby/spec](https://img.shields.io/badge/ruby%2Fspec-23%2C483%20examples%20passing-1a7f37)](#runtime-conformance--rubyspec) +[![Release](https://img.shields.io/github/v/release/go-embedded-ruby/ruby?label=release&color=blue)](https://github.com/go-embedded-ruby/ruby/releases/latest) +[![ruby/spec](https://img.shields.io/badge/ruby%2Fspec-23%2C512%20examples%20passing-1a7f37)](#runtime-conformance--rubyspec) **A Ruby interpreter written in pure Go, with cgo disabled** — so you can embed it in a Go program with `import "github.com/go-embedded-ruby/ruby"`, or ship it as a single static binary that cross-compiles wherever Go does, with no C toolchain and no libruby. -**How complete is it?** It runs **23,483** of ruby/spec's `language/` + `core/` +**How complete is it?** It runs **23,512** of ruby/spec's `language/` + `core/` examples ([what that counts](#runtime-conformance--rubyspec)) — a large and growing subset of the language, and **not** a drop-in replacement for CRuby. Read [What does not work yet](#what-does-not-work-yet) before you depend on it; the @@ -982,7 +982,7 @@ specification of the language — through `rbgo` under a minimal MSpec-compatibl shim, and gates CI on a **per-file** baseline ([`scripts/conformance/rubyspec/`](scripts/conformance/rubyspec/), in `BASELINE`). No file may pass fewer examples than its record or stop loading, so -measured language conformance moves in one direction; the total, **23,483**, is +measured language conformance moves in one direction; the total, **23,512**, is a derived summary rather than the thing gated. Run it with `scripts/conformance/rubyspec/run.sh`, and see *Runtime conformance* under *Status* for the full breakdown. diff --git a/scripts/conformance/rubyspec/BASELINE b/scripts/conformance/rubyspec/BASELINE index be618a7a..61ecb187 100644 --- a/scripts/conformance/rubyspec/BASELINE +++ b/scripts/conformance/rubyspec/BASELINE @@ -5,6 +5,19 @@ # Recorded 2026-09-28 on main @862a9f3, corpus SPEC_SHA # 87b1631992bd00cf0c4934474766d54dad088191, 2206 language/+core/ *_spec.rb. # +# Updated 2026-10-08 for the frozen-def fix (#800), 23483 -> 23512 over the 20 +# files the ratchet reported improved. The twenty deltas were taken from the +# CI run on 496c39d (ubuntu-latest, TIMEOUT=60) and applied to this file, +# rather than re-measured here: this machine was carrying a desktop load of 12 +# and a pass/fail count is corrupted under load through the per-file timeout -- +# which is what "record it on an unloaded machine" above is about, and the +# reason the CI job raises TIMEOUT. Three checks before writing: the pre-edit +# sum matched the 23483 the README claimed, every one of CI's "baseline" +# columns matched the value recorded here, and the post-edit sum equals the +# 23512 CI reported. CI's verdict listed improved (20) and nothing else -- no +# regression, no new file, no change of loading -- so the deltas are the whole +# move. +# # THE FRAME MATTERS, twice over, and both are why an earlier attempt at this # file was wrong: # @@ -150,7 +163,7 @@ OK core/array/max_spec.rb 12 OK core/array/min_spec.rb 12 OK core/array/minmax_spec.rb 5 OK core/array/minus_spec.rb 10 -OK core/array/multiply_spec.rb 13 +OK core/array/multiply_spec.rb 14 OK core/array/new_spec.rb 20 OK core/array/none_spec.rb 2 OK core/array/one_spec.rb 2 @@ -590,7 +603,7 @@ OK core/exception/equal_value_spec.rb 10 OK core/exception/errno_spec.rb 8 OK core/exception/exception_spec.rb 10 OK core/exception/exit_value_spec.rb 1 -OK core/exception/frozen_error_spec.rb 0 +OK core/exception/frozen_error_spec.rb 1 OK core/exception/full_message_spec.rb 8 OK core/exception/hierarchy_spec.rb 0 OK core/exception/inspect_spec.rb 5 @@ -801,7 +814,7 @@ OK core/float/gt_spec.rb 6 OK core/float/gte_spec.rb 6 OK core/float/hash_spec.rb 2 OK core/float/infinite_spec.rb 4 -OK core/float/inspect_spec.rb 0 +OK core/float/inspect_spec.rb 1 OK core/float/lt_spec.rb 6 OK core/float/lte_spec.rb 6 OK core/float/magnitude_spec.rb 1 @@ -1113,7 +1126,7 @@ OK core/kernel/chop_spec.rb 0 OK core/kernel/class_spec.rb 3 OK core/kernel/clone_spec.rb 30 OK core/kernel/comparison_spec.rb 5 -OK core/kernel/define_singleton_method_spec.rb 7 +OK core/kernel/define_singleton_method_spec.rb 8 OK core/kernel/display_spec.rb 0 OK core/kernel/dup_spec.rb 17 OK core/kernel/enum_for_spec.rb 0 @@ -1278,7 +1291,7 @@ OK core/math/sqrt_spec.rb 8 OK core/math/tan_spec.rb 8 OK core/math/tanh_spec.rb 7 OK core/method/arity_spec.rb 22 -OK core/method/call_spec.rb 5 +OK core/method/call_spec.rb 6 OK core/method/case_compare_spec.rb 1 OK core/method/clone_spec.rb 3 OK core/method/compose_spec.rb 14 @@ -1292,14 +1305,14 @@ OK core/method/inspect_spec.rb 1 OK core/method/name_spec.rb 3 OK core/method/original_name_spec.rb 8 OK core/method/owner_spec.rb 4 -OK core/method/parameters_spec.rb 51 +OK core/method/parameters_spec.rb 52 OK core/method/private_spec.rb 1 OK core/method/protected_spec.rb 1 OK core/method/public_spec.rb 1 OK core/method/receiver_spec.rb 3 OK core/method/source_location_spec.rb 12 OK core/method/super_method_spec.rb 6 -OK core/method/to_proc_spec.rb 8 +OK core/method/to_proc_spec.rb 9 OK core/method/to_s_spec.rb 15 OK core/method/unbind_spec.rb 6 OK core/module/alias_method_spec.rb 22 @@ -1431,7 +1444,7 @@ OK core/numeric/fdiv_spec.rb 6 OK core/numeric/finite_spec.rb 0 OK core/numeric/floor_spec.rb 1 OK core/numeric/i_spec.rb 3 -OK core/numeric/imag_spec.rb 0 +OK core/numeric/imag_spec.rb 1 OK core/numeric/imaginary_spec.rb 2 OK core/numeric/infinite_spec.rb 0 OK core/numeric/integer_spec.rb 1 @@ -1446,7 +1459,7 @@ OK core/numeric/polar_spec.rb 4 OK core/numeric/positive_spec.rb 5 OK core/numeric/quo_spec.rb 10 OK core/numeric/real_spec.rb 3 -OK core/numeric/rect_spec.rb 0 +OK core/numeric/rect_spec.rb 1 OK core/numeric/rectangular_spec.rb 5 OK core/numeric/remainder_spec.rb 5 OK core/numeric/round_spec.rb 1 @@ -1617,7 +1630,7 @@ OK core/queue/pop_spec.rb 20 OK core/queue/push_spec.rb 1 OK core/queue/shift_spec.rb 1 OK core/queue/size_spec.rb 1 -OK core/random/bytes_spec.rb 8 +OK core/random/bytes_spec.rb 9 OK core/random/default_spec.rb 1 OK core/random/equal_value_spec.rb 2 OK core/random/new_seed_spec.rb 3 @@ -1828,7 +1841,7 @@ OK core/string/comparison_spec.rb 19 OK core/string/concat_spec.rb 29 OK core/string/count_spec.rb 8 OK core/string/crypt_spec.rb 0 -OK core/string/dedup_spec.rb 0 +OK core/string/dedup_spec.rb 1 OK core/string/delete_prefix_spec.rb 12 OK core/string/delete_spec.rb 13 OK core/string/delete_suffix_spec.rb 12 @@ -2018,7 +2031,7 @@ OK core/thread/backtrace/location/to_s_spec.rb 1 OK core/thread/backtrace_locations_spec.rb 18 OK core/thread/backtrace_spec.rb 11 OK core/thread/current_spec.rb 3 -OK core/thread/each_caller_location_spec.rb 5 +OK core/thread/each_caller_location_spec.rb 6 OK core/thread/element_reference_spec.rb 5 OK core/thread/element_set_spec.rb 5 OK core/thread/exit_spec.rb 2 @@ -2161,7 +2174,7 @@ OK core/unboundmethod/bind_spec.rb 8 OK core/unboundmethod/clone_spec.rb 3 OK core/unboundmethod/dup_spec.rb 3 OK core/unboundmethod/eql_spec.rb 1 -OK core/unboundmethod/equal_value_spec.rb 15 +OK core/unboundmethod/equal_value_spec.rb 17 OK core/unboundmethod/hash_spec.rb 3 OK core/unboundmethod/inspect_spec.rb 1 OK core/unboundmethod/name_spec.rb 2 @@ -2192,7 +2205,7 @@ OK language/class_spec.rb 40 OK language/class_variable_spec.rb 13 OK language/comment_spec.rb 1 OK language/constants_spec.rb 88 -OK language/def_spec.rb 61 +OK language/def_spec.rb 63 OK language/defined_spec.rb 256 OK language/delegation_spec.rb 12 OK language/encoding_spec.rb 5 @@ -2204,14 +2217,14 @@ OK language/hash_spec.rb 29 OK language/heredoc_spec.rb 10 OK language/if_spec.rb 50 OK language/it_parameter_spec.rb 15 -OK language/keyword_arguments_spec.rb 14 +OK language/keyword_arguments_spec.rb 20 OK language/lambda_spec.rb 56 OK language/line_spec.rb 6 OK language/loop_spec.rb 5 OK language/magic_comment_spec.rb 27 OK language/match_spec.rb 8 OK language/metaclass_spec.rb 21 -OK language/method_spec.rb 157 +OK language/method_spec.rb 160 OK language/module_spec.rb 14 OK language/next_spec.rb 31 OK language/not_spec.rb 9 @@ -2221,7 +2234,7 @@ OK language/optional_assignments_spec.rb 74 OK language/or_spec.rb 12 OK language/order_spec.rb 5 OK language/pattern_matching_spec.rb 103 -OK language/precedence_spec.rb 29 +OK language/precedence_spec.rb 30 OK language/predefined/data_spec.rb 0 OK language/predefined/toplevel_binding_spec.rb 0 OK language/predefined_spec.rb 141 @@ -2241,15 +2254,15 @@ OK language/regexp/modifiers_spec.rb 6 OK language/regexp/repetition_spec.rb 11 OK language/regexp/subexpression_call_spec.rb 3 OK language/regexp_spec.rb 18 -OK language/rescue_spec.rb 52 +OK language/rescue_spec.rb 53 OK language/retry_spec.rb 3 OK language/return_spec.rb 37 OK language/safe_navigator_spec.rb 9 OK language/safe_spec.rb 1 -OK language/send_spec.rb 71 +OK language/send_spec.rb 72 OK language/singleton_class_spec.rb 47 OK language/source_encoding_spec.rb 0 -OK language/string_spec.rb 24 +OK language/string_spec.rb 25 OK language/super_spec.rb 58 OK language/symbol_spec.rb 11 OK language/throw_spec.rb 9 From 9ae6a3a0c98dbd436a4bfc180ebbaa35237dcc64 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 9 Oct 2026 09:28:59 +0200 Subject: [PATCH 4/4] internal/vm: freeze marks a Regexp too, which the coverage gate found by asking The macos lane refused the previous commit: -- NEWLY BELOW 100% (1) -- internal/vm/builtins.go explicitlyFrozen 90.0% (was fully covered) One branch, `case *Regexp`. A named function is a QUESTION -- is the branch dead, or merely unreached? -- and answering it found a defect rather than dead code. Unreached, not dead: a literal /a/ is born frozen in both engines, so defining on it already raised, and the branch runs. What was missing was a test. Writing that test turned up the second half. Object#freeze had no *Regexp arm while isFrozen had read Regexp.frozen all along, so: Regexp.new("a").freeze.frozen? rbgo: false MRI: true The literal case hid it, because a literal needs no freeze call to be frozen. Found by comparing the two switches TERM BY TERM rather than spot-checking: Regexp was the only type present in one and absent from the other, and a sampled comparison would have had a good chance of missing exactly it. Four cases, including the negative control that an unfrozen Regexp still takes a singleton method -- without it, marking everything frozen would pass. Co-Authored-By: Claude Opus 5 --- internal/vm/builtins.go | 8 ++++++++ internal/vm/frozen_define_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6afdc34f..a3483f32 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -326,6 +326,14 @@ func (vm *VM) bootstrap() { } case *RClass: o.frozen = true + case *Regexp: + // isFrozen has read Regexp.frozen all along and freeze never wrote it, + // so `Regexp.new("a").freeze.frozen?` answered false where MRI says + // true. A literal /a/ is born frozen, which is how this stayed + // invisible: the common case was already right. Found by comparing + // this switch against isFrozen's TERM BY TERM -- Regexp was the only + // type in one and not the other, which a spot check would have missed. + o.frozen = true default: // Bound/UnboundMethod (and any future boxed value) freeze via state. if b, ok := self.(boxed); ok { diff --git a/internal/vm/frozen_define_test.go b/internal/vm/frozen_define_test.go index 064aca18..0ef6c6b7 100644 --- a/internal/vm/frozen_define_test.go +++ b/internal/vm/frozen_define_test.go @@ -70,3 +70,31 @@ def o.foo; 8; end p o.foo`, "8\n"}, }) } + +// TestAFrozenRegexpRefusesDefToo covers the one branch of explicitlyFrozen the +// first version of this change left untested -- the coverage gate named it, at +// 90.0%, and the question a named function asks is whether the branch is DEAD +// or merely unreached. It was unreached: a literal /a/ is born frozen in both +// engines, and defining on it already raised. +// +// Measuring it turned up a second thing. `Regexp.new("a").freeze.frozen?` +// answered FALSE: Object#freeze had no *Regexp arm while isFrozen had read +// Regexp.frozen all along, so the two disagreed. The literal case hid it, +// because a literal needs no freeze call to be frozen. +func TestAFrozenRegexpRefusesDefToo(t *testing.T) { + checkCases(t, []runCase{ + // freeze must actually mark it. This is the half that was broken. + {`r = Regexp.new("a"); p r.frozen?; r.freeze; p r.frozen?`, "false\ntrue\n"}, + // ... and then refuse a definition, naming the Regexp as MRI does. + {`r = Regexp.new("a").freeze +begin; r.define_singleton_method(:z) {}; rescue FrozenError => e; puts e.message.split(":").first; end`, + "can't modify frozen Regexp\n"}, + // A literal is born frozen in both engines, so it refuses without a + // freeze call -- this is the case that was already passing and hid the + // one above. + {`begin; /a/.define_singleton_method(:z) {}; rescue FrozenError => e; puts e.message.split(":").first; end`, + "can't modify frozen Regexp\n"}, + // The negative control: an unfrozen Regexp still takes a singleton method. + {`r = Regexp.new("a"); r.define_singleton_method(:z) { 7 }; p r.z`, "7\n"}, + }) +}