Repository navigation
lang: defined?(super) and frozen tags, block auto-splat via #to_ary, pattern-matching deconstruct contract - #628
Merged
Merged
Conversation
`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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ruby4.0.5 onthe machine.
Measured
language/2158 → 2222 passing examples (80 spec files, found recursively —the tree has
language/regexp/andlanguage/predefined/subdirectories).Measured over
language/andcore/together, 2206 files, so a gain in oneplace cannot hide a loss in another. Every file that moved:
language/defined_spec.rblanguage/block_spec.rblanguage/pattern_matching_spec.rblanguage/execution_spec.rbcore/random/bytes_spec.rbNo other file moves in either direction.
core/random/bytes_spec.rbvaries byone between runs on
mainand on this branch alike (sampled 5 runs each).Ratchet total: 21750 → 21815 (floor 21740).
FLOORis left alone.What changed
defined?(super)answered"expression"— 18 examples, the largest singleitem in
defined_spec.NODE_SUPERandNODE_ZSUPERare bothDEFINED_ZSUPER(compile.c v3_4_0:6139-6143), lowered as
putnil+defined ZSUPER: the superARGUMENTS are never evaluated, only the existence of the method
superwouldreach is tested. A new
OpDefinedSuperanswers it from the same resolution thesuper CALL uses —
invokeSuperis split sosuperMethodlocates the targetwithout invoking it, which is what
vm_defined'sDEFINED_ZSUPERdoes(
rb_method_boundpovervm_search_normal_superclassof the frame'sdefined_class, vm_insnhelper.c v3_4_0:5505-5518). A block and adefine_methodbody answer for their home method.The
defined?tags were not frozen — ~12 examples. MRI builds every answerwith
rb_iseq_defined_string, i.e.rb_fstring_cstr(iseq.c v3_4_0:3692). Bothhalves now go through one constructor,
bytecode.DefinedTag.OpPushConsthadto stop cloning a frozen String constant:
String.Dupreturns an UNFROZEN copy,so the freeze never survived the push. Sharing is what MRI does —
putobjectover 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 elementand 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 arekeywords, parsed to
NODE_FILE/NODE_LINE/NODE_ENCODING, which fallthrough to
DEFINED_EXPR; go-ruby-parser hands them over as bare calls, so thecompiler has to recognise them. A nested
defined?(defined?(x))was nil for thesame reason.
defined?had one method probe where MRI has two.DEFINED_FUNC(noreceiver written) counts ANY visibility;
DEFINED_METHOD(a receiver written,self.included) screens it — private is notdefined?, protected only for akin self.
OpDefinedMethod'sBoperand now carries which, and the explicitform asks
visBlockedKind, the helper an explicit-receiver send already uses.Neither probe consulted
respond_to_missing?; both do now. The operator fastpath over-claimed:
defined?(nil / 2)was"method"whilenil / 2raisesNoMethodError here exactly as in MRI.
TestDefinedOperatorMethodasserted thatover-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 compilesNODE_DEFINEDto nothing when popped (compile.c v3_4_0:11332-11336). Theoperand'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.cv3_4_0:97-119):
#to_aryconverts, an overridden#respond_to?is consultedfirst, a non-Array answer is a TypeError, an exception propagates, and an object
that declines stays whole.
masgnExpandOperandis already that samerb_check_array_type, written forexpandarray, so the auto-splat asks itrather than restating the conversion.
Pattern matching — 7 examples. A container pattern's constant was tested
with
is_a?; MRI compiles it tocheckmatch VM_CHECKMATCH_TYPE_CASE—Module#===— so a redefined#===decides (a bare constant pattern too). Ahash pattern IGNORED its constant entirely; the guard is now emitted, and FIRST,
before the protocol call.
#deconstruct_keyswas always passed nil; it now getsthe key list the pattern will ask for (nil only for a named
**restor**nil,witnessed against 4.0.5 for all four shapes). A
#deconstructreturning anon-Array (or
#deconstruct_keysa non-Hash) was a failed match; it is aTypeError. And
#deconstructwas called once per clause; it is now memoised forthe whole
casewith 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_XSTRto a CALL of`on self with the command fstring, so aredefined
`receives it, frozen. `Kernel#`` still does the work, so thetwo spellings cannot drift apart.
Gates
CGO_ENABLED=0 go build ./...,go vet ./..., and the FULLCGO_ENABLED=0 GOWORK=off go test ./...are green.-coverpkgover./internal/..., wholesuite): the functions below 100% are EXACTLY the sixteen
origin/mainhas onthe 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=wasmandGOOS=linuxbuild.Out of reach in this lane, reported rather than forced
structural gap.
foo(1, 2, 3, {key: 42})ondef foo(a, b, c, **hsh)mustraise ArgumentError; rbgo converts the trailing Hash to keywords. MRI decides
this at the CALL SITE (
VM_CALL_KWARG/VM_CALL_KW_SPLAT), and rbgo losesthe distinction before
bindKeywordssees it: the flag would have to travelthrough every send path. Costs 2 examples in
method_specand more inkeyword_arguments_spec.def m(a = 1, b),{ |a = 5, b, c, d| }.bytecode.ISeqhasNumRequiredandSplatIndexbutno post count for the no-splat case, so the optional eats the leading
argument. Worth ~10 examples across
block_spec,def_spec,send_spec, butthe arity and
#parametersreflection that must agree with it lives inmethod.go,method_reflect*.goandaot/freeze.go, outside this wave'sfile assignment. Doing the binding alone would leave
#aritylying.{ |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.redoat all — it lexes as an identifier, sodefined?(redo)is nilwhere MRI says
"expression", andredoitself does not work.%xliteral:ast.XStrcarries a plainCommandstring, so`echo #{x}`reaches the shell verbatim and#starts a comment there (4 examples in
execution_spec).aanda()are the sameast.Call, sodef m(a = a())cannot call themethod (2 examples).
ast.CaseIn.Elseisnilfor an EMPTYelse, indistinguishable from noelse, socase 0; in 1; else; endraises NoMatchingPatternError.Kernel#=~/Kernel#!~are missing, sodefined?(x !~ 2)is nil whereMRI says
"method". Fixingdefined?alone would report a method that thenraises; the method belongs in
builtins.go, a sibling's file this wave.possibly useless use of defined? in void context) is emitted by MRI's parser under$VERBOSE. rbgo's compiler has nowarning channel, and adding one touches the several
*_open.gofiles thatcall
compiler.Compile.__LINE__is always 0 — the ISeq carries no line map. That is a feature,not a fix, and it costs
line_specentirely plus backtrace examples elsewhere.Noticed while measuring, not caused here
core/module/autoload_spec.rbaborts in about 1 run in 10 — on this branchand on
origin/mainalike (12 runs each) — withpanic: runtime error: slice bounds out of range [:-1]repanicking asinterface conversion: runtime.boundsError, not vm.RubyError. It comes from theconcurrent 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