Point it at a Ruby script, a gem, a Rack app, or a Rails root and it
tells you whether that code can run inside Ractors, why it cannot,
and how to fix it. Unlike a linter, Audition does not stop at
pattern-matching your source: whole-program analysis is powered by
rubydex, Shopify's Ruby
indexer, and the target is also loaded in a sandboxed subprocess
to observe real Ractor::IsolationErrors on the live object
graph. Some of the checks and fixes were trained on how Rails
core itself is being ractorized; see the
pattern study.
- Three probes, one verdict. Per-file Prism AST checks, whole-program semantic analysis powered by rubydex (Shopify's code graph; class-level state is resolved across files and reopenings), and dynamic in-Ractor execution of the actual target.
- Explains, not just flags. Every finding carries a
why(which rule of the Ractor model it violates) and afix(what to write instead). --fixlike RuboCop, in two tiers. Safe corrections:.freezeon string constants (literals as well as the fresh strings core methods return), sentinels, and containers whose elements are all shareable, and boot-time hoisting of method-body requires.--fix-unsafeadds semantics-affecting rewrites:Ractor.make_shareable(...)for the remaining mutable and shallow-frozen containers, for containers a core method allocates, and for Proc constants, magic-comment insertion, freeze-on-memoize for class-level memoization (both@x ||=andreturn @x if defined?(@x)idioms keep their caching, the memoized value becomes shareable, Rails-core style;Ractor.store_if_absentonly for block initializers and invalidated caches),autoloadtorequire, and write-once globals/class variables to frozen constants.--dry-runpreviews everything as a diff.- Dependency-aware. Runtime findings are attributed to their
source via
const_source_location; when your own code is clean but a dependency is not, the verdict is a distinctblockedstate, soglobalidis not blamed for ActiveSupport's state. - Dogfooding. The scanner for Ractor compatibility is built using Ractors: static analysis fans out across CPU cores on Ractor workers, and Audition passes its own audit. It runs on itself on every commit (a lefthook pre-commit over the staged files) and on every push (a full self-audit in CI).
- Trained on Rails core. Several checks and fix suggestions
come straight from studying the Rails ractorization effort
(some two hundred commits and pull requests):
Hash.newdefault procs, in-place mutation of registry constants, closure-carryingdefine_method, copy-on-write rewrites. The findings are documented in docs/rails_core_best_practices.md. - Terminal-native output. Colors, glyphs, and OSC 8 hyperlinks;
path:lineis clickable in supporting terminals. Long scans narrate their phases on stderr. JSON output for CI.
$ audition worker.rb
* Audition 0.3.0 ruby 4.0.6 · script at .
worker.rb
x worker.rb:1 write to global variable $jobs global-variables
why: Non-main Ractors cannot access global variables; this raises
Ractor::IsolationError the moment the line executes in a Ractor
(verified on Ruby 4.0).
fix: Pass the value into the Ractor explicitly (Ractor.new(value) {
|v| ... }) or over a Ractor::Port; for per-Ractor state use
Ractor.current[:key]; do one-time process setup on the main Ractor
before spawning.
x worker.rb:1 raises inside a Ractor: Ractor::IsolationError ...
x worker.rb:4 read of global variable $jobs global-variables
...
dynamic probes
x script probe failed (details above)
summary: 3 errors
verdict: x not ractor-ready
$ echo $?
1And the whole-bundle view:
$ audition Gemfile.lock --static-only
╭───────────────────────────────────────────────────────────────────────────────────────╮
│ Audition bundle sweep │
├──────────┬──────────┬─────────────┬────────┬────────────┬──────────┬─────────┬────────┤
│ gem │ version │ verdict │ errors │ dep errors │ warnings │ fixable │ status │
├──────────┼──────────┼─────────────┼────────┼────────────┼──────────┼─────────┼────────┤
│ rubocop │ 1.88.2 │ x not ready │ 194 │ - │ 203 │ 28 │ ok │
│ parser │ 3.3.12.0 │ x not ready │ 154 │ - │ 26 │ 26 │ ok │
│ rack │ 3.2.7 │ x not ready │ 31 │ - │ 61 │ 13 │ ok │
│ rake │ 13.4.2 │ x not ready │ 30 │ - │ 24 │ 5 │ ok │
│ prism │ 1.9.0 │ x not ready │ 16 │ - │ 88 │ 15 │ ok │
│ json │ 2.21.2 │ x not ready │ 7 │ - │ 5 │ - │ ok │
│ pastel │ 0.8.0 │ x not ready │ 2 │ - │ 1 │ - │ ok │
│ tty-link │ 0.2.0 │ x not ready │ 1 │ - │ 1 │ - │ ok │
╰──────────┴──────────┴─────────────┴────────┴────────────┴──────────┴─────────┴────────╯
x 0 of 8 gems ractor-ready · 8 not readyVerdicts are colored by severity, clean counts are left blank, and the sweep names each gem as it finishes:
◆ Audition sweep rubocop 8/8 (100%) 5.8sRequires Ruby 4.0 or newer, strictly: the tool targets the modern
Ractor API (Ractor::Port, Ractor#value, main-Ractor require
proxying) and its verified semantics.
Warning
The entire codebase was written by Claude Fable 5 (Anthropic).
It has a thorough spec suite and was validated against real
gems, but no human has reviewed every line. Be wary; read before
you trust, especially --fix rewrites.
- Installation
- Usage
- Adopting incrementally
- CI and git hooks
- What it catches
- Agent skill
- Extending
- Development
- Acknowledgements
- License
gem install auditionOr in a Gemfile:
gem "audition", require: falseaudition worker.rb # a script: static + run inside Ractor
audition my_gem # an installed gem, by name
audition path/to/gem-checkout # a gem working copy (*.gemspec)
audition path/to/rack-app # a config.ru directory
audition path/to/rails-root # a Rails application
audition lib # any directory, static-only
audition a.rb b.rb c.rb # several files at once, statically
audition Gemfile.lock # sweep every gem in the bundle
audition path/to/app --deps # same, from the app rootUseful flags:
| Flag | Effect |
|---|---|
--deps |
sweep the target's Gemfile.lock gem by gem |
--write-baseline / --no-baseline |
record / ignore known findings |
--fix |
apply safe corrections, then re-check |
--fix-unsafe |
also apply semantics-affecting corrections |
--dry-run |
with a fix flag: preview edits, change nothing |
--format json |
machine-readable report for CI |
--format github |
GitHub Actions annotations + job summary |
--compare old.json |
delta vs a previous report: fixed/introduced |
--static-only / --dynamic-only |
pick one probe layer |
--fail-on warning |
stricter CI gate (default: error) |
--exit-zero |
report findings but never fail the build |
--capabilities |
table of what this Ruby allows in Ractors |
--timeout 60 |
dynamic probe budget in seconds |
--plain |
no colors or hyperlinks (also via NO_COLOR, pipes) |
--progress / --no-progress |
force / suppress the stderr narration |
-j 4 / --workers 4 |
scan Ractors (default: cores, capped by RUBY_MAX_CPU) |
Long runs narrate themselves on stderr, so stdout stays pipeable: a rewriting status line on a terminal, one line per phase off it.
◆ Audition checking 50/919 5% (0.2s, on 8 ractors)
A phase running in Ractors says how many. The narration turns on
by itself for a large tree or a bundle sweep and stays off for
--format json and --format github; the flags force either
way.
Exit codes: 0 clean, 1 findings at or above the --fail-on
threshold (or a failed dynamic probe), 2 usage error.
--exit-zero (alias for --fail-on never) always exits 0
unless the invocation itself is broken.
Nobody goes from 150 findings to zero in one commit. Three tools keep the gate useful from day one:
Baseline. Record today's findings, then fail CI only on new ones:
audition . --write-baseline # writes .audition-baseline.json
audition . # exit 0; summary shows "N baselined"The ledger stores per-check-per-file counts, so line drift never
invalidates it. --no-baseline shows everything again.
Inline pragmas. Silence a single line, rubocop-style:
$legacy_flag = true # audition:disable global-variables
risky_call # audition:disableProject config. .audition.yml at the target root
(CLI flags always win):
fail_on: warning
timeout: 60
exclude:
- legacy/**
- db/schema.rb
test_dirs:
- qa
checks:
disable:
- at-exittest_dirs names the directories that hold tests rather than
code a production boot loads; findings in them are tagged
test and rated as test code. It defaults to test, spec,
features, and replaces that list rather than adding to it—the
_test.rb and _spec.rb suffixes always count, whatever it
says.
GitHub Actions. --format github turns findings into
workflow-command annotations that land right on the PR diff, and
appends a verdict-plus-counts markdown table to the job summary
page. A blocking gate:
- uses: ruby/setup-ruby@v1
with:
ruby-version: "4.0"
- run: gem install audition
- run: audition --format github .To surface findings without failing the build while you adopt
(the flag other linters call --exit-zero too, so it keeps its
name here):
- run: audition --format github --exit-zero .--fail-on never is the long form, and works from
.audition.yml as well; GitHub's own continue-on-error: true
on the step is the workflow-level equivalent.
Git hooks. Passing several .rb files audits exactly those
files statically, which is the shape hook managers hand over.
With lefthook:
pre-commit:
commands:
audition:
glob: "*.rb"
run: audition --static-only --plain {staged_files}With pre-commit:
- repo: local
hooks:
- id: audition
name: Audition
language: system
entry: audition --static-only --plain
types: [ruby]Config, pragmas, and the baseline resolve against the working
directory, so a hook run from the repository root honors the
same .audition.yml as a full audit.
This repository eats its own dog food. Every commit runs
audition --static-only over the staged files through lefthook
(next to standardrb), and every push and pull request gets a
full self-audit in CI with annotations and a job summary,
non-blocking via --exit-zero
(audit.yml). Current state: own
code audits ready; the full dynamic probe reports the terminal
dev-dependencies as blocked, which is exactly the distinction
the verdict system exists to make.
Static, with file:line precision:
- Global variables, with a verified allowlist:
$stdout,$~,$!,$VERBOSEwrites and friends stay legal. - Class variables, resolved on the rubydex graph.
- Class-level instance variables, unified across the class
body,
def self., andclass << self, across files; the classic@cache ||= {}andreturn @x if defined?(@x)memoizations. A memo whose write is proxied to the main Ractor (@x || on_main(self) { @x ||= ... }) is reported as that escape hatch, not as a raw memo. - Constants that are not deeply shareable: bare mutable
literals, interpolated strings, the subtle shallow freeze
(
[[1], [2]].freezestill raises; Audition explains why), and call results the magic comment never covers, from the return-type contracts of core methods: fresh strings (X.tr(":", ""),[8, 2, 0].join("."),Regexp.new,format), fresh containers (TYPES.keys,LIST.map { },BASE + [:x],DEFAULTS.merge(...),.dup), string splitters under a shallow freeze (".*".chars.freeze), and Method objects, which no freeze makes shareable. A spec executes the tables against the running Ruby. Integer arithmetic, comparisons, and negation are recognized as shareable. Honors# frozen_string_literal:and# shareable_constant_value:magic comments. - Instance memoization on classes that get frozen: a lazy
@x ||=on a class whose initialize freezes self raises FrozenError on first use; on a class with afreezeoverride the memo must be warmed inside the override beforesuper(compute on freeze), and one left cold is reported. - Sync primitives and Procs in constants (Mutex, Queue,
lambdas), including
Hash.new { }default procs, which stay unshareable even after.freeze. - Registry-style constant mutation (
RENDERERS << key,LOOKUP[k] = v) anddefine_methodwith a literal block (the method carries an unshareable Proc); both patterns and their fixes come from the Rails core ractorization study. - Runtime require and autoload (serializes all Ractors through the main-Ractor proxy).
Ractor.newblocks capturing outer locals (the ArgumentError at creation time), resolved through Prism's exact scope depths; and blocks thatRactor.shareable_procwould refuse, handed to it directly or to a Rails callback macro (before_create,validate,on_load, ...), because a captured local holds a provably unshareable value (prefix = +"Draft: ") or is assigned more than once.- Hostile or removed APIs:
Ractor.yield/take(gone in 4.0), ActiveSupportcattr_*/mattr_*class variables (with theclass_attributemigration Rails itself made) andclass_attributewithout copy-on-write writes,include Singleton,fork,ObjectSpace._id2ref, ENV mutation. - Native extensions that never declare Ractor safety: a byte
scan of every compiled
.bundle/.sofor therb_ext_ractor_safeimport, which also covers precompiled platform gems that ship no sources; an unbuilt checkout is scanned at the source level instead (C, Rust, or Zig sources beside theirextconf.rb,Cargo.tomlorbuild.zig). A silent extension raisesRactor::UnsafeErroron every call from a non-main Ractor, so it rates a warning; a declared one gets an info note, because the declaration is the maintainer's assertion, not a proof. - The static pass's own blind spots: where rubydex reports an
expression it could not resolve and the shape could hide what
the checks above look for, the hole is reported rather than
read as a clean line — a singleton opened on a runtime receiver
that writes class-level state, a superclass or
includeargument computed at runtime, a constant assigned through an unresolved path. A clean report for such a class covers only what the class itself declares; the dynamic probe reaches the rest.
Dynamic, on the live object graph:
- Runs scripts inside a real Ractor (via
load, which is not proxied) and reports the actual exception. - Requires a library, then sweeps every constant it introduced,
under new namespaces and pre-existing ones alike, with
Ractor.shareable?, and inspects every class and module for class-level ivars and class variables, withconst_source_locationattribution. What the sweep proves shareable retires the static guesses about the same objects: an unproven constant warning disappears, and class-level state that held only shareable values after boot keeps an info note instead of an error. - Records every compiled extension the load pulled in,
dependencies included, and byte-scans each for the
rb_ext_ractor_safeimport; silent ones are reported against the dependency that ships them. Ruby's own extensions are left to Ruby. - Boots
config.ruand serves one GET / entirely inside a Ractor, the per-worker model of Ractor web servers; then hammers it from 4 Ractors x 25 requests to surface failures that only appear under concurrency. - Boots Rails (
config/environment.rb) withunshareable_proc_actionarmed, so every callback block Rails cannot make shareable is reported at the Proc's definition site; eager-loads; on Rails 8.2 callsractorize!to freeze the application graph, then serves one GET / on the main Ractor (where a lazy memo on a now-frozen object raises FrozenError) and one inside a Ractor; and sweeps the application's namespaces. Withoutractorize!an info note says so.
This repository ships a ractor-readiness skill that teaches
coding agents (Claude Code and friends) the full Audition
workflow: audit, fix tiers, suite-parity verification, and
incremental adoption. It lives in
skills/ractor-readiness/SKILL.md.
Install into Claude Code as a plugin:
/plugin marketplace add yaroslav/audition
/plugin install audition@audition
Or install the skill with the skills CLI:
$ npx skills add yaroslav/auditionChecks are written in a small declarative DSL and can be registered from outside the gem:
class NoSleep < Audition::Static::Checks::Base
check_name "no-sleep"
explain :sleepy,
severity: :warning,
message: "sleep inside potential Ractor code",
why: "Blocking one Ractor blocks its whole OS thread.",
fix: "Prefer Ractor::Port#receive with a timeout."
on :call_node do |node|
flag(node, :sleepy) if node.name == :sleep && !node.receiver
end
end
Audition::Static::Checks.register(NoSleep)on generates the Prism visitor and always continues traversal;
explain entries are a message catalog with %{placeholders}.
bundle install
bundle exec rake spec # RSpec suite
bundle exec rake standard # standardrb lint
lefthook install # pre-commit standardrb + audition
bundle exec exe/audition --capabilitiesStatic scanning is Ractor-parallel on large targets (one worker
per core, minus one for the main Ractor); Audition's own lib/
passes audition lib clean.
The design notes in docs/design.md include the empirically
verified Ruby 4.0 Ractor semantics table that the checks are
calibrated against.
The whole-program checks stand on rubydex, Shopify's high-performance static analysis suite for Ruby: Audition feeds every file into its graph and reads state ownership back out. Thanks to its authors, in particular the top five contributors: Alexandre Terrasa, Vinicius Stock, Alex Rocha, Stan Lo, and Soutaro Matsumoto.
Claude Fable 5.
MIT. See LICENSE.txt.
