Skip to content

OriPekelman/spinel-dev

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

71 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

spinel-dev

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-eval compiler? That question is now answered by working tools, below, and the compiler-side hooks they need have all merged upstream in matz/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, and doctor.sh's deep legs (which delegate the basic checks to upstream spinel-doctor when present).

The tools

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.

spinel doctor — one-shot health check

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.

spinel-reduce / spinel-reduce-project — minimal repro from a degrade

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.

value-bisect — differential value localization

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.

ruby-lsp-spinel — inferred types in the editor

tools/ruby-lsp-spinel/

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.

performance analysis — would it be faster? why is it slow?

tools/perf/

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:

Spinel flamegraph of the roundhouse Rails blog — ~72% of self-time is GC/alloc, in red

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.

spinel-migrate / spinel-probe / spinel-gate-bisect — track a moving compiler

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.

Keeping up with master

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.

Getting started

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-dev

1 — "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=-9223372036854775808

It 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 diagnostics

4 — "Step through the compiled binary."

spinel app.rb --debug -o app && gdb ./app    # break app.rb:42 ; step ; bt  → Ruby lines

5 — "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.

Compiler surfaces (upstreaming)

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#line directives 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 FloatArray delete_at/join (#1301) and a debug-only null-receiver guard (NoMethodError instead of a silent NULL deref).

Design docs (RFC / discussion)

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.

Sibling projects

  • matz/spinel — the AOT compiler. Now a hand-written C compiler (src/*.cbin/spinel); the self-hosted Ruby pipeline (spinel_parsespinel_analyzespinel_codegen) is retained under legacy/ as a parity oracle only. Whole-program inference; native C value model (no VALUE, no VM, no eval).
  • spinelgems (bundler-spinel) — dependency gating + the vendor flow that links C extensions into a Spinel binary, plus the verified differential 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).

Why this is cheap in Spinel

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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors