From 99fe52759eb5851a9ecb58fb13cc0d5e9a458bf9 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 25 Sep 2026 21:47:32 +0200 Subject: [PATCH 1/3] Refinements: Refinement class surface, refined-class superclass, using replaces the frame cref (+12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes in internal/vm/refinement.go, each read out of MRI first: 1. The Refinement class surface. Init_eval undefines append_features, prepend_features and extend_object on rb_cRefinement (ruby/ruby v3_4_0 eval.c:2140-2142), and rb_mod_include / rb_mod_prepend raise TypeError "Refinement#include has been removed" / "...prepend has been removed" for a refinement RECEIVER before looking at their arguments (eval.c:1212, 1266). 2. A refinement's superclass is the class it refines: RCLASS_SET_SUPER(refinement, refinement_superclass(klass)) in rb_mod_refine (eval.c:1465-1509). That is what lets `alias`, #instance_methods, #method_defined? and #instance_method inside a refine block reach the refined class. It does NOT widen dispatch: refinedMethod resolves through lookupOwnOrIncluded, which never walks the superclass, so a refinement still answers only for methods actually written in it. Refinement#ancestors hides the link exactly as MRI's rb_mod_ancestors does, by breaking the walk at the refined class (class.c:1584-1597). 3. `using` points the calling frame's cref entry at the scope the activation landed on. MRI does not merely record the module: mod_using and top_using call rb_vm_cref_replace_with_duplicated_cref (vm.c:1931 → vm_cref_replace_with_duplicated_cref, vm_insnhelper.c:912), which REPLACES the frame's cref with a duplicate carrying the refinements, and every later reader (rb_mod_s_used_refinements, rb_method_entry_with_refinements) walks that chain. rbgo keeps the activation on the scope object, and inside a Module.new / Class.new / class_eval body the frame's cref is the block's TEXTUAL scope, not the body's definee — so Module.used_refinements and Module#instance_method read the wrong scope and saw nothing. The entry is popped with the frame, so nothing outside that body is activated. ruby/spec, measured per file against MRI 4.0.5 through the same shim, whole language+core corpus serial, 22360 -> 22372 (+12), no file down: core/module/refine_spec.rb 25 -> 29 (ceiling 38) core/module/used_refinements_spec.rb 2 -> 4 (ceiling 4, reached) core/refinement/append_features_spec.rb 1 -> 2 (ceiling 2, reached) core/refinement/prepend_features_spec.rb 1 -> 2 (ceiling 2, reached) core/refinement/include_spec.rb 0 -> 1 (ceiling 1, reached) core/refinement/prepend_spec.rb 0 -> 1 (ceiling 1, reached) core/refinement/extend_object_spec.rb 0 -> 1 (ceiling 2) core/refinement/import_methods_spec.rb 13 -> 14 (ceiling 15) Every gain is inside core/module and core/refinement; nothing moved elsewhere, which is the check that activation did not widen. Co-Authored-By: Claude Opus 5 --- internal/vm/refinement.go | 84 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/internal/vm/refinement.go b/internal/vm/refinement.go index 7082bc03..907bf147 100644 --- a/internal/vm/refinement.go +++ b/internal/vm/refinement.go @@ -56,6 +56,50 @@ func (vm *VM) registerRefinements() { // Refinement#import_methods (see refinement_import.go). vm.installRefinementImport() + // MRI UNDEFINES the three module hooks on Refinement, so a refinement is + // never asked to graft itself into a class: Init_eval calls + // rb_undef_method(rb_cRefinement, …) for append_features, prepend_features + // and extend_object (ruby/ruby v3_4_0 eval.c:2140-2142). An `undefined` + // entry rather than a deletion is what keeps the name out of + // Refinement.private_instance_methods(true) while Module keeps its own — the + // same shape bootstrap already uses to undefine them on Class. + for _, n := range []string{"append_features", "prepend_features", "extend_object"} { + vm.cRefinement.methods[n] = &Method{name: n, owner: vm.cRefinement, undefined: true} + } + + // Module#include / Module#prepend refuse outright when the RECEIVER is a + // refinement: rb_mod_include raises TypeError "Refinement#include has been + // removed" and rb_mod_prepend "Refinement#prepend has been removed" before + // they even look at their arguments (ruby/ruby v3_4_0 eval.c:1212 and + // eval.c:1266). Refinement overriding Module's methods is where that + // receiver check lands for a Refinement receiver. + for _, n := range []string{"include", "prepend"} { + msg := "Refinement#" + n + " has been removed" + vm.cRefinement.define(n, func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + raise("TypeError", "%s", msg) + return object.NilV + }) + } + + // Refinement#ancestors stops AT the refined class. A refinement's superclass + // is the class it refines (see refinementFor), which is what lets `alias`, + // #instance_methods and #method_defined? inside a refine block reach the + // refined class's methods — but MRI does not report that link in #ancestors: + // rb_mod_ancestors breaks out of the walk when it reaches the refined class + // (ruby/ruby v3_4_0 class.c:1584-1597), so `refine(Array) { ancestors }` + // answers just [#]. + vm.cRefinement.define("ancestors", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + r := self.(*RClass) + arr := object.NewArray() + for _, k := range vm.ancestors(r) { + if k == r.refinedClass { + break + } + arr.Elems = append(arr.Elems, k) + } + return arr + }) + // Module#refinements returns this module's own refinement modules (not those // of included modules). vm.cModule.define("refinements", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { @@ -80,6 +124,7 @@ func (vm *VM) registerRefinements() { } scope := vm.refinementScope(self) vm.anyRefinements = true + vm.adoptRefinementCref(scope) // Record in call order, de-duplicating so a repeated using is a no-op for // used_modules ordering while still being harmless. for _, m := range scope.usedModules { @@ -119,7 +164,17 @@ func (holder *RClass) refinementFor(target *RClass) *RClass { if r, ok := holder.refinements[target]; ok { return r } - r := newClass("", nil) + // The refinement's SUPERCLASS is the class it refines: MRI's rb_mod_refine + // does `RCLASS_SET_SUPER(refinement, refinement_superclass(klass))` + // (ruby/ruby v3_4_0 eval.c:1497-1500). That link is what makes the refined + // class's methods reachable from inside the refine block, so + // `refine(Array) { alias :orig_count :count }` finds Array#count and + // `refine(Array) { instance_methods }` answers Array's list. It does NOT + // widen dispatch: refinedMethod resolves through lookupOwnOrIncluded, which + // walks own + included modules and never the superclass, so a refinement + // still only answers for methods actually written in it. #ancestors hides + // the link, exactly as MRI's does (see the Refinement#ancestors override). + r := newClass("", target) r.isModule = true r.isRefinement = true r.refinedClass = target @@ -142,6 +197,33 @@ func (vm *VM) refinementScope(self object.Value) *RClass { return vm.cObject } +// adoptRefinementCref points the CALLING frame's cref entry at the scope that +// just recorded a `using`, so a native called from that frame reads the scope +// the activation landed on. +// +// MRI's `using` does not merely record the module: it REPLACES the calling +// frame's cref with a duplicate and hangs the refinements off that duplicate — +// rb_vm_cref_replace_with_duplicated_cref (ruby/ruby v3_4_0 vm.c:1931) → +// vm_cref_replace_with_duplicated_cref (vm_insnhelper.c:912), called from both +// mod_using (eval.c:1547) and top_using (eval.c:1890). Every later reader — +// rb_mod_s_used_refinements, rb_method_entry_with_refinements — then finds them +// by walking that frame's cref chain. +// +// rbgo keeps the activation on the scope OBJECT (refinementScope) rather than +// on a per-frame cref record, and a frame's cref entry is its TEXTUAL lexical +// scope, which is not the same class inside a Module.new / Class.new / +// class_eval body: there the block's cref is where the block was written while +// `using` records on the body's definee. Pointing the entry at the scope is the +// narrowest equivalent of MRI's replacement — the entry is popped with the +// frame, so it activates nothing outside the body that called `using`, and for +// a class/module body or the top level (where the two already agree) it is a +// no-op. +func (vm *VM) adoptRefinementCref(scope *RClass) { + if n := len(vm.frameCrefs); n > 0 && vm.frameCrefs[n-1] != scope { + vm.frameCrefs[n-1] = scope + } +} + // inMethodScope reports whether the innermost interpreted frame (the caller of a // native such as `using`) is a method body rather than a class/module/top-level // body or block — those push a non-empty frame name, methods a non-empty one. From 5fd1ead6381443aaecddd8703721bf894e713cc0 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 25 Sep 2026 22:00:17 +0200 Subject: [PATCH 2/3] Whitebox tests for the refinement changes, including the two narrowness guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six tests covering every branch added in refinement.go: - the three module hooks MRI undefines on Refinement (eval.c:2140-2142); - the two TypeError arms for a refinement receiver (eval.c:1212, 1266); - the superclass link rb_mod_refine installs (eval.c:1497-1500), through the reflection it enables inside a refine block; - both arms of the Refinement#ancestors walk — report the refinement, BREAK at the refined class (class.c:1584-1597); - both arms of adoptRefinementCref: a Module.new body, where the frame cref and the activation scope differ and the entry is repointed, and the top level, where they already agree and nothing is written. The last two are the narrowness guards this lane needs, because a refinement that activates too widely is worse than one that does not activate at all: - TestRefinementSuperDoesNotWidenDispatch: the new superclass link is for reflection only. refinedMethod resolves through lookupOwnOrIncluded, which never walks the superclass, so a name the refinement does not define still reaches the receiver's own definition and an unrelated class is untouched. - TestUsingActivationIsPoppedWithTheFrame: the cref entry `using` writes is popped with the body that called it, so a later body that never called `using` reads no refinement. The functions-below-100% LIST is byte-identical to main's. Co-Authored-By: Claude Opus 5 --- .../vm/wave31_refinement_internal_test.go | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 internal/vm/wave31_refinement_internal_test.go diff --git a/internal/vm/wave31_refinement_internal_test.go b/internal/vm/wave31_refinement_internal_test.go new file mode 100644 index 00000000..cbb6a3e9 --- /dev/null +++ b/internal/vm/wave31_refinement_internal_test.go @@ -0,0 +1,182 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import "testing" + +// TestRefinementModuleHooksAreUndefined pins the three module hooks MRI +// UNDEFINES on Refinement (ruby/ruby v3_4_0 eval.c:2140-2142), which is what +// keeps Refinement.private_instance_methods(true) free of them while Module +// still carries its own. +func TestRefinementModuleHooksAreUndefined(t *testing.T) { + got := runSrc(t, ` +p Refinement.private_instance_methods(true).include?(:append_features) +p Refinement.private_instance_methods(true).include?(:prepend_features) +p Refinement.private_instance_methods(true).include?(:extend_object) +p Module.private_instance_methods(true).include?(:append_features) +`) + want := "false\nfalse\nfalse\ntrue" + if got != want { + t.Errorf("Refinement hook visibility =\n%s\nwant\n%s", got, want) + } +} + +// TestRefinementIncludePrependRemoved covers the two TypeError branches added +// for a refinement RECEIVER: rb_mod_include / rb_mod_prepend raise before +// looking at their arguments (ruby/ruby v3_4_0 eval.c:1212 and eval.c:1266). +func TestRefinementIncludePrependRemoved(t *testing.T) { + cases := []struct{ name, src, want string }{ + { + "include", + `Module.new { refine(String) { include Module.new } }`, + "TypeError: Refinement#include has been removed", + }, + { + "prepend", + `Module.new { refine(String) { prepend Module.new } }`, + "TypeError: Refinement#prepend has been removed", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := refErr(t, c.src); got != c.want { + t.Errorf("%s: got %q, want %q", c.src, got, c.want) + } + }) + } +} + +// TestRefinementSuperIsTheRefinedClass covers the superclass link rb_mod_refine +// installs (ruby/ruby v3_4_0 eval.c:1497-1500): the refined class's methods are +// reachable from inside the refine block, so `alias`, #instance_methods and +// #method_defined? answer as MRI's do. +func TestRefinementSuperIsTheRefinedClass(t *testing.T) { + got := runSrc(t, ` +Module.new do + refine Array do + p method_defined?(:size) + p instance_methods(false) + p instance_method(:size).owner == Array + alias :aliased_size :size + end +end +`) + want := "true\n[]\ntrue" + if got != want { + t.Errorf("refine block reflection =\n%s\nwant\n%s", got, want) + } +} + +// TestRefinementAncestorsStopsAtRefinedClass covers both arms of the +// Refinement#ancestors walk: the refinement itself is reported, and the walk +// BREAKS at the refined class instead of continuing into it — which is how +// MRI's rb_mod_ancestors hides the superclass link (v3_4_0 class.c:1584-1597). +func TestRefinementAncestorsStopsAtRefinedClass(t *testing.T) { + got := runSrc(t, ` +Module.new do + refine Array do + a = ancestors + p a.size + p a.first.equal?(self) + p a.include?(Array) + end +end +`) + want := "1\ntrue\nfalse" + if got != want { + t.Errorf("Refinement#ancestors =\n%s\nwant\n%s", got, want) + } +} + +// TestRefinementSuperDoesNotWidenDispatch is the guard on the superclass link: +// a refinement that reaches the refined class for REFLECTION must not start +// answering sends for methods it does not define. refinedMethod resolves +// through lookupOwnOrIncluded, which never walks the superclass, so an +// unrefined name still reaches the receiver's own definition — and a class the +// refinement does not name is untouched. +func TestRefinementSuperDoesNotWidenDispatch(t *testing.T) { + got := runSrc(t, ` +k = Class.new do + def foo; "plain foo"; end + def bar; "plain bar"; end +end +r = Module.new do + refine k do + def foo; "refined foo"; end + end +end +using r +o = k.new +p o.foo +p o.bar +p o.frozen? +p "str".size +`) + want := `"refined foo" +"plain bar" +false +3` + if got != want { + t.Errorf("dispatch under a refinement =\n%s\nwant\n%s", got, want) + } +} + +// TestUsingAdoptsTheFrameCref covers both arms of adoptRefinementCref. Inside a +// Module.new body the frame's cref is the block's TEXTUAL scope while `using` +// records on the body's definee, so the entry is repointed and +// Module.used_refinements — a native that reads the caller's cref — sees the +// activation. At the top level the two already agree, so nothing is written and +// the same read answers from the unchanged entry. +func TestUsingAdoptsTheFrameCref(t *testing.T) { + got := runSrc(t, ` +r = Module.new do + refine Integer do + def foo; "foo"; end + end +end + +# Module.new body: scope and frame cref differ (the write arm). +Module.new do + p Module.used_refinements.size + using r + p Module.used_refinements.size + p Module.used_refinements.first.target +end + +# Top level: the two already agree (the no-write arm). +p Module.used_refinements.size +using r +p Module.used_refinements.size +`) + want := "0\n1\nInteger\n0\n1" + if got != want { + t.Errorf("used_refinements around using =\n%s\nwant\n%s", got, want) + } +} + +// TestUsingActivationIsPoppedWithTheFrame is the narrowness guard on the cref +// adoption: the entry adoptRefinementCref writes is popped when the body that +// called `using` returns, so a LATER Module.new body — one that never called +// `using` — reads no refinement at all. +func TestUsingActivationIsPoppedWithTheFrame(t *testing.T) { + got := runSrc(t, ` +r = Module.new do + refine Integer do + def foo; "foo"; end + end +end +Module.new do + using r + p Module.used_refinements.size +end +Module.new do + p Module.used_refinements.size +end +`) + want := "1\n0" + if got != want { + t.Errorf("used_refinements after the using body returned =\n%s\nwant\n%s", got, want) + } +} From 808c57d66f53971101d0ad23652c92fd290c91f9 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 25 Sep 2026 22:15:25 +0200 Subject: [PATCH 3/3] refine and using are Module-PRIVATE, and Class undefines refine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two divergences found while measuring the lane against MRI 4.0.5, both in the declarations rather than the behaviour: - Module#refine and Module#using are rb_define_private_method in Init_eval (ruby/ruby v3_4_0 eval.c:2130-2131). rbgo defined both public, so Module.private_instance_methods(true).include?(:refine) answered false where MRI answers true, and a refinement could be opened on a module from outside the body it scopes. - Class UNDEFINES refine: rb_undef_method(rb_cClass, "refine") (eval.c:2137). A refinement is held by a module, never a class; rbgo let `Class.new.send(:refine, String) { }` through where MRI raises NoMethodError. The `undefined` entry (as for Class#module_function) keeps the name out of Class.private_instance_methods(true) while Module keeps its own. ruby/spec is unchanged at 22372 — no example covers either — so this is fidelity, verified against MRI directly, and the whole language+core corpus was re-measured per file to confirm nothing moved in the other direction. Noted while probing, NOT fixed here because the path is outside this lane's files: rbgo does not enforce a PRIVATE Module instance method when it is called with an explicit module receiver — `Module.new.module_function` and `Module.new.refine(String) {}` both go through, while MRI raises NoMethodError. public_send does enforce it (which is what the test asserts), and so does the Object path, which is why `Module.new.using(m)` already raised. The gap is in the class-receiver dispatch in internal/vm/vm.go, not in refinement.go. Co-Authored-By: Claude Opus 5 --- internal/vm/refinement.go | 16 +++++++++++ .../vm/wave31_refinement_internal_test.go | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/internal/vm/refinement.go b/internal/vm/refinement.go index 907bf147..a1f771d0 100644 --- a/internal/vm/refinement.go +++ b/internal/vm/refinement.go @@ -140,6 +140,22 @@ func (vm *VM) registerRefinements() { vm.cObject.methods["using"] = &Method{name: "using", native: usingFn, owner: vm.cObject, vis: visPrivate} vm.cModule.define("using", usingFn) + // refine and using are PRIVATE instance methods of Module — Init_eval uses + // rb_define_private_method for both (ruby/ruby v3_4_0 eval.c:2130-2131) — so + // they are callable only with an implicit receiver, from inside the body + // they affect. Witnessed on 4.0.5: + // `Module.private_instance_methods(true).include?(:refine)` is true. + for _, n := range []string{"refine", "using"} { + vm.cModule.methods[n].vis = visPrivate + } + // Class UNDEFINES refine (rb_undef_method(rb_cClass, "refine"), eval.c:2137): + // a refinement is held by a module, never by a class, and MRI answers + // NoMethodError rather than scoping one to a class body. An `undefined` + // entry (as for Class#module_function) is what keeps the name out of + // Class.private_instance_methods(true) while Module keeps its own. + vm.cClass.methods["refine"] = &Method{ + name: "refine", owner: vm.cClass, vis: visPrivate, undefined: true} + // Module#used_modules lists the modules activated by `using` reachable from // this scope. Called as an instance method, self is the scope module. vm.cModule.define("used_modules", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { diff --git a/internal/vm/wave31_refinement_internal_test.go b/internal/vm/wave31_refinement_internal_test.go index cbb6a3e9..40a2a891 100644 --- a/internal/vm/wave31_refinement_internal_test.go +++ b/internal/vm/wave31_refinement_internal_test.go @@ -180,3 +180,30 @@ end t.Errorf("used_refinements after the using body returned =\n%s\nwant\n%s", got, want) } } + +// TestRefineAndUsingAreModulePrivate pins the declaration MRI gives them: +// rb_define_private_method for Module#refine and Module#using +// (ruby/ruby v3_4_0 eval.c:2130-2131), so both are callable only with an +// implicit receiver — and rb_undef_method(rb_cClass, "refine") (eval.c:2137), +// so a class cannot hold a refinement at all. +func TestRefineAndUsingAreModulePrivate(t *testing.T) { + got := runSrc(t, ` +p Module.private_instance_methods(true).include?(:refine) +p Module.private_instance_methods(true).include?(:using) +p Class.private_instance_methods(true).include?(:refine) +begin + Module.new.public_send(:refine, String) { } +rescue NoMethodError => e + p :refine_is_private +end +begin + Class.new.send(:refine, String) { } +rescue NoMethodError => e + p :class_refine_undefined +end +`) + want := "true\ntrue\nfalse\n:refine_is_private\n:class_refine_undefined" + if got != want { + t.Errorf("refine/using declarations =\n%s\nwant\n%s", got, want) + } +}