"A carpenter, on arriving at a site, builds the workstation for the day's project from the materials and tools at hand. We should be the same: while implementing rubyrs, build the tools that will guarantee its quality in the future."
This document explains how we keep rubyrs honest as it grows, and the pipeline we are building to scale that.
┌─────────────────────────────────────────────────────────────────┐
│ Layer 1: unit tests (in modules, #[cfg(test)]) │
│ - Tight Rust-level checks for individual functions │
│ - Currently sparse; we lean on layers 2/3/4 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Layer 2: integration fixtures (tests/integration.rs) │
│ - .rb file + .expected golden file for stdout │
│ - tests/fixtures/errors/ for .expected_err (stderr) cases │
│ - `cargo test` execs the rubyrs binary, diffs │
│ - UPDATE_EXPECTED=1 cargo test regenerates the goldens │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Layer 3: public API smoke tests (tests/embed.rs) │
│ - Calls Runtime/Config/register_fn/set_stdout/format_trap │
│ - Pins the embedding API surface so accidental breakage shows │
│ up in CI; also exercises resource caps (fuel/heap/frames) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Layer 4: ruby/spec coverage (planned, see below) │
│ - Spec files from upstream ruby/spec, mechanically translated │
│ - Run on rubyrs AND CRuby; compare PASS counts │
│ - SPEC_STATUS.md is auto-generated; coverage drives credibility │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Layer 5: cargo-fuzz soak (nightly) │
│ - libfuzzer-sys harness over Runtime::eval + parser path │
│ - Discovers panics / ICEs no hand-written fixture would find │
│ - Findings get pinned as Layer 2 / 3 fixtures and fixed │
│ - See docs/FUZZING.md │
└─────────────────────────────────────────────────────────────────┘
Layers 2 and 3 are hand-curated — we choose the cases. Layer 4 is the antidote: an external standard chooses the cases for us. Layer 5 closes a different loop: nothing chooses the cases — the fuzzer mutates bytes until the binary crashes.
ruby/spec is the de facto executable
standard for Ruby semantics. CRuby, JRuby, and TruffleRuby all run it.
Picking it as our oracle means:
- No bias — we don't get to handpick what we test.
- Credibility — "we pass X% of ruby/spec" is the only meaningful compatibility metric outside the CRuby team's blessing.
- Forced discipline — we can't skip the hard semantic edges.
ruby/spec uses mspec, a custom test framework. Running mspec inside rubyrs requires roughly all of Ruby (classes, modules, Symbol, interpolation, exception hierarchy, method_missing, file I/O, ...) — that's the TruffleRuby/JRuby long-tail path, not a near-term option.
Instead, we treat ruby/spec as a corpus and ingest it mechanically:
ruby/spec (git submodule — pending)
│
▼ ❶ crates/rubyrs-spec-extract (Rust crate; ruby_prism walk)
│ Reads an upstream spec file, rewrites the recognised
│ matcher patterns (v0.1: `expr.should == val`), passes
│ the rest through verbatim so a human can finish.
│ See crates/rubyrs-spec-extract/README.md for the
│ pattern surface.
│
▼ crates/rubyrs/spec/ruby/<feature>_spec.rb
│ The extracted file lands alongside the existing
│ hand-translated set and is picked up by the runner.
│
▼ ❷ tests/ruby_spec.rs (existing micro-runner)
│ Runs every spec file end-to-end through `Runtime`,
│ counting pass/fail per example. Today the file must
│ pass cleanly; a tagged-divergent lane is future work.
│
▼ ❸ docs/SPEC_STATUS.md (auto-generated)
Regenerated by `UPDATE_SPEC_STATUS=1 cargo test -p rubyrs
--test spec_status`; the same test fails CI when the
on-disk file drifts from the corpus. One section per
class with file count, example count, and skipped-trace
breakdown by `polish.py` category.
Each piece is a small, focused tool. The pipeline grows by teaching
spec_extract new patterns, not by re-doing manual work.
rubyrs-spec-extract will support more mspec patterns over time. We treat each
pattern as a version increment:
| Version | Recognises | Estimated reach | Status |
|---|---|---|---|
| v0.1 | expr.should == val, require_relative strip |
~10–20% of spec files | shipped |
| v0.2 | should_not == val, predicate matchers (.should.foo? / .should_not.foo?), -> { ... }.should.raise(X) lambda lowering (incl. M::Cls class paths) |
+20–25% | shipped |
| v0.3 | before :each body lift into sibling its, mock_int(LITERAL_INT) substitution, skip-log header listing unrewritten patterns per file |
+10% | shipped |
| v0.4 | it_behaves_like :NAME, args... cross-file inlining (via --shared <path> CLI flag); @method / @method2 / ... substitution; recognisers run on the substituted body |
+20% | shipped |
| ... | ... approaching mspec full | ... |
Anything the extractor doesn't recognise — at any version — passes
through unchanged; the human reviewer sees what's still
hand-translation territory in the diff against upstream. A
"skipped and logged" report (per the original ingestion plan) is
deferred to v0.3 alongside the before / after hook handling,
where the cost-to-write-the-log starts paying off (predicate-heavy
v0.1 / v0.2 files extract mostly cleanly, so a per-line skip log
adds little signal there).
Live counts per class, file, and skip category live in
SPEC_STATUS.md — auto-generated by
cargo test -p rubyrs --test spec_status (regenerate with
UPDATE_SPEC_STATUS=1 cargo test -p rubyrs --test spec_status).
The translation conventions live in
crates/rubyrs/spec/README.md
and the mechanical-extraction pipeline in
crates/rubyrs-spec-extract/README.md.
Every example must pass — there is no "tagged divergent" lane
yet. Skipped upstream it blocks are noted in the spec file's
top-of-file comment with the reason (out of subset / out of
master / fixtures-dependent). When master lands a feature that
unblocks a previously-skipped block, the comment becomes the
ratchet: un-skip and re-test.
Progress goes by extractor-then-curate: run
crates/rubyrs-spec-extract (v0.4 shipped) on the upstream spec
- any shared bodies, then pipe through
crates/rubyrs-spec-extract/scripts/polish.pyto drop fixture / mock / unimplemented-method blocks. Hand-translated files remain as a validation oracle — "does the tool produce the same output as a human did?" — and are kept in lockstep when the extractor gains new patterns.
The intended pull request flow:
- Pick a feature from ROADMAP.md, or a spec directory that is partly red.
- Implement the language feature.
- Write at least one hand-crafted fixture in
tests/fixtures/to lock in observable behaviour. This stays small and human-readable. - If the feature unlocks a previously-skipped block, regenerate the
affected spec via the extractor + polish pipeline (see
crates/rubyrs-spec-extract/README.md) and drop the skip fromcrates/rubyrs/spec/ruby/<feature>_spec.rb. - Run
cargo test. New tests should pass; old ones must still pass. - Regenerate SPEC_STATUS.md:
UPDATE_SPEC_STATUS=1 cargo test -p rubyrs --test spec_status. CI'sspec_status_is_up_to_datetest fails otherwise. - Commit. Reference the spec coverage delta in the commit body.
This loop means every new feature is automatically graded against ruby/spec, without anyone manually picking what to test.
Goal of mspec-inside-rubyrs is on the long-term ROADMAP.md under "Run mspec inside rubyrs". It only becomes meaningful once we have ~95% of mspec's own dependencies running — Symbol, interpolation, Module, exception classes, method_missing, IO, Comparable, Enumerable as a mixin. We get there by following the SPEC_STATUS.md report; once it crosses a threshold the switch is mechanical.
Until then, the extractor approach gives us:
- Faster iteration (no Ruby host bootstrap)
- Identical CRuby/rubyrs comparison, byte-level
- A real number to report, every commit