Audition

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.

gem audition

GitHub Release Docs

  • 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 a fix (what to write instead).
  • --fix like RuboCop, in two tiers. Safe corrections: .freeze on 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-unsafe adds 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 ||= and return @x if defined?(@x) idioms keep their caching, the memoized value becomes shareable, Rails-core style; Ractor.store_if_absent only for block initializers and invalidated caches), autoload to require, and write-once globals/class variables to frozen constants. --dry-run previews 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 distinct blocked state, so globalid is 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.new default procs, in-place mutation of registry constants, closure-carrying define_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:line is 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 $?
1

And 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 ready

Verdicts 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.8s

Requires 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.

Table of contents

Installation

gem install audition

Or in a Gemfile:

gem "audition", require: false

Usage

audition 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 root

Useful 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.

Adopting incrementally

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:disable

Project 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-exit

test_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.

CI and git hooks

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.

What it catches

Static, with file:line precision:

  • Global variables, with a verified allowlist: $stdout, $~, $!, $VERBOSE writes and friends stay legal.
  • Class variables, resolved on the rubydex graph.
  • Class-level instance variables, unified across the class body, def self., and class << self, across files; the classic @cache ||= {} and return @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]].freeze still 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 a freeze override the memo must be warmed inside the override before super (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) and define_method with 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.new blocks capturing outer locals (the ArgumentError at creation time), resolved through Prism's exact scope depths; and blocks that Ractor.shareable_proc would 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), ActiveSupport cattr_*/mattr_* class variables (with the class_attribute migration Rails itself made) and class_attribute without 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/.so for the rb_ext_ractor_safe import, 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 their extconf.rb, Cargo.toml or build.zig). A silent extension raises Ractor::UnsafeError on 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 include argument 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, with const_source_location attribution. 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_safe import; silent ones are reported against the dependency that ships them. Ruby's own extensions are left to Ruby.
  • Boots config.ru and 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) with unshareable_proc_action armed, so every callback block Rails cannot make shareable is reported at the Proc's definition site; eager-loads; on Rails 8.2 calls ractorize! 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. Without ractorize! an info note says so.

Agent skill

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/audition

Extending

Checks 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}.

Development

bundle install
bundle exec rake spec       # RSpec suite
bundle exec rake standard   # standardrb lint
lefthook install            # pre-commit standardrb + audition
bundle exec exe/audition --capabilities

Static 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.

Acknowledgements

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.

Assisted by

Claude Fable 5.

License

MIT. See LICENSE.txt.