Skip to content

Predefined globals, trace_var, rb_feature_p, Kernel#Complex and module_function - #634

Merged
tannevaled merged 11 commits into
mainfrom
wave27/kermod
Sep 23, 2026
Merged

tannevaled merged 11 commits into
mainfrom
wave27/kermod

Conversation

@tannevaled

@tannevaled tannevaled commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

What this is

Wave 27, the predefined-globals / Kernel / Module lane. Every item was read out
of the MRI C source first (the file and function are named in each commit
message) and then checked byte for byte against ruby 4.0.5 on this machine
before any code was written.

Measured

Whole language/ tree (walked with find, not a flat glob — it has
subdirectories), core/kernel and core/module, before and after, per FILE:

area before after
language/predefined_spec.rb 73 pass, 88 fail+error 124 pass, 37 fail+error
language/ (80 files) 2222 pass, 448 fail+error 2273 pass, 397 fail+error
core/kernel (118 files) 1952 pass, 184 fail+error 1978 pass, 158 fail+error
core/module (85 files) 866 pass, 160 fail+error 872 pass, 154 fail+error

Whole language/ + core/ ratchet: 21810 → 21899 (+89), floor 21740 (not
touched). Re-measured from scratch after merging origin/main (#628, #537),
two clean runs a side. Compared per file, not just in total: every file that moves, moves
up
— nothing regressed anywhere in core/ or language/.

core/builtin_constants/builtin_constants_spec.rb   13 -> 17
core/exception/backtrace_spec.rb                   10 -> 11
core/exception/set_backtrace_spec.rb                9 -> 10
core/kernel/Complex_spec.rb                        54 -> 60
core/kernel/open_spec.rb                            9 -> 14
core/kernel/require_spec.rb                        65 -> 69
core/kernel/trace_var_spec.rb                       0 -> 6
core/kernel/untrace_var_spec.rb                     0 -> 2
core/kernel/warn_spec.rb                           18 -> 21
core/module/module_function_spec.rb                20 -> 26
language/predefined_spec.rb                        73 -> 124

core/module/autoload_spec.rb still swings between 57 and 0 between runs, on
this branch exactly as on main — issue #615. Re-run and it comes back; the
totals above are from runs where it reported.

One other file, core/random/bytes_spec.rb, keeps coming out 9 or 8 between
sweeps. Two consecutive full runs of the base binary alone give 8 then 9,
so it is flaky independently of this branch — checked from the base side rather
than assumed, since a loss hiding inside a net gain is exactly what comparing
per file is for.

The ceiling, measured rather than assumed

MRI 4.0.5 was run over the same corpus under the same vendored spec_helper.rb
shim, which gives the score that is actually reachable: 5780 in those three
trees against rbgo's 5028 before this branch. 94 examples in them cannot pass
whatever rbgo does, because the shim differs from real mspec. That measurement
is what says, below, which of the remaining failures are ours.

Fixed

The predefined globals (globals.go, regexp.go) — read from
variable.c (rb_gvar_readonly_setter, and rb_define_hooked_variable's
if (!setter) setter = rb_gvar_readonly_setter, which is what silently makes
every rb_define_virtual_variable(name, getter, 0) read-only), eval.c
(errat_getter/errat_setter, errinfo_getter), re.c (match_setter,
ignorecase_getter), io.c (deprecated_str_setter, must_respond_to,
argf_lineno_setter, set_LAST_READ_LINE), string.c (rb_str_setter,
rb_fs_setter, rb_fs_check), load.c, process.c, ruby.c
(verbose_setter, opt_W_getter, set_arg0) and error.c
(deprecation_warning_enabled). Read-only globals raise NameError naming the
spelling written through; $/ $-0 $\ $, $; type-check; $. takes #to_int;
$stdout/$stderr/$> must answer #write; $~ moves the backref so the
derived $& $ $' $+ $1..follow;$@reads and writes the rescued exception's backtrace;$VERBOSE/$DEBUGreadfalse; $-0 $-F $-I $-v $-w
$-d $>resolve to their shared slot through onecanonicalGvar` on both sides.
+51 on predefined_spec alone.

RUBY_RELEASE_DATE / RUBY_REVISION, frozen Strings, from version.h at
tag v3_4_1 (RUBY_RELEASE_DATE is composed from the release
year/month/day, RUBY_REVISION comes from the generated revision.h — the
tagged commit, 48d4efcb85… of 2024-12-25). builtin_constants 13 → 17, zero
failures left in that file.

Exception#set_backtrace from an Array of Thread::Backtrace::Location —
error.c exc_set_backtrace tries rb_location_ary_to_backtrace FIRST and only
then applies rb_check_backtrace's String rules.

Kernel#trace_var / #untrace_var (variable.c rb_f_trace_var,
rb_f_untrace_var, rb_gvar_set_entry) — did not exist. Hooks fire after the
setter, most recently registered first, never when the setter raised; a String
command is evaluated, not called; untrace_var returns the removed commands.
Both spec files go to a full score. The hook table is a lazily created ivar on
the Kernel module, so a program that never traces a global allocates nothing.

require's rb_feature_p (load.c, with loaded_feature_path) — require
decided "already loaded?" by absolute path, MRI decides it by NAME before
touching the filesystem, matching "#{name}#{e}" or
"#{load_path[j]}/#{name}#{e}". That is why a feature stays loaded once when
$LOAD_PATH is rearranged under it, and why an entry a program pushed by hand
suppresses the require of that spelling.

Kernel#Complex (complex.c nucomp_convert, rewritten in that function's
own order) — a 22-case probe table now agrees with 4.0.5 byte for byte. The
subtle one: complex.c's internal f_real_p is NOT Complex#real?; for a
Complex it is f_zero_p(imag), so it calls Complex(1, 0.0) real although
Complex#real? returns false for every Complex. That is the whole reason
Complex(Complex(1, 0.0), 2) is (1+2i) with an Integer imaginary part while
Complex(1, 0.0) + Complex(0, 2) is (1+2.0i). Also: exception: false is
not a blanket rescue — MRI applies it at exactly three points and lets
nucomp_real_check's "not a real" through, so Complex(:sym, 0, exception: false) really does raise.

Kernel#open redirects to #to_open (io.c rb_f_open) — it tests
rb_respond_to(argv[0], to_open) FIRST, before treating the argument as a path
at all, and hands back whatever #to_open returns; the open-uri protocol is
built on that. rbgo went straight to the path conversion. With a block the
result is yielded and then closed through a CHECKED #close, so a value that
cannot be closed is left alone.

Kernel#warn (error.c rb_warn_m / rb_warn_category) — rb_warn_m
wraps its whole body in if (!NIL_P(ruby_verbose) && argc > 0), so
$VERBOSE = nil makes it a no-op (rbgo wrote regardless); the message is
assembled by rb_io_puts, so an Array argument writes one line per element and
an empty Array writes nothing (rbgo inspected it); the level goes through
NUM2LONG, so a Float or Rational truncates; and a Warning.warn that takes
exactly one argument is called WITHOUT the category keyword
(rb_warning_warn_arity() == 1). With a level, MRI's own no-location fallback
prefix "warning: " is now emitted — which is the truthful answer for every
level here, since rbgo has no line numbers, and it makes the "uplevel too
large" case match byte for byte.

Module#module_function (vm_method.c rb_mod_modfunc /
rb_mod_define_method) — the T_MODULE guard, Class's undef tombstone, the
search_method(rb_cObject, …) fallback that makes module_function :require
work, and define_method honouring the toggle.

Pinned tests updated, each to a value witnessed on MRI

Seven Go tests were asserting behaviour this branch replaces. Every replacement
was checked against ruby 4.0.5 first, and the comment says which C function
decides it: $VERBOSE reads false; $LOAD_PATH = 42 and $ERROR_INFO = …
are NameErrors; a $stdout that never answered #write can no longer be
assigned (the three fallback tests now bind an object that answers #write and
then remove the method, so the branch stays covered and the comments say
plainly that MRI raises NoMethodError where rbgo tolerates); Complex(1, "b") is an ArgumentError; a lone non-real Numeric comes back unchanged; and
warn("x", uplevel: 0) now carries MRI's no-location "warning: " prefix
(the comment says what MRI produces there and why rbgo cannot yet).

The windows-only failure, and which side was wrong

The first push was red on test (windows-latest) alone, on three subtests of
TestFeatureProvided. The matching was right; the fixture was POSIX-shaped.

load.c decides the prefix boundary with a bare name[plen-1] != '/' and
carries no separator conditional anywhere — grep for isdirsep,
FILE_ALT_SEPARATOR and _WIN32 over the file finds zero. The
"and a backslash on Windows" rule is file.c's, whose isdirsep macro drives
is_explicit_relative — a different function, already modelled by
isExplicitRelative with os.IsPathSeparator. So the loaded-features matching
is /-only on every platform and teaching it about backslashes would have been
the divergence.

The divergence was then located rather than inferred. featureProvided has
exactly one platform-dependent step — expandedLoadPath runs each $LOAD_PATH
entry through filepath.Abs — and feeding it a path darwin does not call
absolute reproduces the symptom here:

lp="/lib"          expanded=["/lib"]                    -> true
lp="lib"           expanded=["<cwd>/internal/vm/lib"]   -> false
lp=<t.TempDir()>   expanded=[<same>]                    -> true

/lib on Windows is the middle case: rooted, but with no volume name, so
filepath.IsAbs is false. The eight prefixed cases now build on t.TempDir()
— which both sides of MRI's own comparison have anyway — and the fixture's
premise is asserted in one line rather than assumed. Deliberately not a
runtime.GOOS skip: the matching keeps being exercised on Windows, and gains a
case pinning that a prefix ending in a backslash must not match there (read
from the source, not witnessed — there is no Windows MRI on this host, and the
comment says so).

Worth recording: only the three cases expecting a match actually failed. The
five expecting no match passed on Windows for the wrong reason — the prefix
never matched at all, so the rule each names was never reached. All eight test
what they mean now.

Gates

go build ./..., go vet ./..., full go test ./... green; gofmt -l internal/vm/ empty; CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build ./...
green; and a GOOS=windows go vet ./internal/vm/ to typecheck the tests for
that lane. Coverage run the way CI runs it (-coverpkg over ./internal/...,
./...): the set of functions below 100% is byte-identical to the same run
on the merged origin/main
in this environment — every function this branch adds or
edits is at 100.0%, in a new predefined_globals_internal_test.go plus the
existing suites.

Out of reach from this branch, and why

Reported rather than forced, with the measurement that says so:

  • const_source_location (30 examples, the biggest single gap in
    core/module) is blocked on line numbers, not on a constant-assignment
    hook.
    rbgo has NO line tracking at all: __LINE__ is 0 everywhere, and
    every expectation in that file is [__FILE__, __LINE__ - 1], i.e. -1. A
    perfect const_source_location would answer [path, 0] and still fail all
    30. MRI scores 42 on the file under the same shim. The fix belongs in
    internal/compiler. Same root cause for the 4 Kernel#warn :uplevel
    examples that are still red — they are now ALL of warn_spec's remaining
    failures that are ours — and for language/line_spec.rb (0/6).
  • $! frame scoping (10 examples in predefined_spec) — "cleared when an
    exception is rescued", "set to the value of $! before the begin after a
    successful rescue", and $@'s "is cleared when an exception is rescued".
    vm.curExc is saved and restored per FRAME (vm.go around the rescue
    dispatch); MRI clears errinfo when a rescue clause completes. vm.go is a
    sibling's this wave, so the $@ half is done here and this half is not.
  • **$& $ $' $+"is read-only" (4)** want aSyntaxErrorfromeval("$& = ''"), which is a parse-time rejection (internal/compiler). They now raise NameErrorinstead of silently succeeding, which is nearer but still not equal. The 4 "read-only when aliased" wantalias $x $&amp;to carry the read-only-ness; global aliasing isobject_model.go:709 (vm.globals[new] = vm.gvar(old)`, a snapshot, not an alias), a sibling's
    file.
  • nil = 1 / self = 1 SyntaxError messages (4) — the parser's message,
    internal/compiler.
  • The deprecation-warning examples (5 for $/ $-0 $\ $, $;, 2 for $=, 4 in
    deprecate_constant_spec) are shim artefacts, not divergences.
    error.c deprecation_warning_enabled() needs BOTH a non-nil $VERBOSE and
    Warning[:deprecated], and the category is off by default. Verified: MRI
    4.0.5 prints nothing under exactly the conditions the vendored shim sets up,
    and rbgo now prints nothing too — byte-identical. Real mspec enables the
    category; the vendored spec_helper.rb does not, and it is a sibling's file
    this wave. Making rbgo warn anyway would be a divergence, so it does not.
  • 6 of Complex_spec's 14 and 2 of warn_spec's are the same kind of
    thing: MRI fails them under this shim too (the shim's mock_numeric is a
    plain mock, not a Numeric). Complex_spec is finished at 60 of a reachable
    62.
  • $VERBOSE seeded to false in the globals map — MRI-correct, and it
    surfaces a real defect I could not fix from here.
    $VERBOSE now reads
    false, but rbgo's rb_warn gate still keys on the raw unset slot, so
    rb_warn-class warnings stay silent on a default run where MRI emits them.
    Seeding the slot (a one-line change, tried and measured) makes three
    warnings appear on EVERY run — already initialized constant Slim::Engine,
    Haml::Engine, OptParse — because rbgo's own prelude re-assigns constants
    the Go registration already set (prelude.rb:2261, 2347, 2417 over
    slim.go:36, haml.go:35, optparse.go:79). 48 pinned Go tests then fail
    on the extra stdout. The conformance benefit is zero (measured: 21804 in
    both states, and identical in all three target trees), so the seeding is not
    in this PR. The duplicate definitions are the thing to fix, in files outside
    this wave's allocation.
  • core/kernel/sleep_spec.rb scores 0 because ONE example hangs the whole
    file.
    Fiber.new(blocking: false) { sleep }.resume never returns:
    Kernel#sleep (thread.go:1072) does not consult Fiber.scheduler, and
    fiber.go:307 says so in as many words ("rbgo runs every fiber blocking, so
    nothing consults the scheduler yet"). MRI dispatches to the scheduler's
    #kernel_sleep. Reduced to a 10-line reproduction. That file is worth 15
    examples and the fix is small, but it is thread.go + fiber.go, outside
    this wave's file allocation — flagging rather than reaching in.
  • require does not search only $LOAD_PATH. file.c rb_find_file
    searches the load path ALONE for a bare name; rbgo also tries the requiring
    file's directory and the process CWD first (requireCandidates' default
    arm), which is what "does not load a bare filename unless the current
    working directory is in $LOAD_PATH" (2 examples) is about. Removing the
    fallback is MRI-correct but rbgo's $LOAD_PATH starts EMPTY, so it would
    change how every app-level require resolves. That is a project decision, not
    a conformance patch, so it is only reported.
  • globals.go was not in this wave's allocation list, and neither was it in
    the do-not-touch list; it is where setGVar/specialGvar live, so the
    predefined-globals work is in it. complex.go was offered in the brief and
    taken. builtins.go, require.go, modulefunc.go and
    kernel_introspect.go are mine. Nothing under internal/compiler,
    internal/bytecode, vm.go, defined.go, object_model.go, file*.go,
    dir.go, exception.go, errno.go, go.mod, FLOOR or spec_helper.rb
    was touched.

🤖 Generated with Claude Code

tannevaled and others added 11 commits September 23, 2026 14:10
Every special global went through one untyped slot: `$/ = 1` stored the
Integer, `$: = []` replaced the load path, `$@ = [...]` wrote a global nobody
read, and `$VERBOSE` read back nil. Give each name the setter MRI gives it.

Read from ruby/ruby v3_4_0:

  * variable.c rb_gvar_readonly_setter + rb_define_hooked_variable ("if
    (!setter) setter = rb_gvar_readonly_setter"), which is what makes every
    rb_define_virtual_variable(name, getter, 0) read-only, and the message
    "%s is a read-only variable" naming the spelling written through.
    Read-only per eval.c Init_eval ($!), re.c Init_Regexp ($&, $`, $', $+),
    load.c Init_load ($:/$LOAD_PATH/$-I, $"/$LOADED_FEATURES), io.c Init_IO
    ($<, $FILENAME, $*), process.c Init_process ($?), ruby.c ruby_prog_init
    ($-W) and rb_define_readonly_boolean ($-a, $-l, $-p).
  * io.c deprecated_str_setter and string.c rb_str_setter / rb_fs_setter /
    rb_fs_check: "value of $/ must be String", "value of $; must be String
    or Regexp", each warning under the deprecated category on a non-nil
    assignment.
  * io.c must_respond_to / stdout_setter / stderr_setter: "$stdout must have
    write method, NilClass given", with rb_obj_class (not rb_obj_classname).
  * io.c argf_lineno_setter (NUM2INT, so $. takes #to_int and a Float
    truncates) and set_LAST_READ_LINE.
  * eval.c errat_getter/errat_setter: $@ is rb_get_backtrace(errinfo), and
    assigning it with no rescued exception raises ArgumentError "$! not set"
    while otherwise routing through Exception#set_backtrace.
  * re.c match_setter (Check_Type T_MATCH) so `$~ = md` moves the backref and
    the derived $&, $`, $', $+, $1.. follow it.
  * ruby.c verbose_setter (RTEST(val) ? Qtrue : val), opt_W_getter, and the
    fact that ruby_verbose/ruby_debug start at Qfalse, so $VERBOSE and $DEBUG
    read false rather than nil.
  * error.c deprecation_warning_enabled(): a non-nil $VERBOSE *and*
    Warning[:deprecated], which is off by default — so the warnings stay
    silent on an ordinary run, exactly as MRI is.

The shared-slot spellings ($-0 for $/, $-F for $;, $-I for $:, $-v/$-w for
$VERBOSE, $-d for $DEBUG, $> for $stdout) now resolve through one
canonicalGvar on both the read and the write side, so they cannot drift.

language/predefined_spec.rb: 73 -> 122 passing, 88 failures+errors -> 39.
No other file in language/, core/kernel or core/module moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps left by the previous commit's sweep of the predefined names.

RUBY_RELEASE_DATE and RUBY_REVISION were simply missing, so
Object.const_defined?(:RUBY_RELEASE_DATE) was false and every reference
raised NameError. version.h at ruby/ruby tag v3_4_1 composes
RUBY_RELEASE_DATE from RUBY_RELEASE_YEAR/MONTH/DAY and takes RUBY_REVISION
from the generated revision.h — the tagged commit itself, which for v3_4_1 is
48d4efcb85000e1ebae42004e963b5d0cedddcf2 dated 2024-12-25. Both are frozen
Strings, which core/builtin_constants asserts separately from their class.

Exception#set_backtrace rejected an Array of Thread::Backtrace::Location with
a TypeError. error.c v3_4_0 exc_set_backtrace tries
rb_location_ary_to_backtrace FIRST and only falls back to rb_check_backtrace's
String-or-Array-of-String rules when the value is not a location array, so
copying one exception's backtrace_locations into another is legal and sets
both #backtrace and #backtrace_locations.

language/predefined_spec.rb: 122 -> 124 passing.
core/builtin_constants/builtin_constants_spec.rb: 13 -> 17 (0 failures left).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither existed, so the global-assignment hook was missing entirely and both
names were absent from Kernel.private_instance_methods / Kernel.public_methods.

Read from ruby/ruby v3_4_0 variable.c: rb_f_trace_var (which takes the block
through rb_block_proc() when no command is given — hence the ArgumentError
"tried to create Proc object without a block" — and delegates to
rb_f_untrace_var on an explicit nil command), rb_f_untrace_var (NameError
"undefined global variable $x" for a name with no entry; returns the removed
commands as an Array), and rb_gvar_set_entry, which runs the trace list AFTER
the variable's setter and walks it from the head that rb_f_trace_var pushes
onto — so hooks fire most-recently-registered first, and never fire at all
when the setter raised. A String command is Ruby source, evaluated by
rb_trace_eval/rb_eval_cmd_kw rather than called.

Witnessed against ruby 4.0.5 for each case: registration order (LIFO),
untrace_var's return values with and without a command, the NameError, and
trace_var's nil return.

The hook table hangs off the Kernel module as a lazily created ivar rather
than a VM field, so a program that never traces a global allocates nothing
and Kernel gains no instance variable.

core/kernel/trace_var_spec.rb: 0 -> 6 passing (the file's whole content).
core/kernel/untrace_var_spec.rb: 0 -> 2. No other file moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oved

require decided "already loaded?" by absolute path alone. MRI decides it by
NAME, before it touches the filesystem: load.c v3_4_0 search_required calls
rb_feature_p first, which looks in $LOADED_FEATURES for an entry equal to
either "#{name}#{e}" or "#{load_path[j]}/#{name}#{e}" for an acceptable
(possibly empty) extension e, with loaded_feature_path deciding whether an
entry's prefix really is a load-path directory. That is why a feature stays
loaded once when $LOAD_PATH is rearranged under it, and why an entry a
program pushed by hand — ruby/spec pushes "./load_fixture.rb" — suppresses a
require of that same spelling although nothing ever expanded it. Ported here
including loaded_feature_path's backwards extension scan and its
plen/'/'-boundary rules.

core/kernel/require_spec.rb: 66 -> 68/69 passing (the file varies by a point
between runs; measured both alone and under the parallel sweep).

Four pinned tests were asserting behaviour the predefined-globals commit
replaced; each is updated to the value MRI 4.0.5 actually produces, verified
case by case:

  * $VERBOSE now READS false rather than nil (ruby.c ruby_prog_init binds it
    to ruby_verbose, which starts at Qfalse; `ruby -e 'p $VERBOSE'` prints
    false). rbgo's rb_warn gate still keys on the raw unset slot, so the
    warning under test stays silent — noted in the test.
  * `$LOAD_PATH = 42` is a NameError, so loadPathDirs' non-Array arm is now
    reached by emptying the list instead; the NameError is asserted too.
  * `$ERROR_INFO = …` is a NameError naming the alias, which is what MRI
    reports once "English" is loaded.
  * A $stdout that never answered #write can no longer be assigned, so the
    three curStdout/nativePuts fallback tests bind an object that answers
    #write and then remove the method — the branch stays covered and the
    comments say plainly that MRI raises NoMethodError where rbgo tolerates.

Read-only-ness now follows an English alias while the NameError names the
spelling written through, as rb_gvar_readonly_setter's QUOTE_ID does.

Full `go test ./...` green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…re_p

Every branch the three preceding commits introduced now has a test, and each
expectation is the answer ruby 4.0.5 gives — the read-only NameErrors and the
spelling each one names, the typed setters' TypeErrors and their accepting
arms, $~ moving the derived match globals, $@ reading and writing the rescued
exception's backtrace, $VERBOSE/$DEBUG/$-W's values and the verbose_setter's
truthy collapse, $= reading false and the deprecation warnings appearing only
under Warning[:deprecated], trace_var's LIFO firing and untrace_var's return
values, and the rb_feature_p match table.

Three simplifications fell out of chasing the last uncovered lines, which is
what the exercise is for:

  * untrace_var returned nil for a defined-but-untraced global where MRI
    returns the empty Array rb_f_untrace_var builds from an empty trace list
    (`$tv = nil; p untrace_var(:$tv)` prints [] on 4.0.5). Fixed, with the
    with-a-command case still nil.
  * doRequire's old "an entry spelled exactly like the bare name counts as
    provided" check is dead now that featureProvided runs first and subsumes
    it; removed rather than left as unreachable code.
  * loadedFeaturePath now counts the separator in its prefix length instead of
    reproducing loaded_feature_path's decrement, which removes the caller's
    off-by-one and the defensive re-check that guarded it.

go test -coverpkg over ./internal/... leaves every function this branch adds
or edits at 100.0%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Complex() was a two-line shape check that rejected most of what MRI accepts:
Complex(Complex(3,4), Complex(5,6)) was "can't convert Complex into Complex",
an object answering #to_c was refused, a nil argument reported its class
instead of "nil", an embedded NUL came out as "invalid value for convert()"
rather than "string contains null byte", and exception: false swallowed
errors MRI lets through.

Rewritten as complex.c v3_4_0 nucomp_convert in that function's own order,
because each step rewrites the argument the next one reads: the nil check, the
STRICT String parse (string_to_c_strict — in BOTH argument positions, so
Complex(1, "b") is an ArgumentError, not a TypeError), the k_exact_zero_p
unwrapping of a Complex argument, the lone-Complex short circuit, the #to_c
conversion of a non-Numeric (rb_convert_type, with both of its messages), the
a1 + a2*i rule for non-real Numerics, and finally nucomp_s_new with
nucomp_real_check's "not a real".

exception: false is not a blanket rescue and is no longer implemented as one.
MRI applies it at exactly three points — the nil check, the String parse, and
a second argument that is not an Integer/Float/Rational — and lets everything
else propagate, so `Complex(:sym, 0, exception: false)` really does raise
TypeError on 4.0.5. Confirmed against it.

The subtle one: complex.c's INTERNAL f_real_p is not Complex#real?. For a
Complex it is nucomp_real_p, i.e. f_zero_p(imag), which calls Complex(1, 0.0)
real although Complex#real? returns false for every Complex. That is the whole
reason Complex(Complex(1, 0.0), 2) is (1+2i) with an Integer imaginary part
while Complex(1, 0.0) + Complex(0, 2) is (1+2.0i) — the first never reaches
the f_add path. Both witnessed on 4.0.5; a fRealP helper now carries the rule.

A 22-case probe table now agrees with ruby 4.0.5 byte for byte.
core/kernel/Complex_spec.rb: 54 -> 60 passing. The 6 that remain need mspec's
real mock_numeric (the vendored shim's is a plain mock), and MRI scores 62 on
this file under the same shim — so the file is finished as far as it can go.
No file in core/complex, core/rational, core/numeric, core/integer or
core/float moves.

Two pinned Go tests were asserting the old behaviour and are updated to the
value MRI gives: Complex(1, "b") is ArgumentError, and a lone non-real Numeric
comes back unchanged rather than raising (covered as a value case instead).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…irror

Four gaps in Module#module_function, each read off vm_method.c v3_4_0
rb_mod_modfunc / rb_mod_define_method and witnessed on ruby 4.0.5:

  * rb_mod_modfunc opens with `if (!RB_TYPE_P(mod, T_MODULE)) rb_raise(
    rb_eTypeError, "module_function must be called for modules")`. rbgo
    happily ran it on a Class.
  * Class UNDEFINES it (rb_undef_method(rb_cClass, "module_function")), so it
    is absent from Class.private_instance_methods(true) although Module still
    carries it; rbgo listed it.
  * the named-method search is `me = search_method(m, id, 0); if (!me) me =
    search_method(rb_cObject, id, 0);` — a name the module does not carry is
    looked up on Object, which is what makes `Module.new { module_function
    :require }` work. rbgo raised NameError.
  * rb_mod_define_method reads the same scope visibility a `def` does, so
    under the toggle a define_method'd name is private as an instance method
    and public on the module's singleton. rbgo applied the toggle only in
    OpDefineMethod, so define_method (with a block, a Method or an
    UnboundMethod) escaped it.

The Class tombstone exposed a defensive default in filterVisibility: a
candidate name that resolves to no method counted as PUBLIC, so :module_function
— reaching the walk from Module's table and then resolving to nothing on a
module receiver — started appearing in Module#methods. MRI lists what the
receiver can actually be sent, so an unresolvable candidate is now dropped.
`K23Mod.methods.include?(:module_function)` is false again, as on 4.0.5.

core/module/module_function_spec.rb: 20 -> 26 passing (MRI scores 28 on it
under the same shim; the two left need per-FRAME scope visibility, which rbgo
keeps per module object — an eval'd `module_function` therefore leaks to the
enclosing body).

core/module/autoload_spec.rb swings between 57 and 0 between runs on this
branch as it does on main — issue #615, re-run and it comes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four divergences, all read off error.c v3_4_0 rb_warn_m / rb_warn_category and
witnessed against ruby 4.0.5 in one probe table:

  * rb_warn_m wraps its WHOLE body in `if (!NIL_P(ruby_verbose) && argc > 0)`,
    so $VERBOSE = nil makes Kernel#warn a no-op — nothing written, nothing
    validated, Warning.warn not called. rbgo wrote regardless. ($VERBOSE false
    still warns; only nil, the -W0 level, silences.)
  * the message is assembled by rb_io_puts, not by joining #to_s: an Array
    argument writes one line per element, nested arrays flatten, and an empty
    Array writes nothing. rbgo inspected it — `warn(["a", "b"])` came out as
    `["a", "b"]`. ioPutsValueRec already models rb_io_puts, so it is reused
    rather than reimplemented.
  * with a level, the message carries a "path:lineno: warning: " prefix, or the
    bare "warning: " when rb_ec_backtrace_location_ary yields no location.
    rbgo has no line numbers at all, so no frame can ever supply a path: the
    no-location form is the truthful answer for every level, and it is what MRI
    itself produces for a level past the bottom of the stack — the one uplevel
    case that now matches byte for byte.
  * the level goes through NUM2LONG, so a Float or a Rational truncates where
    rbgo raised TypeError; a Symbol is still "no implicit conversion of Symbol
    into Integer".

Also rb_warn_category's rb_warning_warn_arity(): a Warning.warn that takes
exactly one argument is called WITHOUT the category keyword, so a program that
overrides it with `def Warning.warn(message)` is not handed an argument it
cannot take.

core/kernel/warn_spec.rb: 18 -> 21 passing. The 4 left all want a real
"file:LINE: warning: " prefix; MRI scores 25 on the file under this shim and
its own 4 failures are a different set (the shim's category handling).

One pinned test asserted the old prefix-less uplevel output; it is updated,
with the comment saying what MRI produces there and why rbgo cannot yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
io.c v3_4_0 rb_f_open tests `rb_respond_to(argv[0], to_open)` FIRST, before it
treats the argument as a path at all. An object that answers #to_open is
REDIRECTED: open calls it with the remaining arguments (keywords included,
RB_PASS_CALLED_KEYWORDS) and hands back whatever it returns, whatever that is
— the open-uri protocol is built on this. rbgo went straight to the path
conversion and answered "no implicit conversion of MockObject into String".

With a block MRI runs `rb_ensure(rb_yield, io, io_close, io)`: the result is
yielded and then closed, and io_close is a CHECKED call, so a returned value
that does not answer #close (the specs return a Symbol) is left alone rather
than raising.

core/kernel/open_spec.rb: 9 -> 14 passing. MRI scores 15 on the file under this
shim; the 3 pipe-form errors are its too, and the one left over
(`open(path, "r", 0666, {flags: …})` must raise for a POSITIONAL Hash where the
keyword form is accepted) needs rbgo to tell a keyword bundle from a trailing
Hash at the call site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#634 was red on test (windows-latest) alone, on three subtests of
TestFeatureProvided. The matching is the right side; the test was wrong.

Which side, established before touching anything:

  * load.c v3_4_0 loaded_feature_path decides the prefix boundary with a bare
    `name[plen-1] != '/'` and scans back for the extension with
    `*e != '.' && *e != '/'`. load.c carries NO separator conditional anywhere
    — grep for isdirsep, FILE_ALT_SEPARATOR and _WIN32 over the file finds
    zero. The "and a backslash on Windows" rule is file.c's, whose
    `#define isdirsep(x) ((x) == '/' || (x) == FILE_ALT_SEPARATOR)` drives
    is_explicit_relative — a different function, already modelled by
    isExplicitRelative with os.IsPathSeparator and documented there as
    platform-dependent. So the loaded-features matching is '/'-only on every
    platform, featureProvided/loadedFeaturePath already are, and teaching them
    about backslashes would have been the divergence.

  * The divergence was then LOCATED rather than inferred. featureProvided has
    exactly one platform-dependent step: expandedLoadPath runs each $LOAD_PATH
    entry through featurePath (filepath.Abs + ToSlash). Reproduced on this
    host by feeding it a path darwin does not call absolute:

        lp="/lib"  expanded=["/lib"]                          -> true
        lp="lib"   expanded=["<cwd>/internal/vm/lib"]         -> false
        lp=<t.TempDir()>  expanded=[<same>]                   -> true

    "/lib" on Windows is the middle case: rooted, but with no volume name, so
    filepath.IsAbs is false and Abs joins it onto the working directory. The
    fixture's "/lib/foo.rb" entry then has no matching prefix.

So the eight prefixed cases now build on t.TempDir(), which both sides of
MRI's own comparison have anyway — $LOADED_FEATURES entries come from
rb_file_expand_path and the load path from get_expanded_load_path, both
expanded and absolute. The premise is asserted in one line
(featurePath(dir) == dir) so a platform where it fails says so directly
instead of surfacing as eight confusing mismatches.

This is deliberately NOT a runtime.GOOS skip: that would stop exercising the
matching on the one platform where separators are interesting. It keeps
exercising it, and gains a case that pins the rule there — an entry whose
prefix ends in a BACKSLASH must not match, on either platform. That one is
read from the source, not witnessed: there is no Windows MRI on this host, and
the comment says so.

Worth recording: only the three cases expecting a match actually failed. The
five expecting NO match passed on Windows for the wrong reason — the prefix
never matched at all, so the rule each of them names was never reached. All
eight test what they mean now.

Also merges origin/main (#628, #537). Re-measured from scratch against it:
language/ + core/ 21810 -> 21899 (+89, two clean runs a side); language/
2222 -> 2273; core/kernel 1952 -> 1978; core/module 866 -> 872;
predefined_spec 73 -> 124. Every file that moves still moves up.

go build, go vet, full go test, gofmt, GOOS=wasip1 build and a
GOOS=windows vet of internal/vm all green; the set of functions below 100%
coverage is identical to origin/main's in this environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 68cb53a into main Sep 23, 2026
7 checks passed
@tannevaled
tannevaled deleted the wave27/kermod branch September 23, 2026 16:52
tannevaled added a commit that referenced this pull request Sep 23, 2026
Four units:

  #628  language (compiler): defined?(super) — 18 examples behind one opcode —
        the defined? tags frozen, array/hash literal probing, the two method
        probes MRI distinguishes, block auto-splat through #to_ary, and the
        pattern-matching deconstruct contract   (language 2158 -> 2222)
  #634  predefined globals, trace_var/untrace_var, rb_feature_p, Kernel#Complex,
        Module#module_function, Kernel#warn and #open
                                   (predefined_spec 73 -> 124 passing,
                                    core/kernel +26, core/module +6)
  #629  FileTest re-exported from File's own records, the Errno table from 28
        names to MRI's 158, SystemCallError#initialize/#errno/.===, the real
        realpath/realdirpath walk, File::Stat#atime/#ctime (both had returned
        mtime), #inspect, File#stat/#lstat
                                   (core/file 618 -> 629, core/filetest 45 -> 88,
                                    core/exception 149 -> 174)
  #537  ci: actions/setup-go v7 (unblocked by rebasing; it had been red for
        three weeks on a coverage gate main no longer enforces on that lane)

Measured with scripts/conformance/rubyspec/run.sh against the pinned corpus at
87b1631, 16-way, TIMEOUT=25, three consecutive runs: 21983, 21982, 21981. The
floor is set eleven below the lowest, the same margin rule as the last two
bumps: enough for the run-to-run jitter, not enough to hide the loss of a whole
spec file. #615 remains open and can still cost a file when it fires.

This bump is VM conformance throughout — unlike the previous one, none of it
comes from the harness or from a dependency making new files visible.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant