Confronting rbgo (pure-Go, CGO=0 Ruby; CLI rbgo) with RSpec — the
dominant Ruby testing framework. RSpec is mostly pure Ruby and extremely
metaprogramming-heavy (describe/context/it DSL, instance_exec,
method_missing, define_method, let, matchers), so it exercises exactly the
dynamic-dispatch / blocks / metaprogramming features rbgo claims.
This is not an attempt to run RSpec's suite green. It is a measured gap map: how much of RSpec rbgo can parse/load, and how rbgo's DSL behaves on small hand-written specs, versus MRI.
scripts/conformance/rspec/run.shThe harness builds rbgo + a parsesweep helper, shallow-clones the four core
RSpec repos, runs a parse sweep over their lib/ trees, and diffs 10 hand-
written RSpec-style DSL snippets against MRI. It is re-runnable and skips
gracefully when offline (no clones) or when MRI ruby is absent (rbgo-only).
- Oracle: MRI ruby 4.0.5 (
ruby -cfor syntax acceptance,rubyfor DSL output).parsesweepcalls rbgo's real front-end (parser.Parse+compiler.Compile, no execution) — a file that "compiles" here is onerbgocan load. - RSpec repos pinned at shallow HEAD of
rspec/{rspec-support,rspec-core, rspec-expectations,rspec-mocks}as of 2026-06-25.
All numbers below are from real runs, not static reasoning.
G2 is closed and executes with MRI-4.0.5 semantics. A paren-less command
call whose trailing argument is followed by a do…end block —
describe "x" do … end, foo bar do … end,
config.expect_with :rspec do |x| … end — now parses and runs. The block
binds to the outermost command call (not to the last argument), matching
Ruby's classic do-vs-brace precedence rule.
The parse gap closed in go-ruby-parser via commit 0fe341b ("Attach do…end
block to a receiver command call", 2026-06-26); it is already in the parser
version rbgo pins (v0.0.0-20260703103305). The compiler + VM already lower and
execute the resulting AST, so no rbgo production change was required — this
update adds the execution regression guard and re-measures.
Measured impact — pure G2 (single parser commit 0fe341b^ → 0fe341b, run
over an identical current RSpec file set, parser.Parse only, so the delta is
attributable to G2 alone):
| file set | before (G2 open) | after (G2 closed) | do-rejects before → after |
|---|---|---|---|
| spec files (the G2 hot path) | 34 / 276 = 12.3 % | 236 / 276 = 85.5 % | 237 → 1 |
| lib files | 130 / 197 = 66.0 % | 132 / 197 = 67.0 % | 5 → 0 |
| all (lib+spec) | 164 / 473 = 34.7 % | 368 / 473 = 77.8 % | 242 → 1 |
G2 alone lifts spec-file parse acceptance 12.3 % → 85.5 % (+73.2 pts),
eliminating 236 of the 237 unexpected "do" after statement rejects — by far
the single biggest parse-acceptance lever, exactly as this doc predicted. Before
the fix, unexpected "do" after statement was 97.9 % of all spec-file
rejects (237 / 242).
With the full current parser + VM that rbgo ships (G2 plus every later fix),
re-measured today with scripts/conformance/rspec/run.sh + a spec-file sweep:
| metric | now (2026-07-31) |
|---|---|
lib/ parse acceptance |
196 / 197 = 99.5 % |
| spec-file parse acceptance | 274 / 276 = 99.3 % |
| DSL snippets vs MRI | 10 / 10 PASS |
MRI-parity proofs (rbgo output byte-identical to ruby 4.0.5):
def foo(x); yield x*2; end; foo 3 do |n| p n end # => 6 (yield through a command-call block)
def m1(x); "m1:#{block_given?}"; end # do binds to the OUTER command → m1 sees the block
def m2; "m2:#{block_given?}"; end
puts(m1 m2 do end) # => m1:true
puts(m1 m2 { }) # => m1:false ({…} binds to the NEAREST call, m2)
def describe(n); "D:#{n}(#{yield})"; end # nested RSpec DSL shape
def it(n); "I:#{n}=#{yield}"; end
puts(describe "o" do; it "i" do 42 end; end) # => D:o(I:i=42)Regression guard: internal/vm/command_call_block_test.go
(TestCommandCallBlock) — 20 cases covering yield-through, do-vs-brace
precedence, nested describe/it, splat/keyword args, &block capture, closure
over caller locals, the LocalJumpError branch, and paren-form regression.
Update — 2026-07-31: compiler-lowering gaps closed (post-loop, named captures, expr-receiver singleton def, anonymous **)
Four compiler-lowering gaps are closed and execute with MRI-4.0.5 semantics
(regression suite internal/vm/compiler_lowering_gaps_test.go +
internal/compiler/whitebox_test.go):
begin…end while/until condpost-loop.postLoopBodyrecognises the modifier form wrapping abeginandcompileDoWhileruns the body once before the first condition test, compiling the body before the condition so a local the body assigns is in scope for the trailing condition (begin; x = …; end until x >= 3now yieldsx; MRI-exact).nextre-evaluates the condition.- Named-capture
=~local pre-declaration. For a literal regexp with named captures on the left of=~, the compiler statically introduces each valid capture name as a local (regexpNamedCaptures+compileMatchWithCaptures), bound from$~after the match (nil when unmatched / non-participating). Only the literal-on-the-left, non-interpolated form triggers it, matching MRI. def <expr>.methodon a method-call receiver.compileSingletonReceiverlowers a bare identifier receiver that is not a local as a zero-arg self-send (def foo.barwherefoois a method), reusing the singleton-def path.- Anonymous
**mixed with explicit keywords, andyield(*, **).rewriteAnonKwSplatfills bare anonymous**entries even when combined with explicit pairs, andyieldnow routes its args through the anonymous-forward rewrite, sog(k: v, **)andyield(*, **)forward intact.
G7 (backslash line-continuation with interpolation in the continued fragment)
is also closed by the go-ruby-parser v0.1.0 bump.
The parenthesized singleton-receiver form def (expr).method is closed by the
go-ruby-parser v0.1.1 bump: the parser emits the same MethodDef/Recv node as
the bare def expr.method form, so compileSingletonReceiver lowers it with no
compiler change. End-to-end regression in internal/vm/def_receiver_test.go
(TestDefWithParenReceiver), MRI-4.0.5-verified.
The modifier-rescue-after-a-command-call precedence gap is closed by the
go-ruby-parser v0.1.2 bump. A paren-less command call followed by a modifier
rescue — raise "x" rescue 42 — now parses as (raise "x") rescue 42, the
rescue wrapping the whole call rather than binding to the last argument; and
def … end rescue nil likewise wraps the whole definition. rbgo's compiler
already lowered the resulting Begin/rescue node correctly, so the bump alone
activates it — no compiler change. End-to-end regression in
internal/vm/rescue_modifier_command_test.go (TestRescueModifierCommandCall),
MRI-4.0.5-verified, including the negative that a non-StandardError (raise Exception) still propagates past the bare modifier.
Both singleton-class parse gaps are closed and execute with MRI-4.0.5
semantics. class << <expr> (of which class << self is one case) is one
grammar production: the parser emits ast.SingletonClassDef{Target, Body}, the
compiler lowers it via compileSingletonClass → OpOpenSingletonClass, and the
VM runs the body with the target's singleton (meta) class as the definee.
Re-measured with scripts/conformance/rspec/run.sh (parser dep
go-ruby-parser 2026-07-03, which post-dates the 2026-06-25 snapshot the body of
this doc was measured against — several other gaps have also closed since):
| metric | this doc (2026-06-25) | now (2026-07-31) |
|---|---|---|
G4 class << self |
open (11 expected CONST, got "<<") |
CLOSED |
G5 class << expr |
open (5 unexpected token "class") |
CLOSED |
lib/ parse acceptance |
79 / 197 = 40.1 % | 196 / 197 = 99.5 % |
| DSL snippets vs MRI | 5 / 10 PASS | 10 / 10 PASS |
rspec/support.rb (line 21 (class << self; self; end).__send__) |
parse FAIL (G5) | parses in full (load then hits a runtime gap, not the parser) |
Per-repo lib/ acceptance now: rspec-support 33/33, rspec-core 73/74,
rspec-expectations 49/49, rspec-mocks 41/41. The single remaining reject
is unrelated to G4/G5 — rspec-core/lib/rspec/core/formatters.rb:241 uses the
retry keyword (a distinct, newly-surfaced parse gap).
require "rspec/support": the file now parses in full — the line-21
class << expr blocker is gone. With the $LOAD_PATH cwd workaround (gap
G-LOAD, still open) the require advances past parsing and reaches a runtime
gap instead — undefined method 'method' for class 'Kernel' (Kernel#method
reflection is not yet defined). So the parse gap G5 no longer blocks the load;
the first hard failure has moved from the parser to that runtime method.
MRI-parity proofs (rbgo output byte-identical to ruby 4.0.5):
class Foo; class << self; def bar; 42; end; end; end; Foo.bar # => 42 (class method via class << self)
o = Object.new; o.singleton_class.equal?(class << o; self; end) # => true (singleton_class identity)
module M; class << self; def reg; "R"; end; end; end; M.reg # => "R" (module class-level accessor)
(class << Object.new; 40; 2; end) # => 2 (body value = last expression)Regression guard: internal/vm/singleton_test.go
(TestSingletonClass, TestSingletonClassOfExpression,
TestSingletonClassErrors) and go-ruby-parser/singleton_class_test.go.
The rest of this document is the original 2026-06-25 gap map, retained for history; the table below has been annotated where G4/G5 are referenced.
MRI baseline: all 197 lib/ files pass ruby -c. So every rbgo reject
below is a genuine rbgo gap (MRI-accepts / rbgo-rejects).
| Repo | rbgo accepts |
|---|---|
| rspec-support | 15 / 33 |
| rspec-core | 38 / 74 |
| rspec-expectations | 16 / 49 |
| rspec-mocks | 10 / 41 |
| Total (lib/) | 79 / 197 = 40.1 % |
Including spec files (which use the DSL heavily) across all four repos: 127 / 559 = 22.7 % — the DSL surface (paren-less command call + block, see gap G2) is the main drag on spec-file acceptance.
Breakdown of the 117 parse rejects + 1 compile reject, clustered by message:
| count | rbgo error cluster | root cause (gap) |
|---|---|---|
| 33 | unexpected token "::" |
G1 leading :: — CLOSED |
| 11 | unexpected "," after statement |
G3 masgn ivar lhs — CLOSED |
| 11 | expected CONST, got "<<" |
G4 class << self — CLOSED |
| 6 | expected IDENT, got ")" |
G6 bare anon splat — CLOSED |
| 5 | unexpected token "class" |
G5 class << expr — CLOSED |
| 5 | unexpected "do" after statement |
G2 cmd-call + block — CLOSED |
| ~30 | unexpected "..." after statement (long literals) |
G7 line-cont + interp |
| 1 | compile error: cannot compile *ast.SplatArg |
G8 super(*args) — CLOSED |
| entrypoint | result |
|---|---|
require "rspec/support" |
parse gap G5 closed — rspec/support.rb parses in full; load now advances to a runtime gap (Kernel#method undefined), not the parser (see 2026-07-31 update) |
require "rspec/core" |
fails (depends on rspec/support) |
require "rspec/expectations" |
fails (depends on rspec/support) |
require "rspec/mocks" |
fails (depends on rspec/support) |
No RSpec entrypoint loads under rbgo. Two distinct blockers surface:
$LOAD_PATH/$:isnilin rbgo, andrequiredoes not consult a load path — it searches only the requiring file's directory and the process CWD (internal/vm/require.go: requireCandidates). RSpec's own bootstrap does$LOAD_PATH.unshift liband thenrequire "rspec/support", which cannot work. (Workaround for this harness:cdinto thelib/dir so CWD-relative resolution applies.)- With the path worked around, the first hard failure is a parse gap:
rspec/support.rb:21uses(class << self; self; end).__send__(...)— the singleton-class-of-expression form (gap G5), which also hits__send__(gap G-RT2) once it parses.
Ten hand-written snippets reproducing RSpec's real DSL patterns without loading RSpec. Run through rbgo and MRI; stdout + exit code compared.
Result (2026-06-25 snapshot): 5 PASS / 5 FAIL. As of 2026-07-31 the harness measures 10 PASS / 0 FAIL — G4 flipped 01 and 09; the three runtime gaps (G-RT1/2/3) have since closed too. Rows below keep the original per-snippet diagnosis.
| # | snippet | pattern exercised | result |
|---|---|---|---|
| 01 | describe/it DSL | define_method + instance_exec + class-level block recording, class << self accessor |
PASS (G4 closed) |
| 02 | let memoization |
define_method caching in ivar, instance_exec |
PASS |
| 03 | method_missing matcher |
be_<predicate> dispatch via method_missing + respond_to_missing? |
FAIL (semantic G-RT1) |
| 04 | Comparable / === |
Comparable mixin, <=>, Range#=== |
PASS |
| 05 | matcher-builder DSL | define_singleton_method capturing a block param `{ |
&blk |
| 06 | nested context inheritance | Class.new(parent) + class_eval(&block) + superclass |
PASS |
| 07 | yield / block args | explicit block params, block.call, lambdas |
PASS |
| 08 | stub / send | define_singleton_method, __send__, public_send |
FAIL (missing core G-RT2) |
| 09 | shared examples registry | module class << self + instance_exec(*args, &block) |
PASS (G4 closed) |
| 10 | expect(x).to(matcher) chain |
fluent objects, raise/rescue | PASS |
Verdict. rbgo's core dynamism holds up well: define_method,
instance_exec, class_eval, Class.new subclassing, superclass,
Comparable, block/lambda passing, send/public_send, and the
expect().to() fluent spine all work (snippets 02, 04, 06, 07, 10 are
byte-identical to MRI). The failures are not failures of the metaprogramming
model — they are a small set of concrete, isolatable gaps (two parse-level,
three runtime-level) that happen to sit on RSpec's hot path. Fix the eight gaps
below and a large fraction of both the parse sweep and the DSL snippets flip
green.
Ranked by impact (lib/ files containing the construct, out of 197). For the
coordinated internal/vm / go-ruby-parser fix pass — not fixed here.
if defined?(::Foo)
x = ::Foo::Bar.new
end- MRI:
Syntax OK - rbgo: CLOSED — parses + executes.
::Foo/::M::Barresolve from the top level, anddefined?(::Foo)returns"constant", matching MRI. - Layer: go-ruby-parser (lexer/parser).
::Constin expression position.
config.expect_with :rspec do |x|
x
end- MRI:
Syntax OK - rbgo:
parse error at line 1: unexpected "do" after statement - Layer: parser.
recv.meth arg do … end. The paren formrecv.meth(arg) do … endparses fine; only the command (paren-less) form with both an argument and adoblock fails. This is the shape ofRSpec.configure do,config.expect_with :rspec do, etc. — the single biggest blocker on actual*_spec.rbfiles (drives spec-file acceptance down to 22.7 %).
@read_io, @write_io = IO.pipe- MRI:
Syntax OK - rbgo: CLOSED — parses + executes. Masgn now lowers ivar and attribute
(
o.x =) targets on the LHS, not just locals, matching MRI. - Layer: parser. Note: masgn to local vars works (
a, b = 1, 2); masgn to ivars on the LHS now works too.
class Foo
class << self
def bar; 1; end
end
end- MRI:
Syntax OK - rbgo: CLOSED — parses + executes;
Foo.barreturns1. DSL snippets 01 and 09 now PASS.ast.SingletonClassDef→compileSingletonClass→OpOpenSingletonClass. - Layer: parser + compiler + vm.
(class << self; self; end).__send__(:define_method, name) { }- MRI:
Syntax OK - rbgo: CLOSED — parses + executes. The body is a real scope whose value is
its last expression, so
(class << obj; self; end)returnsobj's singleton class (obj.singleton_class.equal?(...)istrue).rspec/support.rb:21now parses in full; the load advances past the parser to a runtime gap (Kernel#method, see §2) rather than failing here. - Layer: parser + compiler + vm (same production as G4).
def self.extract(file, line, *)
[file, line]
end- MRI:
Syntax OK - rbgo: CLOSED — parses + executes. Trailing nameless
*,**and¶meters all accept (and discard) their arguments, matching MRI. - Layer: parser. Trailing nameless
*(and nameless**/&).
x = "a" \
"b#{1}"- MRI:
Syntax OK→"ab1" - rbgo: CLOSED — parses + executes;
xis"ab1". Closed by thego-ruby-parser v0.1.0bump (the lexer now folds a\+newline continuation before an interpolated fragment). Cleared the ~30 "after statement" rejects (e.g.raise ArgumentError, "..." \/"...#{verb}..."). - Layer: lexer (go-ruby-parser).
class B < A
def m(*a); super(*a); end
end- MRI:
[1, 2] - rbgo: CLOSED — compiles + executes;
B.new.m(1, 2)returns[1, 2]. The explicit-supersplat argument now lowers like any other splat call. - Layer: compiler (parses + lowers). Was the only compile-stage reject in
the lib sweep (
verifying_message_expectation.rb).
class C
def respond_to_missing?(n, p=false); n == :foo; end
def method_missing(n,*a); n == :foo ? 1 : super; end
end
C.new.respond_to?(:foo) # MRI: true rbgo: false- Layer:
internal/vm.respond_to?does not fall back torespond_to_missing?. Breaks DSL snippet 03 (predicate matchers).
P.new.__send__(:g) # MRI: ok rbgo: NoMethodError: undefined method '__send__'- Layer:
internal/vm.sendandpublic_sendboth work; only the__send__alias is missing. RSpec uses__send__pervasively. Trivial fix (alias). Breaks DSL snippet 08.
o = Object.new
captured = nil
o.define_singleton_method(:take) { |&blk| captured = blk }
o.take { 99 }
captured.call # MRI: 99 rbgo: NoMethodError: undefined method 'call' for NilClass- Layer:
internal/vm. A block passed to a singleton method whose body declares a&blkblock parameter is not bound. Breaks DSL snippet 05 (the matcher builder DSL —RSpec::Matchers.define).
$LOAD_PATH/$:evaluate tonil;requiresearches only the requiring file's dir + CWD. RSpec (and most gems) bootstrap via$LOAD_PATH.unshift "lib"thenrequire "rspec/support", which cannot resolve. Layer:internal/vm(require.go). Needed for any real gem loading.
- Parse acceptance: 40.1 % of RSpec
lib/(79/197), 22.7 % including specs. Every reject is a real rbgo gap (MRI accepts 100 % oflib/). - Load: no RSpec entrypoint loads — blocked first by
$LOAD_PATHbeing unusable, then by theclass << exprparse gap atrspec/support.rb:21. - DSL dynamism (headline): the metaprogramming model is sound — 5/10
hand-written RSpec-pattern snippets are byte-identical to MRI, including
define_method/instance_exec/let, anonymous-subclass nesting,Comparable, blocks/lambdas, and theexpect().to()fluent chain. The 5 failures reduce to a short, isolatable gap list (G1–G8 parse/compile + G-RT1/2/3 runtime + G-LOAD). None is a deep architectural problem; G1 (leading::, 64 files) and G2 (command call + block, spec hot path) give the most leverage, and G-RT2 (__send__alias) is a near-free win.