From 7649b13d6d0541b9fb45da544c48a2dd1e4c45ff Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 25 Jun 2026 22:30:24 +0200 Subject: [PATCH] Add RSpec conformance stress test harness + gap map Confronts rbgo with RSpec (rspec-core/expectations/mocks/support) as a metaprogramming/dynamic-dispatch conformance probe against MRI 4.0.5. Harness (scripts/conformance/rspec/): - parsesweep: runs rbgo's front-end (parse + compile, no exec) over a tree - dsl/01..10: hand-written snippets reproducing RSpec's DSL patterns - run.sh: build + shallow-clone + parse-sweep + DSL-diff vs MRI; re-runnable, skips gracefully offline / without MRI Findings (CONFORMANCE-RSPEC.md), all from real runs: - Parse acceptance: 40.1% of RSpec lib/ (79/197), 22.7% incl specs. MRI accepts 100% of lib/, so every reject is a genuine rbgo gap. - Load: no RSpec entrypoint loads -- blocked by $LOAD_PATH being nil + the `class << expr` parse gap at rspec/support.rb:21. - DSL dynamism (headline): model is sound -- 5/10 snippets byte-identical to MRI (define_method/instance_exec/let, anon-subclass nesting, Comparable, blocks/lambdas, expect().to() chain). The 5 failures reduce to a short, isolatable gap list. Ranked gap map with minimal repros (for a later coordinated fix pass; no internal/vm or parser-core edits here): G1 leading :: const ref (64 files) G5 class << expr (load blocker) G2 cmd-call + do-block (spec hot path) G6 bare anon splat (a, *) G3 masgn to ivar LHS (12 files) G7 line-cont + interpolation G4 class << self (20 files) G8 super(*args) compile error G-RT1 respond_to? ignores respond_to_missing? G-RT2 __send__ alias missing (send/public_send work) G-RT3 &blk param unbound in define_singleton_method G-LOAD $LOAD_PATH nil; require has no load-path search Co-Authored-By: Claude Opus 4.8 --- CONFORMANCE-RSPEC.md | 269 ++++++++++++++++++ .../rspec/dsl/01_describe_it_dsl.rb | 30 ++ .../rspec/dsl/02_let_memoization.rb | 20 ++ .../rspec/dsl/03_method_missing_matcher.rb | 28 ++ .../rspec/dsl/04_comparable_matching.rb | 19 ++ .../rspec/dsl/05_define_method_matcher_dsl.rb | 37 +++ .../dsl/06_nested_context_inheritance.rb | 23 ++ .../rspec/dsl/07_yield_block_args.rb | 29 ++ .../rspec/dsl/08_send_define_singleton.rb | 15 + .../rspec/dsl/09_shared_examples_proc.rb | 23 ++ .../rspec/dsl/10_expect_to_chain.rb | 27 ++ scripts/conformance/rspec/parsesweep/main.go | 122 ++++++++ scripts/conformance/rspec/run.sh | 89 ++++++ 13 files changed, 731 insertions(+) create mode 100644 CONFORMANCE-RSPEC.md create mode 100644 scripts/conformance/rspec/dsl/01_describe_it_dsl.rb create mode 100644 scripts/conformance/rspec/dsl/02_let_memoization.rb create mode 100644 scripts/conformance/rspec/dsl/03_method_missing_matcher.rb create mode 100644 scripts/conformance/rspec/dsl/04_comparable_matching.rb create mode 100644 scripts/conformance/rspec/dsl/05_define_method_matcher_dsl.rb create mode 100644 scripts/conformance/rspec/dsl/06_nested_context_inheritance.rb create mode 100644 scripts/conformance/rspec/dsl/07_yield_block_args.rb create mode 100644 scripts/conformance/rspec/dsl/08_send_define_singleton.rb create mode 100644 scripts/conformance/rspec/dsl/09_shared_examples_proc.rb create mode 100644 scripts/conformance/rspec/dsl/10_expect_to_chain.rb create mode 100644 scripts/conformance/rspec/parsesweep/main.go create mode 100755 scripts/conformance/rspec/run.sh diff --git a/CONFORMANCE-RSPEC.md b/CONFORMANCE-RSPEC.md new file mode 100644 index 00000000..0be77876 --- /dev/null +++ b/CONFORMANCE-RSPEC.md @@ -0,0 +1,269 @@ +# RSpec conformance stress test for rbgo (go-embedded-ruby) + +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. + +## How to reproduce + +```sh +scripts/conformance/rspec/run.sh +``` + +The 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 -c` for syntax acceptance, `ruby` for DSL + output). `parsesweep` calls rbgo's real front-end (`parser.Parse` + + `compiler.Compile`, no execution) — a file that "compiles" here is one `rbgo` + can 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. + +--- + +## 1. Parse sweep — `lib/` trees (the framework code itself) + +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 `::` | +| 11 | `unexpected "," after statement` | G3 masgn ivar lhs| +| 11 | `expected CONST, got "<<"` | G4 `class << self`| +| 6 | `expected IDENT, got ")"` | G6 bare anon splat| +| 5 | `unexpected token "class"` | G5 `class << expr`| +| 5 | `unexpected "do" after statement` | G2 cmd-call + block| +| ~30 | `unexpected "..." after statement` (long literals) | G7 line-cont + interp| +| 1 | `compile error: cannot compile *ast.SplatArg` | G8 `super(*args)`| + +--- + +## 2. Load attempts — `require` of RSpec entrypoints + +| entrypoint | result | +|-----------------------|--------| +| `require "rspec/support"` | **fails** — `rspec/support.rb:21` `unexpected token "class"` (gap G5) | +| `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: + +1. **`$LOAD_PATH` / `$:` is `nil`** in rbgo, and `require` does 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 lib` and then `require "rspec/support"`, which cannot + work. (Workaround for this harness: `cd` into the `lib/` dir so CWD-relative + resolution applies.) +2. With the path worked around, the **first hard failure** is a parse gap: + `rspec/support.rb:21` uses `(class << self; self; end).__send__(...)` — the + singleton-class-of-expression form (gap G5), which also hits `__send__` + (gap G-RT2) once it parses. + +--- + +## 3. DSL usage — the headline (rbgo metaprogramming vs MRI) + +Ten hand-written snippets reproducing RSpec's real DSL patterns *without* +loading RSpec. Run through rbgo and MRI; stdout + exit code compared. + +**Result: 5 PASS / 5 FAIL.** + +| # | snippet | pattern exercised | result | +|---|---------|-------------------|--------| +| 01 | describe/it DSL | `define_method` + `instance_exec` + class-level block recording, `class << self` accessor | **FAIL** (parse G4) | +| 02 | `let` memoization | `define_method` caching in ivar, `instance_exec` | PASS | +| 03 | `method_missing` matcher | `be_` 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| }` | **FAIL** (semantic G-RT3) | +| 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)` | **FAIL** (parse G4) | +| 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. + +--- + +## 4. Ranked gap map (minimal repros, MRI-expected vs rbgo-actual) + +Ranked by impact (lib/ files containing the construct, out of 197). For the +coordinated `internal/vm` / go-ruby-parser fix pass — **not fixed here.** + +### G1 — leading `::` (top-level constant reference) — 64 files +```ruby +if defined?(::Foo) + x = ::Foo::Bar.new +end +``` +- MRI: `Syntax OK` +- rbgo: `parse error at line 1: unexpected token "::" (::)` +- Layer: go-ruby-parser (lexer/parser). `::Const` in expression position. + +### G2 — paren-less command call with arg **and** `do…end` block — spec hot path +```ruby +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 form + `recv.meth(arg) do … end` parses fine; only the command (paren-less) form with + both an argument and a `do` block fails. This is the shape of `RSpec.configure + do`, `config.expect_with :rspec do`, etc. — the single biggest blocker on + actual `*_spec.rb` files (drives spec-file acceptance down to 22.7 %). + +### G3 — multiple assignment with instance-variable targets — 12 files +```ruby +@read_io, @write_io = IO.pipe +``` +- MRI: `Syntax OK` +- rbgo: `parse error at line 1: unexpected "," after statement` +- Layer: parser. Note: masgn to *local* vars works (`a, b = 1, 2`); masgn to + *ivars* on the LHS does not. + +### G4 — `class << self` (singleton class, statement position) — 20 files +```ruby +class Foo + class << self + def bar; 1; end + end +end +``` +- MRI: `Syntax OK` +- rbgo: `parse error at line 2: expected CONST, got "<<" (<<)` +- Layer: parser. Blocks DSL snippets 01 and 09. + +### G5 — `class << expr` (singleton class of an expression) — RSpec bootstrap +```ruby +(class << self; self; end).__send__(:define_method, name) { } +``` +- MRI: `Syntax OK` +- rbgo: `parse error: unexpected token "class"` +- Layer: parser. This is `rspec/support.rb:21` — the first hard load blocker. + +### G6 — bare anonymous splat parameter `(a, *)` — 3 files +```ruby +def self.extract(file, line, *) + [file, line] +end +``` +- MRI: `Syntax OK` +- rbgo: `parse error: expected IDENT, got ")" ())` +- Layer: parser. Trailing nameless `*` (and likely nameless `**`/`&`). + +### G7 — backslash line-continuation + interpolation in the continued string +```ruby +x = "a" \ + "b#{1}" +``` +- MRI: `Syntax OK` +- rbgo: `parse error at line 2: unexpected "b" after statement` +- Layer: lexer. Adjacent string-literal concatenation across a `\` line + continuation works when both fragments are plain, but breaks when the second + fragment contains `#{…}`. Accounts for the ~30 long "after statement" rejects + (e.g. `raise ArgumentError, "..." \` / `"...`#{verb}`..."`). + +### G8 — `super(*args)` (explicit super with a splat argument) — compile stage +```ruby +class B < A + def m(*a); super(*a); end +end +``` +- MRI: `[1, 2]` +- rbgo: `compile error: cannot compile *ast.SplatArg` +- Layer: **compiler** (parses, fails to lower). The only compile-stage reject in + the lib sweep (`verifying_message_expectation.rb`). Plain `f(*args)` lowers + fine; only inside an explicit `super(...)` does the splat arg fail. + +### Runtime / semantic gaps (surface only after parse succeeds) + +### G-RT1 — `respond_to?` ignores `respond_to_missing?` +```ruby +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 to + `respond_to_missing?`. Breaks DSL snippet 03 (predicate matchers). + +### G-RT2 — `__send__` not defined as a core method +```ruby +P.new.__send__(:g) # MRI: ok rbgo: NoMethodError: undefined method '__send__' +``` +- Layer: `internal/vm`. `send` and `public_send` **both work**; only the + `__send__` alias is missing. RSpec uses `__send__` pervasively. Trivial fix + (alias). Breaks DSL snippet 08. + +### G-RT3 — block param `{ |&blk| … }` captures `nil` in `define_singleton_method` +```ruby +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 `&blk` block parameter is not bound. Breaks DSL snippet 05 (the matcher + builder DSL — `RSpec::Matchers.define`). + +### G-LOAD — `$LOAD_PATH` is `nil`; `require` has no load-path search +- `$LOAD_PATH` / `$:` evaluate to `nil`; `require` searches only the requiring + file's dir + CWD. RSpec (and most gems) bootstrap via + `$LOAD_PATH.unshift "lib"` then `require "rspec/support"`, which cannot + resolve. Layer: `internal/vm` (`require.go`). Needed for any real gem loading. + +--- + +## 5. Bottom line + +- **Parse acceptance**: 40.1 % of RSpec `lib/` (79/197), 22.7 % including specs. + Every reject is a real rbgo gap (MRI accepts 100 % of `lib/`). +- **Load**: no RSpec entrypoint loads — blocked first by `$LOAD_PATH` being + unusable, then by the `class << expr` parse gap at `rspec/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 the `expect().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. diff --git a/scripts/conformance/rspec/dsl/01_describe_it_dsl.rb b/scripts/conformance/rspec/dsl/01_describe_it_dsl.rb new file mode 100644 index 00000000..a244a8ad --- /dev/null +++ b/scripts/conformance/rspec/dsl/01_describe_it_dsl.rb @@ -0,0 +1,30 @@ +# RSpec core pattern: a describe/it DSL built from define_method + blocks + +# instance_exec. A mini example-group records `it` blocks and runs them in the +# context of a fresh group instance (this is exactly how rspec-core dispatches +# example blocks). +class Group + @examples = [] + class << self + attr_accessor :examples + end + + def self.it(desc, &block) + @examples << [desc, block] + end + + def self.run + @examples.each do |desc, block| + instance = new + instance.instance_exec(&block) + puts "ran: #{desc}" + end + end +end + +class MySpec < Group + @examples = [] + it("adds") { puts(1 + 1) } + it("greets") { puts "hi" } +end + +MySpec.run diff --git a/scripts/conformance/rspec/dsl/02_let_memoization.rb b/scripts/conformance/rspec/dsl/02_let_memoization.rb new file mode 100644 index 00000000..3ed70f46 --- /dev/null +++ b/scripts/conformance/rspec/dsl/02_let_memoization.rb @@ -0,0 +1,20 @@ +# RSpec `let` memoization: define_method that caches in an ivar keyed by name. +# This is the heart of rspec-core's MemoizedHelpers. +class Context + def self.let(name, &block) + define_method(name) do + @__memo ||= {} + @__memo[name] = @__memo.key?(name) ? @__memo[name] : instance_exec(&block) + end + end +end + +class Example < Context + $calls = 0 + let(:value) { $calls += 1; 42 } +end + +e = Example.new +puts e.value # 42, computes once +puts e.value # 42, memoized +puts $calls # 1 diff --git a/scripts/conformance/rspec/dsl/03_method_missing_matcher.rb b/scripts/conformance/rspec/dsl/03_method_missing_matcher.rb new file mode 100644 index 00000000..b6bb0a77 --- /dev/null +++ b/scripts/conformance/rspec/dsl/03_method_missing_matcher.rb @@ -0,0 +1,28 @@ +# RSpec matchers lean on method_missing for dynamic predicate matchers +# (be_empty -> calls empty? on the subject). Reproduce the be_ +# dispatch via method_missing + respond_to_missing?. +class BePredicate + def initialize(subject) + @subject = subject + end + + def method_missing(name, *args) + str = name.to_s + if str.start_with?("be_") + pred = (str[3..-1] + "?").to_sym + @subject.send(pred) + else + super + end + end + + def respond_to_missing?(name, include_private = false) + name.to_s.start_with?("be_") || super + end +end + +m = BePredicate.new([]) +puts m.be_empty # true +puts m.respond_to?(:be_empty) # true +m2 = BePredicate.new([1]) +puts m2.be_empty # false diff --git a/scripts/conformance/rspec/dsl/04_comparable_matching.rb b/scripts/conformance/rspec/dsl/04_comparable_matching.rb new file mode 100644 index 00000000..cd92820b --- /dev/null +++ b/scripts/conformance/rspec/dsl/04_comparable_matching.rb @@ -0,0 +1,19 @@ +# RSpec be_within / range matchers use Comparable and ===. +# Exercise Comparable mixin + Range#=== matcher semantics. +class Version + include Comparable + attr_reader :n + def initialize(n); @n = n; end + def <=>(other); n <=> other.n; end +end + +a = Version.new(1) +b = Version.new(2) +puts(a < b) # true +puts(a == Version.new(1)) # true +puts([b, a].min.n) # 1 + +# Range#=== as a matcher (be_within(0.5).of(3) ~ (2.5..3.5) === x) +matcher = (2.5..3.5) +puts(matcher === 3.0) # true +puts(matcher === 4.0) # false diff --git a/scripts/conformance/rspec/dsl/05_define_method_matcher_dsl.rb b/scripts/conformance/rspec/dsl/05_define_method_matcher_dsl.rb new file mode 100644 index 00000000..499e14d8 --- /dev/null +++ b/scripts/conformance/rspec/dsl/05_define_method_matcher_dsl.rb @@ -0,0 +1,37 @@ +# rspec-expectations' matcher DSL: RSpec::Matchers.define(:name) { match { ... } }. +# Reproduce a matcher-builder that captures a match block via define_method and +# evaluates it with instance_exec against the matcher instance. +class Matcher + def initialize(expected, match_block) + @expected = expected + @match_block = match_block + end + def matches?(actual) + @actual = actual + instance_exec(actual, &@match_block) + end + attr_reader :expected +end + +class MatcherDSL + def self.define(name, &body) + define_method(name) do |expected| + match_block = nil + builder = Object.new + builder.define_singleton_method(:match) { |&blk| match_block = blk } + builder.instance_exec(&body) + Matcher.new(expected, match_block) + end + end +end + +class Suite < MatcherDSL + define(:eq_double) do + match { |actual| actual == expected * 2 } + end +end + +s = Suite.new +m = s.eq_double(3) +puts m.matches?(6) # true +puts m.matches?(7) # false diff --git a/scripts/conformance/rspec/dsl/06_nested_context_inheritance.rb b/scripts/conformance/rspec/dsl/06_nested_context_inheritance.rb new file mode 100644 index 00000000..df37e930 --- /dev/null +++ b/scripts/conformance/rspec/dsl/06_nested_context_inheritance.rb @@ -0,0 +1,23 @@ +# RSpec nests describe/context as anonymous subclasses (Class.new(parent)) so +# `let`s and helpers inherit. Reproduce nested-group creation via Class.new with +# a block evaluated by class_eval. +class ExampleGroup + def self.subgroup(&block) + sub = Class.new(self) + sub.class_eval(&block) + sub + end + def self.helper; "base"; end +end + +Outer = ExampleGroup.subgroup do + def self.helper; "outer-" + superclass.helper; end +end + +Inner = Outer.subgroup do + def self.helper; "inner-" + superclass.helper; end +end + +puts ExampleGroup.helper # base +puts Outer.helper # outer-base +puts Inner.helper # inner-outer-base diff --git a/scripts/conformance/rspec/dsl/07_yield_block_args.rb b/scripts/conformance/rspec/dsl/07_yield_block_args.rb new file mode 100644 index 00000000..20b43f16 --- /dev/null +++ b/scripts/conformance/rspec/dsl/07_yield_block_args.rb @@ -0,0 +1,29 @@ +# RSpec hooks (before/after) and `it` blocks receive the example via yield with +# block args; matchers like `change { }` capture and re-invoke blocks. Exercise +# explicit block params, block.call, and yield with arguments. +def with_example + ex = { name: "spec", run: 0 } + yield ex + ex +end + +result = with_example do |ex| + ex[:run] += 1 +end +puts result[:run] # 1 + +# change-matcher style: capture a block, call before/after. +def change(&probe) + before = probe.call + ->(action) { + action.call + after = probe.call + [before, after] + } +end + +counter = [0] +delta = change { counter[0] } +b, a = delta.call(-> { counter[0] += 5 }) +puts b # 0 +puts a # 5 diff --git a/scripts/conformance/rspec/dsl/08_send_define_singleton.rb b/scripts/conformance/rspec/dsl/08_send_define_singleton.rb new file mode 100644 index 00000000..f8e4ce77 --- /dev/null +++ b/scripts/conformance/rspec/dsl/08_send_define_singleton.rb @@ -0,0 +1,15 @@ +# rspec-mocks stubs methods by defining singleton methods on objects at runtime +# and dispatching via __send__/public_send. Reproduce stub/expect-message. +class Stubber + def self.stub(obj, name, value) + obj.define_singleton_method(name) { value } + end +end + +class Plain; end +p = Plain.new +Stubber.stub(p, :greeting, "stubbed") +puts p.greeting # stubbed +puts p.__send__(:greeting) # stubbed +puts p.public_send(:greeting) # stubbed +puts p.respond_to?(:greeting) # true diff --git a/scripts/conformance/rspec/dsl/09_shared_examples_proc.rb b/scripts/conformance/rspec/dsl/09_shared_examples_proc.rb new file mode 100644 index 00000000..5397b7bd --- /dev/null +++ b/scripts/conformance/rspec/dsl/09_shared_examples_proc.rb @@ -0,0 +1,23 @@ +# RSpec shared_examples store named blocks in a registry and replay them inside +# a group via instance_exec (include_examples). Exercise a proc registry + +# replay with parameters. +module SharedRegistry + @store = {} + class << self + def register(name, &block); @store[name] = block; end + def fetch(name); @store[name]; end + end +end + +SharedRegistry.register("a collection") do |size| + puts "size is #{size}" +end + +class Host + def run_shared(name, *args) + block = SharedRegistry.fetch(name) + instance_exec(*args, &block) + end +end + +Host.new.run_shared("a collection", 3) # size is 3 diff --git a/scripts/conformance/rspec/dsl/10_expect_to_chain.rb b/scripts/conformance/rspec/dsl/10_expect_to_chain.rb new file mode 100644 index 00000000..40c42662 --- /dev/null +++ b/scripts/conformance/rspec/dsl/10_expect_to_chain.rb @@ -0,0 +1,27 @@ +# The expect(x).to(matcher) fluent API: expect returns a target wrapping the +# value, #to runs matcher.matches? and raises on failure. Exercise the full +# fluent chain + a custom raise/rescue, the spine of rspec-expectations. +class Target + def initialize(value); @value = value; end + def to(matcher) + raise "expected #{matcher.desc}, got #{@value.inspect}" unless matcher.matches?(@value) + true + end +end + +class EqMatcher + def initialize(expected); @expected = expected; end + def matches?(actual); actual == @expected; end + def desc; "eq #{@expected.inspect}"; end +end + +def expect(v); Target.new(v); end +def eq(v); EqMatcher.new(v); end + +puts expect(2 + 2).to(eq(4)) # true + +begin + expect(1).to(eq(2)) +rescue => err + puts "caught: #{err.message}" +end diff --git a/scripts/conformance/rspec/parsesweep/main.go b/scripts/conformance/rspec/parsesweep/main.go new file mode 100644 index 00000000..618eda9a --- /dev/null +++ b/scripts/conformance/rspec/parsesweep/main.go @@ -0,0 +1,122 @@ +// Command parsesweep runs every .rb file under one or more roots through +// rbgo's front-end (parse + compile, NO execution) and reports an +// acceptance rate. It mirrors exactly what `rbgo run` does up to (but not +// including) vm.Run, so a file that "compiles" here is one rbgo can load. +// +// Output is a TSV stream on stdout (pathstatusstagemessage) +// plus a summary on stderr, so callers can post-process with awk/sort. +// +// Usage: +// +// parsesweep [...] +// +// status is one of: ok | parse-error | compile-error +// stage is one of: parse | compile | - +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/go-embedded-ruby/ruby/internal/compiler" + "github.com/go-ruby-parser/parser" +) + +type result struct { + path string + status string + stage string + message string +} + +func sweepFile(path string) result { + b, err := os.ReadFile(path) + if err != nil { + return result{path, "read-error", "-", err.Error()} + } + src := string(b) + + // Guard against panics in the front-end (parser/compiler are not + // hardened against every malformed input); treat a panic as a reject + // attributed to whichever stage was running. + var res result + stage := "parse" + func() { + defer func() { + if r := recover(); r != nil { + res = result{path, "panic", stage, oneLine(fmt.Sprint(r))} + } + }() + prog, perr := parser.Parse(src) + if perr != nil { + res = result{path, "parse-error", "parse", oneLine(perr.Error())} + return + } + stage = "compile" + if _, cerr := compiler.Compile(prog); cerr != nil { + res = result{path, "compile-error", "compile", oneLine(cerr.Error())} + return + } + res = result{path, "ok", "-", ""} + }() + return res +} + +func oneLine(s string) string { + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\t", " ") + if len(s) > 300 { + s = s[:300] + } + return s +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: parsesweep [...]") + os.Exit(2) + } + + var files []string + for _, root := range os.Args[1:] { + _ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + return nil + } + if strings.HasSuffix(p, ".rb") { + files = append(files, p) + } + return nil + }) + } + sort.Strings(files) + + counts := map[string]int{} + for _, f := range files { + r := sweepFile(f) + counts[r.status]++ + fmt.Printf("%s\t%s\t%s\t%s\n", r.path, r.status, r.stage, r.message) + } + + total := len(files) + ok := counts["ok"] + fmt.Fprintf(os.Stderr, "\n=== parsesweep summary ===\n") + fmt.Fprintf(os.Stderr, "total .rb files : %d\n", total) + statuses := make([]string, 0, len(counts)) + for s := range counts { + statuses = append(statuses, s) + } + sort.Strings(statuses) + for _, s := range statuses { + fmt.Fprintf(os.Stderr, " %-14s: %d\n", s, counts[s]) + } + if total > 0 { + fmt.Fprintf(os.Stderr, "acceptance (ok/total): %.1f%%\n", 100*float64(ok)/float64(total)) + } +} diff --git a/scripts/conformance/rspec/run.sh b/scripts/conformance/rspec/run.sh new file mode 100755 index 00000000..9a02eaaa --- /dev/null +++ b/scripts/conformance/rspec/run.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# RSpec conformance harness for go-embedded-ruby (rbgo). +# +# Three stages: +# 1. build rbgo + the parsesweep helper +# 2. parse-sweep the cloned RSpec lib/ trees (rbgo front-end vs `ruby -c`) +# 3. DSL-usage diff: run hand-written RSpec-style snippets through rbgo and +# MRI and compare stdout. +# +# Re-runnable. Skips gracefully if the RSpec repos are not cloned (offline) or +# if MRI `ruby` is unavailable (rbgo-only run, no oracle diff). +# +# Env: +# RBGO path to rbgo binary (default: /tmp/rbgo, built if absent) +# RUBY path to MRI ruby (default: first `ruby` on PATH) +# CLONES dir holding rspec-* repos (default: /tmp/rspec-clones) +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HERE/../../.." && pwd)" +RBGO="${RBGO:-/tmp/rbgo}" +RUBY="${RUBY:-$(command -v ruby || true)}" +CLONES="${CLONES:-/tmp/rspec-clones}" +PARSESWEEP="${PARSESWEEP:-/tmp/parsesweep}" + +echo "== build rbgo ==" +if [ ! -x "$RBGO" ]; then + (cd "$REPO_ROOT" && GOWORK=off go build -o "$RBGO" ./cmd/rbgo) || exit 1 +fi +(cd "$REPO_ROOT" && GOWORK=off go build -o "$PARSESWEEP" ./scripts/conformance/rspec/parsesweep) || exit 1 +echo "rbgo: $RBGO" +echo "ruby: ${RUBY:-}" + +REPOS="rspec-support rspec-core rspec-expectations rspec-mocks" + +echo +echo "== clone rspec repos (shallow) ==" +mkdir -p "$CLONES" +for r in $REPOS; do + if [ -d "$CLONES/$r/.git" ]; then + echo " $r: present" + elif command -v git >/dev/null 2>&1; then + (cd "$CLONES" && git clone --depth 1 "/rspec/$r" >/dev/null 2>&1) \ + && echo " $r: cloned" || echo " $r: clone FAILED (offline?) - skipping" + else + echo " $r: git missing - skipping" + fi +done + +LIBDIRS="" +for r in $REPOS; do + [ -d "$CLONES/$r/lib" ] && LIBDIRS="$LIBDIRS $CLONES/$r/lib" +done + +if [ -n "$LIBDIRS" ]; then + echo + echo "== parse sweep (rbgo front-end over lib/ trees) ==" + # shellcheck disable=SC2086 + "$PARSESWEEP" $LIBDIRS +else + echo "== parse sweep skipped (no repos cloned) ==" +fi + +echo +echo "== DSL usage diff (rbgo vs MRI) ==" +pass=0; fail=0; nooracle=0 +for f in "$HERE"/dsl/*.rb; do + name="$(basename "$f")" + rb_out="$("$RBGO" run "$f" 2>&1)"; rb_rc=$? + if [ -n "$RUBY" ]; then + mri_out="$("$RUBY" "$f" 2>&1)"; mri_rc=$? + if [ "$rb_out" = "$mri_out" ] && [ "$rb_rc" = "$mri_rc" ]; then + echo " PASS $name" + pass=$((pass+1)) + else + echo " FAIL $name (rbgo rc=$rb_rc mri rc=$mri_rc)" + echo " --- rbgo ---"; echo "$rb_out" | sed 's/^/ /' + echo " --- mri ---"; echo "$mri_out" | sed 's/^/ /' + fail=$((fail+1)) + fi + else + echo " RBGO-only $name rc=$rb_rc" + echo "$rb_out" | sed 's/^/ /' + nooracle=$((nooracle+1)) + fi +done + +echo +echo "== DSL summary: pass=$pass fail=$fail no-oracle=$nooracle =="