Skip to content

yaroslav/audition

Repository files navigation

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.

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, Ractor.make_shareable(...) for mutable and shallow-frozen containers and Proc constants, and boot-time hoisting of method-body requires. --fix-unsafe adds semantics-affecting rewrites: 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.
  • Trained on Rails core. Several checks and fix suggestions come straight from studying the Rails ractorization effort (about 75 substantive commits): 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. JSON output for CI.
$ audition worker.rb
* audition 0.1.0 ruby 4.0.6 · script at .

  worker.rb
    x raises inside a Ractor: Ractor::IsolationError: can not
      access global variable $jobs from non-main Ractor
      why: The script ran fine on the main Ractor but failed under
      Ractor.new; the static findings usually pinpoint the line.
    x worker.rb:1  write to global variable $jobs
      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].
    x worker.rb:4  read of global variable $jobs
      ...

  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
╭───────────────┬─────────┬───────────┬────────┬──────────┬─────────╮
│ gem           │ version │ verdict   │ errors │ warnings │ fixable │
├───────────────┼─────────┼───────────┼────────┼──────────┼─────────┤
│ activesupport │ 8.1.0   │ not ready │    157 │       97 │      77 │
│ i18n          │ 1.14.7  │ not ready │     48 │       40 │      45 │
│ mail          │ 2.9.1   │ not ready │     27 │        4 │      13 │
│ rack          │ 3.2.6   │ not ready │     23 │       45 │      60 │
│ ...           │         │           │        │          │         │
╰───────────────┴─────────┴───────────┴────────┴──────────┴─────────╯
0 of 11 gems ractor-ready

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 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 on PR diffs
--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)
--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)

Exit codes: 0 clean, 1 findings at or above the --fail-on threshold (or a failed dynamic probe), 2 usage error.

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
checks:
  disable:
    - at-exit

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.
  • Constants that are not deeply shareable: bare mutable literals, interpolated strings, and the subtle shallow freeze ([[1], [2]].freeze still raises; audition explains why). Honors # frozen_string_literal: and # shareable_constant_value: magic comments.
  • 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.
  • Hostile or removed APIs: Ractor.yield/take (gone in 4.0), ActiveSupport class_attribute/cattr_*/mattr_*, include Singleton, fork, ObjectSpace._id2ref, ENV mutation.

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 with Ractor.shareable?, and inspects every class and module for class-level ivars and class variables, with const_source_location attribution.
  • 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), eager-loads, and sweeps the application's namespaces.

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 lint hook
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.

Assisted by

Claude Fable 5.

License

MIT. See LICENSE.txt.

About

Auditions Ruby scripts, gems, Rack apps, and Rails applications for the ability to run under Ractors: static analysis of Ractor-isolation violations plus dynamic in-Ractor probing, with explanations and fixes.

Topics

Resources

License

Stars

9 stars

Watchers

1 watching

Forks

Packages

 
 
 

Contributors

Languages