Skip to content

lang: defined?(super) and frozen tags, block auto-splat via #to_ary, pattern-matching deconstruct contract - #628

Merged
tannevaled merged 6 commits into
mainfrom
wave27/lang
Sep 23, 2026
Merged

tannevaled merged 6 commits into
mainfrom
wave27/lang

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Wave 27 — the compiler-shaped part of language/

Six changes to how rbgo lowers and runs defined?, block parameter binding,
pattern matching and backtick literals. Every construct was read in MRI's own
source before being written, and checked byte-for-byte against ruby 4.0.5 on
the machine.

Measured

language/ 2158 → 2222 passing examples (80 spec files, found recursively —
the tree has language/regexp/ and language/predefined/ subdirectories).

Measured over language/ and core/ together, 2206 files, so a gain in one
place cannot hide a loss in another. Every file that moved:

file before after
language/defined_spec.rb 213 255
language/block_spec.rb 139 152
language/pattern_matching_spec.rb 95 102
language/execution_spec.rb 0 2
core/random/bytes_spec.rb 8 9

No other file moves in either direction. core/random/bytes_spec.rb varies by
one between runs on main and on this branch alike (sampled 5 runs each).

Ratchet total: 21750 → 21815 (floor 21740). FLOOR is left alone.

What changed

defined?(super) answered "expression" — 18 examples, the largest single
item in defined_spec. NODE_SUPER and NODE_ZSUPER are both DEFINED_ZSUPER
(compile.c v3_4_0:6139-6143), lowered as putnil + defined ZSUPER: the super
ARGUMENTS are never evaluated, only the existence of the method super would
reach is tested. A new OpDefinedSuper answers it from the same resolution the
super CALL uses — invokeSuper is split so superMethod locates the target
without invoking it, which is what vm_defined's DEFINED_ZSUPER does
(rb_method_boundp over vm_search_normal_superclass of the frame's
defined_class, vm_insnhelper.c v3_4_0:5505-5518). A block and a
define_method body answer for their home method.

The defined? tags were not frozen — ~12 examples. MRI builds every answer
with rb_iseq_defined_string, i.e. rb_fstring_cstr (iseq.c v3_4_0:3692). Both
halves now go through one constructor, bytecode.DefinedTag. OpPushConst had
to stop cloning a frozen String constant: String.Dup returns an UNFROZEN copy,
so the freeze never survived the push. Sharing is what MRI does — putobject
over the shared fstring for a frozen literal, putstring (which duplicates)
only for a mutable one.

An array or hash literal was flatly "expression". MRI probes every element
and branches to the whole expression's nil label on the first undefined one
(compile.c v3_4_0:5975-6019), so defined?([NonExistentConstant, Array]) is nil.

__FILE__ / __LINE__ / __ENCODING__ answered "method". They are
keywords, parsed to NODE_FILE / NODE_LINE / NODE_ENCODING, which fall
through to DEFINED_EXPR; go-ruby-parser hands them over as bare calls, so the
compiler has to recognise them. A nested defined?(defined?(x)) was nil for the
same reason.

defined? had one method probe where MRI has two. DEFINED_FUNC (no
receiver written) counts ANY visibility; DEFINED_METHOD (a receiver written,
self. included) screens it — private is not defined?, protected only for a
kin self. OpDefinedMethod's B operand now carries which, and the explicit
form asks visBlockedKind, the helper an explicit-receiver send already uses.
Neither probe consulted respond_to_missing?; both do now. The operator fast
path over-claimed: defined?(nil / 2) was "method" while nil / 2 raises
NoMethodError here exactly as in MRI. TestDefinedOperatorMethod asserted that
over-claim for a plain object; MRI 4.0.5 prints nil, so the expectation is
corrected to MRI's answer with the witness recorded.

A defined? whose value is discarded ran its receiver. MRI compiles
NODE_DEFINED to nothing when popped (compile.c v3_4_0:11332-11336). The
operand's locals are still declared, because MRI declares them in the parser
whatever the context does with the answer.

A block's auto-splat was a type assertion — 13 examples. MRI runs the lone
argument through rb_check_array_type (args_check_block_arg0, vm_args.c
v3_4_0:97-119): #to_ary converts, an overridden #respond_to? is consulted
first, a non-Array answer is a TypeError, an exception propagates, and an object
that declines stays whole. masgnExpandOperand is already that same
rb_check_array_type, written for expandarray, so the auto-splat asks it
rather than restating the conversion.

Pattern matching — 7 examples. A container pattern's constant was tested
with is_a?; MRI compiles it to checkmatch VM_CHECKMATCH_TYPE_CASE —
Module#=== — so a redefined #=== decides (a bare constant pattern too). A
hash pattern IGNORED its constant entirely; the guard is now emitted, and FIRST,
before the protocol call. #deconstruct_keys was always passed nil; it now gets
the key list the pattern will ask for (nil only for a named **rest or **nil,
witnessed against 4.0.5 for all four shapes). A #deconstruct returning a
non-Array (or #deconstruct_keys a non-Hash) was a failed match; it is a
TypeError. And #deconstruct was called once per clause; it is now memoised for
the whole case with MRI's three states — nil not tried, false does not answer,
else the Array — reaching top-level array and find patterns and their
alternatives only.

A backtick literal ran the shell from the opcode — 2 examples. MRI compiles
NODE_XSTR to a CALL of ` on self with the command fstring, so a
redefined ` receives it, frozen. `Kernel#`` still does the work, so the
two spellings cannot drift apart.

Gates

  • CGO_ENABLED=0 go build ./..., go vet ./..., and the FULL
    CGO_ENABLED=0 GOWORK=off go test ./... are green.
  • Coverage measured the way CI does (-coverpkg over ./internal/..., whole
    suite): the functions below 100% are EXACTLY the sixteen origin/main has on
    the same machine. Nothing this branch adds or touches is below 100%, and no
    function dropped. Every new branch has a whitebox test
    (internal/compiler/wave27_defined_test.go, internal/vm/wave27_lang_test.go).
  • gofmt -l internal/ empty; GOOS=wasip1 GOARCH=wasm and GOOS=linux build.

Out of reach in this lane, reported rather than forced

  • Keyword/positional separation (Ruby 3 semantics) — the biggest remaining
    structural gap. foo(1, 2, 3, {key: 42}) on def foo(a, b, c, **hsh) must
    raise ArgumentError; rbgo converts the trailing Hash to keywords. MRI decides
    this at the CALL SITE (VM_CALL_KWARG / VM_CALL_KW_SPLAT), and rbgo loses
    the distinction before bindKeywords sees it: the flag would have to travel
    through every send path. Costs 2 examples in method_spec and more in
    keyword_arguments_spec.
  • Post-parameters that are not after a splat — def m(a = 1, b),
    { |a = 5, b, c, d| }. bytecode.ISeq has NumRequired and SplatIndex but
    no post count for the no-splat case, so the optional eats the leading
    argument. Worth ~10 examples across block_spec, def_spec, send_spec, but
    the arity and #parameters reflection that must agree with it lives in
    method.go, method_reflect*.go and aot/freeze.go, outside this wave's
    file assignment. Doing the binding alone would leave #arity lying.
  • Parser gaps (go-ruby-parser), each costing examples here:
    • { |foo; foo| } must be a SyntaxError from the parser (parse.y v3_4_0:13621,
      new_bv → shadowing_lvar_0); also the identically-named-arguments,
      numbered-parameter and it-parameter SyntaxErrors.
    • no redo at all — it lexes as an identifier, so defined?(redo) is nil
      where MRI says "expression", and redo itself does not work.
    • no interpolation in a backtick / %x literal: ast.XStr carries a plain
      Command string, so `echo #{x}` reaches the shell verbatim and #
      starts a comment there (4 examples in execution_spec).
    • a and a() are the same ast.Call, so def m(a = a()) cannot call the
      method (2 examples).
    • ast.CaseIn.Else is nil for an EMPTY else, indistinguishable from no
      else, so case 0; in 1; else; end raises NoMatchingPatternError.
  • Kernel#=~ / Kernel#!~ are missing, so defined?(x !~ 2) is nil where
    MRI says "method". Fixing defined? alone would report a method that then
    raises; the method belongs in builtins.go, a sibling's file this wave.
  • The void-context warning (possibly useless use of defined? in void context) is emitted by MRI's parser under $VERBOSE. rbgo's compiler has no
    warning channel, and adding one touches the several *_open.go files that
    call compiler.Compile.
  • __LINE__ is always 0 — the ISeq carries no line map. That is a feature,
    not a fix, and it costs line_spec entirely plus backtrace examples elsewhere.

Noticed while measuring, not caused here

core/module/autoload_spec.rb aborts in about 1 run in 10 — on this branch
and on origin/main alike (12 runs each) — with
panic: runtime error: slice bounds out of range [:-1] repanicking as
interface conversion: runtime.boundsError, not vm.RubyError. It comes from the
concurrent autoload examples. An aborted run contributes 0 passing examples, so
a single measurement can read as a 57-example regression; worth an issue against
the autoload/require lane.

🤖 Generated with Claude Code

tannevaled and others added 6 commits September 23, 2026 14:10
`defined?` diverged from MRI in four ways that language/defined_spec.rb
pins. Read for this change: compile.c v3_4_0 `defined_expr0` (5952-6172)
and `compile_defined_expr` (6213), iseq.h's `enum defined_type`
(309-326), `rb_iseq_defined_string` (iseq.c 3692) and `vm_defined`
(vm_insnhelper.c 5447-5527).

defined?(super) answered "expression". NODE_SUPER and NODE_ZSUPER are
both DEFINED_ZSUPER (compile.c 6139-6143), lowered as `putnil` +
`defined ZSUPER` — the super ARGUMENTS are never evaluated, only the
existence of the method super would reach is tested. Adds OpDefinedSuper
and answers it from the same resolution the super CALL uses: invokeSuper
is split so `superMethod` locates the target without invoking it, which
is what vm_defined's DEFINED_ZSUPER does (rb_method_boundp over
vm_search_normal_superclass of the frame's defined_class,
vm_insnhelper.c 5505-5518). A block and a define_method body answer for
their home method, because they already carry its name and definee.

The tags were not frozen. MRI builds every answer with
rb_iseq_defined_string, i.e. rb_fstring_cstr — a frozen, deduplicated
String — and the spec asserts `.frozen?` on each tag it names. Both
halves now go through one constructor, bytecode.DefinedTag: the compiler
for the syntactically-decided tags, the VM for the ones only it can
decide. Doing one half alone would have left defined? half-frozen.
OpPushConst had to stop cloning a frozen String constant: String.Dup
returns an UNFROZEN copy, so the freeze never survived the push. Sharing
is what MRI does — `putobject` over the shared fstring for a frozen
literal, `putstring` (which duplicates) only for a mutable one — and a
frozen String cannot be told apart by sharing. No other constant is
frozen today, so nothing else changes shape.

An array or hash literal was flatly "expression". MRI probes every
element with defined_expr0 and branches to the whole expression's nil
label on the first undefined one (compile.c 5975-5993 for NODE_HASH /
NODE_LIST, 6011-6019 for NODE_SPLAT), so
`defined?([NonExistentConstant, Array])` is nil. An empty container
(NODE_ZLIST) has nothing to probe and stays "expression".

__FILE__, __LINE__ and __ENCODING__ answered "method". They are keywords
in MRI's grammar, parsed to NODE_FILE / NODE_LINE / NODE_ENCODING, which
sit with the literals falling through to DEFINED_EXPR (compile.c
5996-6008); go-ruby-parser hands them over as bare calls, so the
compiler has to recognise them.

language/defined_spec.rb 213 -> 250 passing; no other file in
language/ moves (2158 -> 2195 over the tree).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read for this change: vm_defined's DEFINED_FUNC / DEFINED_METHOD arms
(vm_insnhelper.c v3_4_0:5472-5498), defined_expr0's method-dispatch case
(compile.c 6072-6119) and NODE_DEFINED in iseq_compile_each0
(compile.c 11332-11336).

MRI keeps two method probes apart and rbgo had one. DEFINED_FUNC — no
receiver written — is rb_ec_obj_respond_to with the include-private
flag, so a private method called bare is "method". DEFINED_METHOD — a
receiver written, `self.` included — screens the method entry's
visibility: private is not defined?, protected only when the calling
self is a kind of the method's owner. OpDefinedMethod's B operand now
carries which one, and the explicit form asks visBlockedKind, the same
helper an explicit-receiver send uses, rather than restating the rule.
`defined?(Object.print)` and `defined?(obj.protected_method)` were
"method" and are now nil.

Neither probe consulted respond_to_missing?, so a proxy serving a method
through method_missing read as undefined; MRI reaches it through
check_respond_to_missing (5496) and rb_obj_respond_to's own fallback.

The operator fast path over-claimed. Arithmetic operators have no
method-table entry in rbgo, so respondsTo answers for them directly —
but it answered for EVERY receiver, and `defined?(nil / 2)` was
"method" while `nil / 2` raises NoMethodError here exactly as in MRI.
The fallback is now taken only for a receiver the fast path computes on;
nil, true/false, a Symbol and a user object with no built-in backing go
to the respond_to_missing? path, which is where MRI sends them.
TestDefinedOperatorMethod asserted the over-claim for a plain object;
MRI 4.0.5 prints nil for `defined?(Plain.new + 1)`, so the expectation
is corrected to that, with the witness recorded.

A `defined?` whose value is discarded compiled and ran its receiver.
MRI compiles NODE_DEFINED to nothing when popped, and ruby/spec pins it:
a bare `defined?(DefinedSpecs.side_effects / 2)` statement must not run
side_effects. The operand's locals are still declared, because MRI
declares them in the parser whatever the context does with the answer.

A nested `defined?(defined?(x))` probed self for a method named
"defined?" and answered nil; NODE_DEFINED is not one of defined_expr0's
cases, so it falls through to "expression".

language/defined_spec.rb 250 -> 255 passing. Measured over language/
AND core/ (2206 files): 21788 -> 21793, no file loses an example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A block taking several parameters and handed ONE argument splats it.
rbgo tested the argument with a type assertion, so only a real Array was
splatted. MRI runs it through rb_check_array_type — args_check_block_arg0
(vm_args.c v3_4_0:97-119), reached from setup_parameters_complex's
arg_setup_block arm (838-852) and from
vm_callee_setup_block_arg_arg0_check for a simple block
(vm_insnhelper.c v3_4_0:5183-5193). That conversion:

  - consults an OVERRIDDEN #respond_to? before calling #to_ary,
  - raises TypeError when #to_ary answers with a non-Array,
  - lets an exception from #to_ary out untouched,
  - and DECLINES — leaving the object whole — when there is no #to_ary
    or it returns nil.

masgnExpandOperand is already that rb_check_array_type, written for
expandarray, down to the respond_to? rule; its one-element [v] result on
a decline is exactly "do not splat", so the auto-splat now asks it
rather than restating the conversion.

language/block_spec.rb 139 -> 152 passing. Measured over language/ AND
core/ (2206 files): 21793 -> 21806, no file loses an example.

Noted while measuring, NOT caused by this change and not in this lane:
core/module/autoload_spec.rb aborts in about 1 run in 10, on this commit
and on its parent alike (12 runs each), with `panic: runtime error:
slice bounds out of range [:-1]` repanicking as `interface conversion:
runtime.boundsError, not vm.RubyError`. It comes from the concurrent
autoload examples; an aborted run contributes 0 passing examples, so it
can look like a 57-example regression in a single measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read for this change: iseq_compile_pattern_each's NODE_ARYPTN, NODE_FNDPTN
and NODE_HSHPTN arms (compile.c v3_4_0:7067-7600), with
iseq_compile_pattern_constant (7054+) and iseq_compile_array_deconstruct
(7055+).

A container pattern's constant was tested with `subject.is_a?(Const)`.
MRI compiles it to `checkmatch VM_CHECKMATCH_TYPE_CASE` over the
constant — Module#=== — so a redefined (or refined) #=== decides the
match; a bare constant pattern is the same. Both now use `===`.

A hash pattern IGNORED its constant entirely: `in Point(x:, y:)` matched
any object answering #deconstruct_keys. The guard is now emitted, and
FIRST, before the protocol call, which is what ruby/spec's "checks
Constant === object before calling #deconstruct_keys" pins.

#deconstruct_keys was always passed nil. The argument names the keys the
pattern will ask for, so the subject may build only those. Witnessed
against ruby 4.0.5 for all four shapes and matching the three
"passes keys …" examples: the key list for a pattern with no rest and
for an anonymous `**`, nil for a named `**rest` and for `**nil`.

A #deconstruct returning a non-Array (or #deconstruct_keys a non-Hash)
was treated as a failed match, so the next `in` clause ran. MRI raises
TypeError from its type_error arm (compile.c 7224, 7396, 7587). rbgo has
no raw type-check opcode, so the test is `is_a?` where MRI uses
`checktype`; the two differ only for an object redefining #is_a?.

#deconstruct was called once per clause. MRI memoises it for the whole
`case` — the clauses share a subject — in a slot with three states: nil
not tried, false does not answer #deconstruct, else the Array
(iseq_compile_array_deconstruct's use_deconstructed_cache). The cache
reaches top-level array and find patterns and their alternatives only;
a nested pattern matches an ELEMENT, not the subject. It is reset at the
head of the statement, because a `case` in a loop runs again in the same
frame with a different subject.

language/pattern_matching_spec.rb 95 -> 102 passing. Measured over
language/ AND core/: 21806 -> 21812, no file loses an example
(core/random/bytes_spec.rb varies by one between runs on this commit and
its parent alike).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cmd` and %x{cmd} ran the shell directly from the opcode, so a
redefined `` ` `` never saw them. MRI compiles NODE_XSTR to a CALL —
putself, putobject of the command fstring, `send :\``  — which is why
language/execution_spec.rb can define a singleton `` ` `` and assert
both that it is reached and that its argument is frozen. OpXStr now
dispatches that send instead of running the command itself; Kernel#`
(kernel_introspect.go) still does the work, so the two spellings cannot
drift apart.

The String is frozen, as an fstring is — and, now that OpPushConst
shares a frozen constant, `.frozen?` reports it.

language/execution_spec.rb 0 -> 2 passing. The four that remain need
interpolation inside a backtick literal, which go-ruby-parser does not
parse: ast.XStr carries a plain Command string, so `echo #{x}` reaches
the shell verbatim and `#` starts a comment there. Reported, not forced.

Measured over language/ AND core/: 21812 -> 21814, no file loses an
example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One test per new branch, so the 100% gate is met by tests rather than by
another package's incidental coverage:

  internal/compiler/wave27_defined_test.go — the tags baked into the
  literal pool are frozen; defined?(super) is its own opcode and emits no
  send for the super arguments; a container probes each element while an
  empty one does not; the three source keywords are not method probes
  while a near-miss name is; OpDefinedMethod's B operand records whether
  a receiver was written; a discarded defined? emits nothing but still
  declares its operand's locals; the container arms of
  declareDefinedLocals.

  internal/vm/wave27_lang_test.go — defined?(super) across the shapes
  (block, define_method, module, undef blocker, class method,
  refinement, top level, unevaluated arguments); frozen tags for every
  runtime-decided kind; the visibility rule both ways; the
  respond_to_missing? fallback; which receivers the operator fast path
  answers for; the void-context elision; the #to_ary auto-splat and its
  TypeError and decline paths; the pattern-matching contract (constant
  ===, the deconstruct_keys key list, both TypeErrors, one #deconstruct
  per case including the remembered ABSENCE, the nested and looped
  cases); and the backtick literal reaching a redefined Kernel#`.

superMethod's "no method entry" guard is now the only one: OpDefinedSuper
called it behind a second, identical check, which made the guard itself
unreachable. Asking it once makes a top-level defined?(super) exercise
it, and matches MRI, where DEFINED_ZSUPER puts the same question to
rb_vm_frame_method_entry before searching.

Coverage, measured as CI does (-coverpkg over ./internal/..., whole
suite): the functions below 100% are EXACTLY the sixteen that
origin/main has on this machine — object.repr, arith.binary/rubyEql/
valueEqlRec, the two bind Inspects, bootstrap, cloneSingleton,
registerNumericGeneric, the two converter_codec helpers, io.inspectStr,
registerNetHTTP, objSingleton, classOf, ivarTable. Nothing this branch
adds or touches is below 100%, and no function dropped. (The darwin
total reads 99.9% on main too; the gate is enforced on the POSIX CI
lanes.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit a0bfd8b into main Sep 23, 2026
7 checks passed
@tannevaled
tannevaled deleted the wave27/lang branch September 23, 2026 14:06
tannevaled added a commit that referenced this pull request Sep 23, 2026
…isagreed with #blocks

Two product defects the Windows lane found, and one test that asserted one
platform's strerror.

**`realpath_rec` built `/C:`.** `realpathResolve` started the walk at `"/"` and
split the whole path on `/`, so on Windows the volume letter became an ordinary
component and every `File.realpath` / `File.realdirpath` produced
`/C:/Users/...` — a path no such filesystem has. It broke `TestFileIO` and
`TestFileRealdirpath` on windows-latest, and it would have broken
`File.realpath` for every Windows user, not only the tests.

MRI does not start at the root either: `rb_check_realpath_emulate` runs the
path through `skipprefixroot` first and threads the resulting **prefixlen**
through `realpath_rec` (ruby/ruby v3_4_0 file.c:4324, and the
`if (*prefixlenp < RSTRING_LEN(*resolvedp))` guard in its `..` branch). That
prefixlen is not decoration: it is what stops `..` walking above a root that is
more than one character long. rbgo now does the same — `realpathRoot` splits off
the volume (`filepath.VolumeName`, empty on POSIX, `C:` or `//host/share` on
Windows), the walk starts there, `realpathParent` truncates no further, and a
symlink target that names a root of its own restarts from that root rather than
from `/`.

On POSIX the change is a no-op by construction — `filepath.VolumeName` returns
"" there, so the root is `"/"` and nothing is consumed — which is asserted
rather than assumed (`TestRealpathRoot`). The multi-character-root behaviour is
covered on the POSIX lanes too, by passing the prefix length directly to the
primitives instead of waiting for a platform where `VolumeName` returns
something.

**`File::Stat#inspect` printed `blocks=0` where `#blocks` answers `nil`.** On
Windows there is no POSIX stat behind the object, so `#blocks` returns nil (as
MRI does where HAVE_STRUCT_STAT_ST_BLOCKS is undefined) — but the native
`Inspect` formatted the raw field. I had written the member values a second time
instead of reading the accessors' rule, and they drifted at the one member that
can be nil. `blocksValue` is now that rule, in one place, read by both. This is
the defect, not the test: a member-by-member comparison is exactly what should
catch it, and it did.

**`TestRealpathSyscallErr` asserted "Not a directory".** The class is portable;
the message is the platform's strerror, and Windows words it "The system cannot
find the path specified." There is no Windows MRI on this host to witness what
MRI prints there, so the text is asserted only where it was witnessed (MRI 4.0.5
on darwin) rather than relaxed to whatever the lane returns — see the errno-text
issue. The class assertion still runs everywhere.

Merged origin/main (#628, #537) and re-measured against it, whole corpus, per
file: **21 815 -> 21 895, and not one spec file lost an example.**
core/file 618 -> 629, core/filetest 45 -> 88, core/exception 149 -> 174.
`gofmt` clean; `go vet ./...` passes for darwin, linux/amd64, linux/arm64,
windows/amd64 and wasip1/wasm, which compiles the tests too; coverage identical
to main function for function, new code at 100%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 23, 2026
#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 added a commit that referenced this pull request Sep 23, 2026
…e_function (#634)

* vm: model the predefined globals the way MRI's variable table does

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>

* vm: RUBY_RELEASE_DATE/RUBY_REVISION, and set_backtrace from Locations

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>

* vm: Kernel#trace_var / #untrace_var

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>

* vm: rb_feature_p for require, and the pinned tests the globals work moved

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>

* vm: whitebox tests for the predefined globals, trace_var and rb_feature_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>

* vm: Kernel#Complex is complex.c's nucomp_convert, step for step

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>

* vm: module_function's type guard, Object fallback and define_method mirror

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>

* vm: Kernel#warn is rb_warn_m

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>

* vm: Kernel#open redirects to #to_open

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>

* vm: the rb_feature_p FIXTURE was POSIX-shaped, not the matching

#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>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 23, 2026
…and the stat surface (+79) (#629)

* filetest: FileTest is File's own predicates, not a second copy (+42 in core/filetest)

MRI defines its one-path file predicates once and installs each on BOTH
FileTest and File through the same C function:

    #define define_filetest_function(name, func, argc) do {        \
        rb_define_module_function(rb_mFileTest, name, func, argc); \
        rb_define_singleton_method(rb_cFile, name, func, argc);    \
    } while(false)

(ruby/ruby v3_4_0 file.c:6635, and its 26 call sites in Init_File at
file.c:7456-7485). rbgo instead had two implementations: a complete one on
File and a partial hand-written one on FileTest carrying 13 of the 26 names.
The 13 missing were blockdev?, chardev?, empty?, executable_real?, grpowned?,
identical?, owned?, pipe?, readable_real?, setgid?, setuid?, socket?,
sticky?, world_readable?, world_writable? and writable_real? — every one of
them a NoMethodError, which is what core/filetest was mostly failing on.

registerFileTest now re-exports File's own *Method records for exactly the
names in define_filetest_function's list. Sharing the record (rather than
copying a closure) is what makes FileTest.method(:zero?) == FileTest.method
(:empty?) hold, which core/filetest/zero_spec.rb asserts.

That exposed a second half of the same drift: File's predicates did not all
check arity. MRI's rb_file_directory_p and friends take argc 1, and
core/filetest/shared/exist.rb asserts the ArgumentError for both receivers.
oneArg now guards the four shared closures (access, realAccess, statTest,
worldPerm) and the four inline predicates, so File.zero? no longer reaches an
index-out-of-range panic with no arguments.

MRI 4.0 removed the deprecated exists? spelling: 4.0.5 reports
FileTest.respond_to?(:exists?) == false, and it is not in
define_filetest_function's list. TestFileTest asserted the pre-4.0 spelling;
it now asserts the NoMethodError, witnessed against the host MRI.

core/filetest: 45 -> 87 passing. core/file and core/exception unchanged
(618 / 149). No spec file lost an example.

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

* errno+exception: SystemCallError.new is a constructor, not a stub (+23 in core/exception)

SystemCallError had no #initialize at all: every argument was discarded, so
SystemCallError.new(Errno::EINVAL::Errno) was a message-less generic
SystemCallError, #errno was read off the class rather than the call, and
Errno::EINVAL.new.message said "Errno::EINVAL" where MRI says "Invalid
argument". This implements MRI's syserr_initialize (ruby/ruby v3_4_0
error.c:3104) whole, including the part that surprises: on the generic class a
lone Integer argument IS the errno, and when the number has a registered class
the receiver's class is REWRITTEN in place (RBASIC_SET_CLASS), which is what
makes SystemCallError.new(22).instance_of?(Errno::EINVAL) true.

Also from error.c: syserr_errno (3153) — #errno returns the value AS GIVEN, so
new("x", 2.9).errno is 2.9 even though the class lookup truncated it to 2 —
and syserr_eqq (3168), the .=== that matches by error NUMBER, so
`rescue Errno::EINVAL` catches a SystemCallError that was never given that
class.

The Errno table was rebuilt against Init_syserr (error.c:4204) and set_syserr
(error.c:3064), which have two rules rbgo did not follow:

  - ONE CLASS PER NUMBER, not per name. A second name for a number that
    already has a class becomes another constant on that same class. That is
    why Errno::EWOULDBLOCK.equal?(Errno::EAGAIN) holds.
  - A name the platform does not define is STILL A CONSTANT — MRI's
    undefined_error binds it to Errno::NOERROR. So Errno.constants is the full
    known_errors list on every platform.

rbgo had 28 names and no NOERROR; it now has MRI's 158, which is what
system_call_error_spec.rb leans on when it takes Errno.constants.size as an
errno number no class can claim. Numbers come from Go's portable syscall
package for the 76 names it defines on every GOOS rbgo builds for (darwin,
linux, windows, wasip1); the remaining names take the undefined_error path.
That is exact on a platform whose libc does not define them and reports 0
where MRI reports a real number on one that does (macOS has EAUTH at 80) —
named in the PR, not papered over. EWOULDBLOCK is registered as EAGAIN's
second name, which every platform rbgo builds for agrees with.

Messages come from syscall.Errno.Error(), whose table is strerror's with a
lower-case lead; errnoStrerror restores the letter and replaces Go's "errno N"
placeholder with the C library's wording. Witnessed byte-for-byte against MRI
4.0.5 over 45 expressions (class, message, errno, arity and coercion errors
for both receiver shapes).

TestProcessSysFailFallback asserted that errno 0 has no registered class. That
premise is now false, and it is false in MRI too (4.0.5:
SystemCallError.new(0).class == Errno::NOERROR), so the case was moved to a
number no name claims and errno 0 given its own assertion.

core/exception: 149 -> 172. errno_spec 1 -> 8, io_error_spec 2 -> 6,
system_call_error_spec 11 -> 22, case_compare_spec 4 -> 5. core/file and
core/filetest unchanged (618 / 87). No spec file lost an example.

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

* file: realpath/realdirpath resolve like realpath_rec, not like EvalSymlinks

Both were filepath.EvalSymlinks with an error guess bolted on. MRI resolves
them with one recursive walk, realpath_rec (ruby/ruby v3_4_0 file.c:4324),
driven by rb_check_realpath_emulate (file.c:4437) and reached through
rb_check_realpath_internal (file.c:4554). EvalSymlinks differs in ways the
specs notice:

  - It refuses a path whose LAST component is a dangling symlink. MRI's
    realdirpath answers the link's TARGET there: realpath_rec follows the link
    and then tolerates the absent name (file.c:4390). rbgo answered the link
    itself.
  - A missing component under a symlink was reported against the symlink, not
    against the resolved name the walk actually failed on.
  - Its errors carry no errno, so ENOTDIR (a path walking through a regular
    file) came out as ENOENT.

realpathResolve is realpath_rec: the component loop, ".."-truncation, the
loopcheck table that both detects a cycle (ELOOP) and answers a link already
resolved, the fallback at file.c:4383, and the
`mode == RB_REALPATH_STRICT || !last || *unresolved_firstsep` rule that decides
whether an absent final component is the answer or an error. The last clause is
why the trailing separator is threaded separately: MRI walks the path as
WRITTEN, so realdirpath("dir/absent/") raises where realdirpath("dir/absent")
does not, and expanding the path (which the base-directory argument requires)
drops it.

The two error WORDINGS are not the same, and now differ correctly: File.realpath
reaches realpath(3) first, so ENOENT and ELOOP name rb_check_realpath_internal
and the whole argument, while ENOTDIR falls through to the emulation and names
realpath_rec and the failing component. File.realdirpath is the emulation, so it
always names realpath_rec.

Verified byte-for-byte against MRI 4.0.5 over 25 expressions (dangling links,
links into missing directories, cycles, relative and absolute link targets,
"..", trailing separators, a regular file used as a directory) — identical.

core/file/realdirpath_spec.rb: 3 -> 4.

The other 11 realpath/realdirpath failures are NOT VM defects: the shim's
SPEC_TMP_BASE is "/tmp", which on darwin is a symlink to /private/tmp, so every
expectation of the form `File.realpath(link).should == @file` compares a
resolved path with an unresolved one. MRI 4.0.5 fails them the same way — it
also answers /private/tmp there. They need SPEC_TMP_BASE moved off a symlinked
root in scripts/conformance/rubyspec/spec_helper.rb, which this branch does not
own.

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

* filestat+exception: real atime/ctime/birthtime, File#stat, rb_stat_inspect, #inspect through #to_s

Four defects, all in this branch's files.

1. File::Stat#atime and #ctime REPORTED THE MODIFICATION TIME. Not an
   approximation — the same number, so File.utime(2000, 2001, f) came back as
   (2001, 2001) and core/file/lutime_spec.rb was reading a value rbgo could not
   have got right. MRI reads st_atime / st_ctime off struct stat (ruby/ruby
   v3_4_0 file.c rb_file_s_atime, stat_atime). Go's syscall.Stat_t has them, but
   under names that differ per platform — Atim/Ctim on Linux, Atimespec/
   Ctimespec on the BSDs and macOS — so shared code cannot name one and
   compile. statTimeField reads whichever the platform carries by reflection,
   which keeps the rule in the shared file where the POSIX lanes cover it
   instead of splitting it across four build-tagged copies. A stat struct with
   neither still falls back to the modification time, which is what rbgo
   reported everywhere before.

2. File::Stat#birthtime raised NotImplementedError unconditionally. It now
   answers where the platform has a creation time (st_birthtime on the BSDs and
   macOS) and keeps MRI's NotImplementedError — same message — where there is
   none, which is MRI's own HAVE_STRUCT_STAT_ST_BIRTHTIMESPEC guard and what
   MRI does on Linux.

3. File::Stat#inspect was the placeholder "#<File::Stat>". It is now
   rb_stat_inspect (ruby/ruby v3_4_0 file.c:1080): the member list in that
   order, dev and rdev in hex, mode in octal with a leading zero, birthtime only
   where it exists. It is written as the value's native Inspect, not as a
   dispatch through the Ruby accessors, because rb_stat_inspect calls the C
   member functions directly — a subclass overriding #uid does not change what
   #inspect prints — and because Kernel#p reaches the same rendering, so `p st`
   and `st.inspect` cannot drift. Two existing tests asserted the placeholder;
   they now assert the real shape, witnessed against MRI 4.0.5.

4. File#stat and File#lstat did not exist, which is what core/file/stat/
   comparison_spec.rb was failing on before it could compare anything. Added in
   registerFileStat beside File.stat/File.lstat. They take no argument
   (ArgumentError otherwise) and refuse a closed stream with IOError, but NOT a
   read-only one — MRI's rb_io_stat works there, so ioCheckOpen is the wrong
   guard.

Also Exception#inspect: it read the stored @message where MRI's exc_inspect
(error.c:1838) goes through rb_obj_as_string(exc), i.e. dispatches #to_s. A
subclass that overrides #to_s therefore changes what #inspect reports, and a
#to_s returning a non-String falls back to the object's identity form. rbgo
already had objAsString with exactly those semantics; #inspect now uses it
rather than a second reading of the same rule.

Verified byte-for-byte against MRI 4.0.5: #inspect over 7 to_s shapes, the
atime/lutime/utime triple, File#stat/#lstat over a symlink, and
File::Stat#inspect built member by member the way the spec builds it.

core/file 618 -> 624, core/exception 172 -> 174, core/filetest unchanged (87).
lutime_spec 6 -> 8, stat/inspect_spec 0 -> 1, stat/comparison_spec 0 -> 2,
stat_spec 4 -> 5, lstat_spec 4 -> 5, exception/inspect_spec 3 -> 5.

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

* file: the stat predicates take an IO, not only a path (rb_stat)

MRI resolves the argument of its whole stat-predicate family through one
function, rb_stat (ruby/ruby v3_4_0 file.c:1304):

    tmp = rb_check_convert_type_with_id(file, T_FILE, "IO", idTo_io);
    if (!NIL_P(tmp)) { GetOpenFile(tmp, fptr); result = fstat_without_gvl(fptr, st); }
    else { FilePathValue(file); result = stat_without_gvl(RSTRING_PTR(file), st); }

so File.directory?(io), File.size(io) and FileTest.directory?(anything_with_
to_io) all work. rbgo coerced every one of them with pathArg, which is
FilePathValue alone, so an IO or a #to_io object was "no implicit conversion of
X into String".

statPathArg now does what rb_stat does, and is applied to exactly the seventeen
methods that call rb_stat in file.c — directory?, pipe?, socket?, blockdev?,
chardev?, setuid?, setgid?, sticky?, owned?, grpowned?, exist?, file?,
zero?/empty?, size?, world_readable?, world_writable?, identical? and
File.size. NOT to readable?/writable?/executable? and the *_real? family, which
go through rb_eaccess, nor to symlink?/ftype, which lstat a path. (An open File
still resolves there, through File#to_path — MRI answers true for
File.readable?(io), and so does rbgo; the difference is which rule gets it
there.)

A #to_io that answers something other than an IO is not silently ignored:
rb_check_convert_type_with_id raises "can't convert Object to IO (Object#to_io
gives Integer)", which statPathArg reproduces.

Verified byte-for-byte against MRI 4.0.5 over 14 expressions, including
File.directory?(STDIN) and an open directory handle.

core/file 624 -> 627, core/filetest 87 -> 88. directory_spec 7 -> 8 on both
receivers, size_spec 18 -> 20.

Coverage: every function in internal/vm is at the same percentage as on main,
with the new code at 100%.

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

* tests: the errno texts are the PLATFORM's, not darwin's — guard what has no witness

The arch lanes (linux amd64/arm64) went red on one assertion:

    errnoStrerror(92) = "Protocol not available", want "Illegal byte sequence"

92 is EILSEQ on darwin and ENOPROTOOPT on Linux. The test hard-coded the
NUMBER and darwin's text for it, which asserts one platform's <errno.h> on
every lane — exactly the mistake this lane has paid for before.

TestErrnoStrerror now separates the two things it was conflating:

  - The RULE, which is portable: for an errno the platform's own table names,
    errnoStrerror is that text with its leading letter restored. Checked for
    every name in errnoNumbers, on every platform.
  - The TEXTS, which are not: the literals are MRI 4.0.5's output on darwin,
    the only platform with an MRI witness on this host, so they are skipped
    elsewhere rather than loosened to whatever the lane happens to print.

Two more platform assumptions found by reading, before the remaining lanes
reported:

  - The "unknown errno" wording only applies where the platform leaves Go's
    "errno N" placeholder. Windows' FormatMessage names every number, so there
    is nothing to substitute and nothing to assert.
  - TestSyserrInitializeShapes asserts strerror texts (darwin and glibc agree on
    the three it uses). Windows has no POSIX errno numbers at all — Go spells
    them APPLICATION_ERROR+n — so it skips there, naming the missing witness.
  - TestFileStatInspect guessed birthtime's presence from runtime.GOOS, which
    was wrong for Windows (Sys() is a Win32FileAttributeData whose CreationTime
    is a Filetime, not a Sec-bearing timespec). It now asks the object, which is
    also what core/file/stat/inspect_spec.rb does.
  - TestFileStatAtimeIsNotMtime skips on Windows for the same reason: atime
    falls back to mtime there, MRI on Windows reports a real one, and there is
    no Windows MRI here to witness the right fix.

`go vet` now passes for darwin, linux/amd64, linux/arm64, windows/amd64 and
wasip1/wasm — which compiles the tests, not only the package. Coverage is
unchanged: identical to main, function for function.

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

* file+filestat: the realpath walk assumed a POSIX root, and #inspect disagreed with #blocks

Two product defects the Windows lane found, and one test that asserted one
platform's strerror.

**`realpath_rec` built `/C:`.** `realpathResolve` started the walk at `"/"` and
split the whole path on `/`, so on Windows the volume letter became an ordinary
component and every `File.realpath` / `File.realdirpath` produced
`/C:/Users/...` — a path no such filesystem has. It broke `TestFileIO` and
`TestFileRealdirpath` on windows-latest, and it would have broken
`File.realpath` for every Windows user, not only the tests.

MRI does not start at the root either: `rb_check_realpath_emulate` runs the
path through `skipprefixroot` first and threads the resulting **prefixlen**
through `realpath_rec` (ruby/ruby v3_4_0 file.c:4324, and the
`if (*prefixlenp < RSTRING_LEN(*resolvedp))` guard in its `..` branch). That
prefixlen is not decoration: it is what stops `..` walking above a root that is
more than one character long. rbgo now does the same — `realpathRoot` splits off
the volume (`filepath.VolumeName`, empty on POSIX, `C:` or `//host/share` on
Windows), the walk starts there, `realpathParent` truncates no further, and a
symlink target that names a root of its own restarts from that root rather than
from `/`.

On POSIX the change is a no-op by construction — `filepath.VolumeName` returns
"" there, so the root is `"/"` and nothing is consumed — which is asserted
rather than assumed (`TestRealpathRoot`). The multi-character-root behaviour is
covered on the POSIX lanes too, by passing the prefix length directly to the
primitives instead of waiting for a platform where `VolumeName` returns
something.

**`File::Stat#inspect` printed `blocks=0` where `#blocks` answers `nil`.** On
Windows there is no POSIX stat behind the object, so `#blocks` returns nil (as
MRI does where HAVE_STRUCT_STAT_ST_BLOCKS is undefined) — but the native
`Inspect` formatted the raw field. I had written the member values a second time
instead of reading the accessors' rule, and they drifted at the one member that
can be nil. `blocksValue` is now that rule, in one place, read by both. This is
the defect, not the test: a member-by-member comparison is exactly what should
catch it, and it did.

**`TestRealpathSyscallErr` asserted "Not a directory".** The class is portable;
the message is the platform's strerror, and Windows words it "The system cannot
find the path specified." There is no Windows MRI on this host to witness what
MRI prints there, so the text is asserted only where it was witnessed (MRI 4.0.5
on darwin) rather than relaxed to whatever the lane returns — see the errno-text
issue. The class assertion still runs everywhere.

Merged origin/main (#628, #537) and re-measured against it, whole corpus, per
file: **21 815 -> 21 895, and not one spec file lost an example.**
core/file 618 -> 629, core/filetest 45 -> 88, core/exception 149 -> 174.
`gofmt` clean; `go vet ./...` passes for darwin, linux/amd64, linux/arm64,
windows/amd64 and wasip1/wasm, which compiles the tests too; coverage identical
to main function for function, new code at 100%.

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

* test: stop asserting POSIX-only shapes on the windows lane

Two assertions failed on windows, and neither was the VM being wrong.

File::Stat#inspect printed `blocks=nil, blksize=0` where the layout regexp
demanded digits. That IS MRI's answer: #blocks is nil where
HAVE_STRUCT_STAT_ST_BLOCKS is undefined, which is every Windows build, and
rb_stat_inspect prints that nil. blocksValue already models it; only the test
insisted on a number no Windows stat can produce. The regexp now accepts either
shape, with the reason beside it.

TestFileRealdirpath compared File.realdirpath against Go's
filepath.EvalSymlinks. On POSIX the two canonicalisers agree; on Windows
EvalSymlinks additionally expands an 8.3 short name (C:/Users/RUNNER~1 ->
C:/Users/runneradmin) and realpath_rec does not, so the test was measuring Go's
normalisation rather than ours. Whether MRI expands it there is unwitnessed —
there is no Windows MRI on this host — so the full equality is asserted where
the two agree by construction, and windows asserts what holds either way:
absolute, forward-slashed, naming the file. The case is not skipped; realdirpath
is still exercised on that lane. The open question is issue #636.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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