Developer-experience tooling for Spinel, the whole-program Ruby → C AOT compiler — plus the design notes (RFC / discussion) that motivated it.
Status. This repo began as analysis — can Ruby tooling work at all against a closed-world, no-VM, no-
evalcompiler? That question is now answered by working tools, below, and the compiler-side hooks they need have all merged upstream inmatz/spinel— landed one PR at a time (--emit-rbs,--debug,--emit-types, native backtrace — see Compiler surfaces). Everything is opt-in / debug-gated; non-debug release output is byte-for-byte unchanged. The design docs remain as the rationale and open-discussion surface — treat them as RFCs.Upstream now ships its own first-party
tools/(spinel-doctor,spinel-reduce,spinel-flatten), so spinel-dev's role has narrowed to the differential / migration / perf layer on top: value-bisect,spinel-reduce-project,spinel-gate-bisect,spinel-migrate/-probe, the perf / flamegraph tools, anddoctor.sh's deep legs (which delegate the basic checks to upstreamspinel-doctorwhen present).
Each is runnable today. The standalone tools live in tools/; the
compiler flags now all ship in matz/spinel (--emit-rbs, --debug,
--emit-types, native backtrace — see Compiler surfaces).
Jump to Getting started for hands-on usage.
tools/doctor/ · doctor.sh [--json] [--no-bisect] <program.rb>
Six escalating checks in one command — it delegates the basic legs to upstream's
first-party spinel-doctor when it's on PATH and layers the deep differential
legs on top: an ignored require (the prime suspect for a silent-degrade
cascade — a wrong path silently unloads a whole module), the compile probe
(an unsupported call hard-errors spinel: unsupported …; a dynamic-receiver call
can silently degrade to nil/0, surfaced by SPINEL_WARN_UNRESOLVED), the
inference scan (methods widened
to untyped), an inference↔codegen disagreement (inference resolves a method
but codegen emits-0 — the static silent-miscompile fingerprint), a codegen
build check (cc -c the emitted C — catches what analysis misses, like a Class
boxed as int), and the behavior diff vs CRuby. Verdict from clean to
codegen-error. Human or --json. doctor-gate wraps it for CI: an allowlist
of known degrades, non-zero exit on a new degrade, a disagreement, a codegen
error, or a miscompile.
tools/reduce/ · spinel-reduce.rb [--target SUBSTR] <degrading.rb>
Delta-debugs a degrading program to its minimal trigger (ddmin with doctor --json as the oracle): keep removing code while the target finding still
reproduces; what survives is the cause. Flattening — inlining a gem's
require_relative graph into one file first, so a real gem's failing smoke
becomes a minimal bug report automatically — is now upstream's first-party
spinel-flatten; spinel-dev's spinel-flatten.rb is deprecated (upstream
supersedes it).
spinel-reduce-project handles the case single-file ddmin can't: a
surface-dependent miscompile where an isolated probe compiles but the full
multi-file compilation unit fails (the "f7ae245 signature" — a poly/array
element type that degrades only once enough of the program is in scope). It
reduces across the real require_relative graph, using the project's own
build as the oracle (spinel [--rbs DIR] <entry> -o bin, so --rbs, FFI
link/cflags, and require resolution behave exactly as in the real build —
which doctor -c on a flattened file can't reproduce). Two passes: drop whole
files from the graph, then drop top-level defs/classes from the survivors. What
remains is the minimal multi-file surface that still triggers the error.
tools/value-bisect/ · bisect.sh [--json] <program.rb>
Runs a program under CRuby (the oracle) and as a Spinel --debug binary,
traces the change-history of every scalar local on both sides, and reports the
first (file, line, variable) whose value diverges — pinpointing a silent
miscompile, the failure mode spinelgems calls the dangerous one. Multi-file
(require_relative chains traced too). triage.sh --failing localizes every
test-suite failure the same way. Consumed by spinelgems verify to upgrade a
"the outputs differ" verdict into a line to look at.
A ruby-lsp addon that surfaces Spinel's per-node type inference on hover, and flags where a type degraded to the boxed poly slow path — directly attacking the silent-miscompile problem at authoring time.
The same inference + #line substrate, turned on speed. speedup-estimate
scores a program's untyped/poly density (the static "should I port this gem?"
gate); spinel-perf profiles a -pg build and maps hot frames back to Ruby
lines with a GC-vs-user self-time split; rbs-disagree finds positions where
the compiler's inference disagrees with a consumer's (a candidate-bug localizer —
it found one). And spinel-flamegraph
renders the call hierarchy with frames demangled to Class#method:
That's an AOT-compiled Rails blog under load. The flame is ~72% red at the
leaves: every hot path (Tep::Request#new, ActiveRecord::Base#save,
ActionView::ViewHelpers.*) bottoms out in sp_gc_alloc. The win over CRuby is
real but capped at ~1.5–1.9× here — because this workload is allocation-bound,
not boxing-bound, a decomposition the flamegraph makes legible at a glance.
Write-up: docs/08, discussion on
spinel-dev#5/#7.
tools/migrate/ · spinel-migrate.rb --to <dir> [--from <dir>] [--rbs DIR] <target.rb>...
tools/probe/ · spinel-probe.rb [--json]
tools/migrate/ · spinel-gate-bisect.sh --repo <dir> --good <rev> --bad <rev> --compile <entry.rb> --bad-when <regex>
When matz/master makes a large change (the June 2026 Ruby→C rewrite is the
motivating case), these answer can my project move, and if not, what's blocking?
spinel-migrate compiles a project's build targets with a candidate compiler
(--to) and, optionally, the current pin (--from), then prints a go/no-go diff:
each target's status (ok / REGRESSED / still-broken) with the first blocker
attributed to a Ruby source site — the manual "build each target on both, diff
the outcomes" probe, codified. spinel-probe underpins it (and retires the
version-guard debt the other tools accreted): a one-shot manifest of a checkout's
driver (C binary vs legacy shell), layout (legacy/-split vs root),
supported --emit-* flags, error model (strict hard-error vs silent emit-0),
and symbol-map mode (emit-only vs ride-along) — so tools and downstream
Makefiles adapt to a layout/flag shift at one tested point. And when a master
bump breaks the project, spinel-gate-bisect wraps git bisect run over the
spinel rev range: it builds each candidate, runs the project gate as the
discriminator (compile the target; clean = good, the regression regex = bad,
any other failure = skip), and reports the first-bad commit — the
toolchain-broken-→-skip discipline the manual #13/#14 bisects needed, automated.
Design: docs/09.
Spinel is pre-alpha and moves fast — matz/master shifted three times in a
single afternoon of this work (a layout move, then individual codegen fixes). We
build real projects on it anyway (toy, a
pure-Ruby ML stack; tep, a web framework),
and that's deliberate: a whole-program AOT compiler only gets exercised by whole
programs. Compiling a real surface flushes out bugs no unit test reaches — the
poly-size Array.new, the $stderr.puts-in-value-position, the surface-dependent
element-typing collapses — all of which surfaced because something downstream
broke, and several of which are now fixed upstream as a result.
The cost is that every master bump can break the build. The goal of the migration tools is to make absorbing that as painless as possible — without slowing upstream down. matz moving fast is a feature; the downstream's job is to keep pace cleanly, not to ask the compiler to wait. So when a bump breaks a project, the discipline is to triage which kind of break it is and respond in kind:
- A usage issue — the lib leaned on behavior that legitimately changed, or carried a workaround the compiler has since obsoleted. → refactor the lib. (The right outcome; the workaround was debt.)
- An actual regression — the compiler miscompiles valid Ruby. → file a clear, minimal reproducer (and, where we can, the fix). A 12-line repro and a one-line PR cost upstream almost nothing to accept; a "toy doesn't build" bug report costs a lot.
The tools exist to make that triage fast and the reproducers clean: probe says
what changed in the compiler's shape, migrate says which targets a bump breaks
and where, reduce-project turns a multi-file miscompile into a filable repro,
and gate-bisect pins the commit when it's a regression. That loop —
build-real-on-pre-alpha → triage → refactor-or-reduce → upstream — is the whole
point of this repo's second phase.
You need a built spinel (the AOT compiler). Point the standalone tools at it
with SPINEL_DIR (defaults to ~/sites/spinel). The bisector also needs
python3 + lldb.
git clone https://github.com/matz/spinel && cd spinel && make all # builds the C compiler → bin/spinel (./spinel symlink); legacy/ is separate (make legacy)
export SPINEL_DIR=$PWD
git clone https://github.com/OriPekelman/spinel-dev && cd spinel-dev1 — "Is my program safe to compile?" Run the one-shot health check:
sh tools/doctor/doctor.sh app.rb
# compile ⚠ 1 silent degrade — dynamic-receiver call lowered to nil/0 (SPINEL_WARN_UNRESOLVED):
# - app.rb:12: warning: unresolved call 'each' on int receiver -> nil
# inference ⚠ 3 method(s) widened to untyped (slow path / inference gap)
# behavior ✓ matches CRuby (value-bisection)
# verdict degrades--json for CI/agents. --no-cruby for FFI/AOT-only apps that can't run under
CRuby (reports ran/crash from the Spinel side alone).
2 — "It compiled but the output is wrong. Where?" Localize the divergence:
sh tools/value-bisect/bisect.sh app.rb
# [MISCMP] x @ app.rb:12 CRuby=9223372036854775808 Spinel=-9223372036854775808It traces every scalar/string/array/hash/bignum local under CRuby and a Spinel
--debug build and reports the first to diverge — or output-differ when the
wrong value never lands in a local. triage.sh --failing does this for a whole
failing test suite.
3 — "What did the compiler infer?"
spinel app.rb --emit-rbs # app.rbs — signatures (untyped = slow path)
spinel app.rb --emit-types -o t.json # per-position {file,line,col,type} + degrade diagnostics4 — "Step through the compiled binary."
spinel app.rb --debug -o app && gdb ./app # break app.rb:42 ; step ; bt → Ruby lines5 — "In my editor." Install tools/ruby-lsp-spinel
as a ruby-lsp addon for inferred types on hover + degrade underlines.
Per-tool detail lives in each tool's README; the agentic/CI patterns are in docs/03.
The tools rely on a few opt-in, output-neutral compiler hooks. These landed in
matz/spinel one reviewable PR at a time — all now merged:
spinel --emit-rbs— whole-program inference exported as RBS signatures. ✅ Merged (matz/spinel#1276).spinel --debug/-g—#linedirectives for native-debugger (lldb/gdb) stepping through Ruby source + non-inlined methods. ✅ Merged (matz/spinel#1292).spinel --emit-types— the same inference as position-keyed JSON (what the LSP consumes). ✅ Merged (matz/spinel#1298).- native
Exception#backtrace/Kernel#caller(macOS + Linux) ✅ Merged (matz/spinel#1300), plus FloatArraydelete_at/join(#1301) and a debug-only null-receiver guard (NoMethodErrorinstead of a silent NULL deref).
| Doc | What it covers |
|---|---|
| 00-architecture-constraints | The Spinel design facts that govern every answer below. Read first. |
| 01-debuggability | Can byebug/pry work? An "auto LSP"? What works today, what's cheap, what's structurally impossible. |
| 02-compile-gems-reverse-cext | Could Spinel compile Ruby into a CRuby C-extension — keep interpreted Ruby as the workhorse? Feasibility + the v1 target. |
| 03-tooling-for-contributors-and-agents | Operator's manual for the tools above: proof-of-value runs, the agentic dev loop, the upstreaming rationale. |
| 04-tooling-for-developers | Gem-author / app-developer how-to: check a binary matches CRuby, debug + backtrace, read inferred types. |
| 05-tooling-surfaces-and-roadmap | Gap analysis — which surfaces (CI, terminal, IDE/DAP, type-checker, packaging) are still needed, in suggested order. |
| 06-validation-results | Evidence the tools are valuable: --emit-rbs vs tep's authored RBS (~73% agreement), doctor on toy (real emit-0 + degrades), and the limitations both expose. |
| 07-packaging | How the tools ship: compiler features upstream, harness as three small gems (ruby-lsp-spinel, spinel-bisect, spinel-doctor). Proposal; unpublished pending upstream merge. |
| 08-perf-analysis | Proposal: "would Spinel make you faster?" (static degrade-scan estimate) + "why slow?" (a profiler over the #line map). Reuses the inference + source-map substrate. |
| 09-tracking-upstream-migrations | Proposal: tools to help downstream projects (toy/tep) move with a fast-moving matz/master across large changes — a parity probe, a capability manifest, a gate-bisector. Grounded in absorbing the Ruby→C rewrite. |
- matz/spinel — the AOT compiler. Now a
hand-written C compiler (
src/*.c→bin/spinel); the self-hosted Ruby pipeline (spinel_parse→spinel_analyze→spinel_codegen) is retained underlegacy/as a parity oracle only. Whole-program inference; native C value model (noVALUE, no VM, noeval). - spinelgems (
bundler-spinel) — dependency gating + the vendor flow that links C extensions into a Spinel binary, plus theverifieddifferential harness (which calls value-bisect). - tep — Sinatra-flavoured web framework compiled through Spinel; the largest real-world Spinel app and a codegen torture test.
- toy — pure-Ruby ML framework compiled by Spinel (Tep's downstream consumer).
Spinel's design — closed-world, no VM, no VALUE, native-typed locals, no eval
— rules out runtime debuggers and a live REPL, but makes the opposite cheap: the
compiler already computes a whole-program type for every node and (under
--debug) a #line map back to Ruby. The tools just surface that. The specific
hooks — source-mapped debug builds, inference export (RBS / JSON), native
backtraces — are listed with their upstreaming status under
Compiler surfaces; the design rationale is in
docs 00–01.
One load-bearing caveat: #line directives perturb DWARF variable-location
info, so a debugger misreads locals in functions with heap locals — stepping
and the line table are correct. value-bisect sidesteps it by tracing a
#line-free build and mapping C lines back.
The throughline: every capability is opt-in and observability-only — it exposes what the compiler already knows or adds a debug-gated build mode, and never changes the semantics or the byte-for-byte output of a release build.