audition 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1605ecba3ed080146001f514aabd1d5b1754ac94623205f9e47500011b3a0894
4
+ data.tar.gz: acd2a8e10e2c487bfc56b53960b23051795bd2c0274f065146e5d497bc4e11be
5
+ SHA512:
6
+ metadata.gz: 9135eecfc64dbdac1dc3aef23a449f707384d41adb018d634a2940ef1a3eb93cebf0836e333f3d6a6bcb47d4eff9f1efdf219406379202e5a10bd0624785d39f
7
+ data.tar.gz: 2111276ebb2d9006c1fdcd8bafcac5086cb00bcd3fbcb6c57f5a9f30c4e388181f523be0b5839ca35d4d64bb2ebeb60a1332d41286befc13c84e7166f20a1468
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yaroslav Markin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,290 @@
1
+ # Audition
2
+
3
+ Point it at a Ruby script, a gem, a Rack app, or a Rails root and it
4
+ tells you whether that code can run inside Ractors, why it cannot,
5
+ and how to fix it. Unlike a linter, audition does not stop at
6
+ pattern-matching your source: it also loads the target in a
7
+ sandboxed subprocess and observes real `Ractor::IsolationError`s on
8
+ the live object graph.
9
+
10
+ [![GitHub Release](https://img.shields.io/github/v/release/yaroslav/audition)](https://github.com/yaroslav/audition/releases)
11
+ [![Docs](https://img.shields.io/badge/yard-docs-blue.svg)](https://rubydoc.info/gems/audition)
12
+
13
+ - **Three probes, one verdict.** Per-file Prism AST checks,
14
+ whole-program semantic analysis on the
15
+ [rubydex](https://github.com/Shopify/rubydex) graph (class-level
16
+ state is resolved across files and reopenings), and dynamic
17
+ in-Ractor execution of the actual target.
18
+ - **Explains, not just flags.** Every finding carries a `why`
19
+ (which rule of the Ractor model it violates) and a `fix`
20
+ (what to write instead).
21
+ - **`--fix` like RuboCop, in two tiers.** Safe corrections:
22
+ `.freeze` on string constants, `Ractor.make_shareable(...)` for
23
+ mutable and shallow-frozen containers and Proc constants, and
24
+ boot-time hoisting of method-body requires. `--fix-unsafe` adds
25
+ semantics-affecting rewrites: magic-comment insertion, class
26
+ memoization to `Ractor.store_if_absent`, `autoload` to
27
+ `require`, and write-once globals/class variables to frozen
28
+ constants. `--dry-run` previews everything as a diff.
29
+ - **Dependency-aware.** Runtime findings are attributed to their
30
+ source via `const_source_location`; when your own code is clean
31
+ but a dependency is not, the verdict is a distinct `blocked`
32
+ state, so `globalid` is not blamed for ActiveSupport's state.
33
+ - **Terminal-native output.** Colors, glyphs, and OSC 8 hyperlinks;
34
+ `path:line` is clickable in supporting terminals. JSON output for
35
+ CI.
36
+
37
+ ```console
38
+ $ audition worker.rb
39
+ * audition 0.1.0 ruby 4.0.6 · script at .
40
+
41
+ worker.rb
42
+ x raises inside a Ractor: Ractor::IsolationError: can not
43
+ access global variable $jobs from non-main Ractor
44
+ why: The script ran fine on the main Ractor but failed under
45
+ Ractor.new; the static findings usually pinpoint the line.
46
+ x worker.rb:1 write to global variable $jobs
47
+ why: Non-main Ractors cannot access global variables; this
48
+ raises Ractor::IsolationError the moment the line executes
49
+ in a Ractor (verified on Ruby 4.0).
50
+ fix: Pass the value into the Ractor explicitly
51
+ (Ractor.new(value) { |v| ... }) or over a Ractor::Port; for
52
+ per-Ractor state use Ractor.current[:key].
53
+ x worker.rb:4 read of global variable $jobs
54
+ ...
55
+
56
+ dynamic probes
57
+ x script probe failed (details above)
58
+
59
+ summary: 3 errors
60
+ verdict: x not ractor-ready
61
+ $ echo $?
62
+ 1
63
+ ```
64
+
65
+ And the whole-bundle view:
66
+
67
+ ```console
68
+ $ audition Gemfile.lock --static-only
69
+ ╭───────────────┬─────────┬───────────┬────────┬──────────┬─────────╮
70
+ │ gem │ version │ verdict │ errors │ warnings │ fixable │
71
+ ├───────────────┼─────────┼───────────┼────────┼──────────┼─────────┤
72
+ │ activesupport │ 8.1.0 │ not ready │ 157 │ 97 │ 77 │
73
+ │ i18n │ 1.14.7 │ not ready │ 48 │ 40 │ 45 │
74
+ │ mail │ 2.9.1 │ not ready │ 27 │ 4 │ 13 │
75
+ │ rack │ 3.2.6 │ not ready │ 23 │ 45 │ 60 │
76
+ │ ... │ │ │ │ │ │
77
+ ╰───────────────┴─────────┴───────────┴────────┴──────────┴─────────╯
78
+ 0 of 11 gems ractor-ready
79
+ ```
80
+
81
+ **Requires Ruby 4.0 or newer**, strictly: the tool targets the modern
82
+ Ractor API (`Ractor::Port`, `Ractor#value`, main-Ractor `require`
83
+ proxying) and its verified semantics.
84
+
85
+ > [!WARNING]
86
+ > The entire codebase was written by Claude Fable 5 (Anthropic).
87
+ > It has a thorough spec suite and was validated against real
88
+ > gems, but no human has reviewed every line. Be wary; read before
89
+ > you trust, especially `--fix` rewrites.
90
+
91
+ ## Table of contents
92
+
93
+ - [Installation](#installation)
94
+ - [Usage](#usage)
95
+ - [Adopting incrementally](#adopting-incrementally)
96
+ - [What it catches](#what-it-catches)
97
+ - [Field notes](#field-notes)
98
+ - [Extending](#extending)
99
+ - [Development](#development)
100
+ - [License](#license)
101
+
102
+ ## Installation
103
+
104
+ ```console
105
+ gem install audition
106
+ ```
107
+
108
+ Or in a Gemfile:
109
+
110
+ ```ruby
111
+ gem "audition", require: false
112
+ ```
113
+
114
+ ## Usage
115
+
116
+ ```console
117
+ audition worker.rb # a script: static + run inside Ractor
118
+ audition my_gem # an installed gem, by name
119
+ audition path/to/gem-checkout # a gem working copy (*.gemspec)
120
+ audition path/to/rack-app # a config.ru directory
121
+ audition path/to/rails-root # a Rails application
122
+ audition lib # any directory, static-only
123
+ audition Gemfile.lock # sweep every gem in the bundle
124
+ audition path/to/app --deps # same, from the app root
125
+ ```
126
+
127
+ Useful flags:
128
+
129
+ | Flag | Effect |
130
+ | --- | --- |
131
+ | `--deps` | sweep the target's Gemfile.lock gem by gem |
132
+ | `--write-baseline` / `--no-baseline` | record / ignore known findings |
133
+ | `--fix` | apply safe corrections, then re-check |
134
+ | `--fix-unsafe` | also apply semantics-affecting corrections |
135
+ | `--dry-run` | with a fix flag: preview edits, change nothing |
136
+ | `--format json` | machine-readable report for CI |
137
+ | `--format github` | GitHub Actions annotations on PR diffs |
138
+ | `--compare old.json` | delta vs a previous report: fixed/introduced |
139
+ | `--static-only` / `--dynamic-only` | pick one probe layer |
140
+ | `--fail-on warning` | stricter CI gate (default: error) |
141
+ | `--capabilities` | table of what this Ruby allows in Ractors |
142
+ | `--timeout 60` | dynamic probe budget in seconds |
143
+ | `--plain` | no colors or hyperlinks (also via NO_COLOR, pipes) |
144
+
145
+ Exit codes: `0` clean, `1` findings at or above the `--fail-on`
146
+ threshold (or a failed dynamic probe), `2` usage error.
147
+
148
+ ## Adopting incrementally
149
+
150
+ Nobody goes from 150 findings to zero in one commit. Three tools
151
+ keep the gate useful from day one:
152
+
153
+ **Baseline.** Record today's findings, then fail CI only on new
154
+ ones:
155
+
156
+ ```console
157
+ audition . --write-baseline # writes .audition-baseline.json
158
+ audition . # exit 0; summary shows "N baselined"
159
+ ```
160
+
161
+ The ledger stores per-check-per-file counts, so line drift never
162
+ invalidates it. `--no-baseline` shows everything again.
163
+
164
+ **Inline pragmas.** Silence a single line, rubocop-style:
165
+
166
+ ```ruby
167
+ $legacy_flag = true # audition:disable global-variables
168
+ risky_call # audition:disable
169
+ ```
170
+
171
+ **Project config.** `.audition.yml` at the target root
172
+ (CLI flags always win):
173
+
174
+ ```yaml
175
+ fail_on: warning
176
+ timeout: 60
177
+ exclude:
178
+ - legacy/**
179
+ - db/schema.rb
180
+ checks:
181
+ disable:
182
+ - at-exit
183
+ ```
184
+
185
+ ## What it catches
186
+
187
+ Static, with file:line precision:
188
+
189
+ - **Global variables**, with a verified allowlist: `$stdout`, `$~`,
190
+ `$!`, `$VERBOSE` writes and friends stay legal.
191
+ - **Class variables**, resolved on the rubydex graph.
192
+ - **Class-level instance variables**, unified across the class
193
+ body, `def self.`, and `class << self`, across files; the classic
194
+ `@cache ||= {}` memoization.
195
+ - **Constants that are not deeply shareable**: bare mutable
196
+ literals, interpolated strings, and the subtle shallow freeze
197
+ (`[[1], [2]].freeze` still raises; audition explains why).
198
+ Honors `# frozen_string_literal:` and
199
+ `# shareable_constant_value:` magic comments.
200
+ - **Sync primitives and Procs in constants** (Mutex, Queue,
201
+ lambdas).
202
+ - **Runtime require and autoload** (serializes all Ractors through
203
+ the main-Ractor proxy).
204
+ - **`Ractor.new` blocks capturing outer locals** (the ArgumentError
205
+ at creation time), resolved through Prism's exact scope depths.
206
+ - **Hostile or removed APIs**: `Ractor.yield`/`take` (gone in 4.0),
207
+ ActiveSupport `class_attribute`/`cattr_*`/`mattr_*`,
208
+ `include Singleton`, `fork`, `ObjectSpace._id2ref`, ENV mutation.
209
+
210
+ Dynamic, on the live object graph:
211
+
212
+ - Runs scripts inside a real Ractor (via `load`, which is not
213
+ proxied) and reports the actual exception.
214
+ - Requires a library, then sweeps every constant it introduced with
215
+ `Ractor.shareable?`, and inspects every class and module for
216
+ class-level ivars and class variables, with
217
+ `const_source_location` attribution.
218
+ - Boots `config.ru` and serves one GET / entirely inside a Ractor,
219
+ the per-worker model of Ractor web servers; then hammers it from
220
+ 4 Ractors x 25 requests to surface failures that only appear
221
+ under concurrency.
222
+ - Boots Rails (`config/environment.rb`), eager-loads, and sweeps
223
+ the application's namespaces.
224
+
225
+ ## Field notes
226
+
227
+ Findings from running audition on popular gems (July 2026,
228
+ Ruby 4.0.6):
229
+
230
+ - **rack 3.2**: `Rack::Builder.parse_file` cannot run inside a
231
+ Ractor at all; `Rack::BUILDER_TOPLEVEL_BINDING` holds an
232
+ unshareable Binding. audition's own rack probe rebuilds the app
233
+ with `Rack::Builder.new` + `instance_eval` to get around it.
234
+ - **mail 2.9**: 28 hard findings, including `@@maximum_amount`,
235
+ `@@autoloads`, and unfrozen table constants like `FIELDS_MAP`.
236
+ - **globalid 1.3**: only 6 findings of its own; the rest of its
237
+ report is ActiveSupport state, attributed as dependency errors
238
+ in the summary.
239
+
240
+ ## Extending
241
+
242
+ Checks are written in a small declarative DSL and can be registered
243
+ from outside the gem:
244
+
245
+ ```ruby
246
+ class NoSleep < Audition::Static::Checks::Base
247
+ check_name "no-sleep"
248
+
249
+ explain :sleepy,
250
+ severity: :warning,
251
+ message: "sleep inside potential Ractor code",
252
+ why: "Blocking one Ractor blocks its whole OS thread.",
253
+ fix: "Prefer Ractor::Port#receive with a timeout."
254
+
255
+ on :call_node do |node|
256
+ flag(node, :sleepy) if node.name == :sleep && !node.receiver
257
+ end
258
+ end
259
+
260
+ Audition::Static::Checks.register(NoSleep)
261
+ ```
262
+
263
+ `on` generates the Prism visitor and always continues traversal;
264
+ `explain` entries are a message catalog with `%{placeholders}`.
265
+
266
+ ## Development
267
+
268
+ ```console
269
+ bundle install
270
+ bundle exec rake spec # RSpec suite
271
+ bundle exec rake standard # standardrb lint
272
+ lefthook install # pre-commit lint hook
273
+ bundle exec exe/audition --capabilities
274
+ ```
275
+
276
+ Static scanning is Ractor-parallel on large targets (one worker
277
+ per core, minus one for the main Ractor); audition's own `lib/`
278
+ passes `audition lib` clean.
279
+
280
+ The design notes in `docs/design.md` include the empirically
281
+ verified Ruby 4.0 Ractor semantics table that the checks are
282
+ calibrated against.
283
+
284
+ ## Assisted by
285
+
286
+ Claude Fable 5.
287
+
288
+ ## License
289
+
290
+ MIT. See LICENSE.txt.
data/exe/audition ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "audition"
5
+
6
+ exit Audition::CLI.run(ARGV)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Audition
6
+ # Incremental-adoption ledger (.audition-baseline.json): known
7
+ # findings recorded as counts per "check|relative/path" so line
8
+ # drift never invalidates it. Present findings up to the recorded
9
+ # count are hidden; anything beyond is new and fails as usual.
10
+ class Baseline
11
+ FILE = ".audition-baseline.json"
12
+
13
+ def self.path_for(root)
14
+ File.join(root.to_s, FILE)
15
+ end
16
+
17
+ def self.write(root, findings)
18
+ counts = Hash.new(0)
19
+ findings.each { |f| counts[key(f, root)] += 1 }
20
+ File.write(
21
+ path_for(root),
22
+ JSON.pretty_generate(counts.sort.to_h) << "\n"
23
+ )
24
+ counts.values.sum
25
+ end
26
+
27
+ def self.load(root)
28
+ path = path_for(root)
29
+ return nil unless File.file?(path)
30
+
31
+ new(JSON.parse(File.read(path)))
32
+ rescue JSON::ParserError => e
33
+ raise Error, "#{path}: #{e.message}"
34
+ end
35
+
36
+ def self.key(finding, root)
37
+ relative =
38
+ finding.path.to_s.delete_prefix("#{root}/")
39
+ "#{finding.check}|#{relative}"
40
+ end
41
+
42
+ def initialize(counts)
43
+ @counts = counts
44
+ end
45
+
46
+ # Budget per key is consumed in finding order.
47
+ #
48
+ # @param findings [Array<Finding>]
49
+ # @param root [String] target root for relative paths
50
+ # @return [Array(Array<Finding>, Integer)] visible findings
51
+ # and the hidden count
52
+ def filter(findings, root:)
53
+ budget = @counts.dup
54
+ hidden = 0
55
+ visible = findings.reject do |finding|
56
+ key = self.class.key(finding, root)
57
+ next false unless budget.fetch(key, 0).positive?
58
+
59
+ budget[key] -= 1
60
+ hidden += 1
61
+ true
62
+ end
63
+ [visible, hidden]
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+
5
+ module Audition
6
+ # Audits every gem in a Gemfile.lock and ranks the results:
7
+ # the "can my whole app move to Ractors" view. Static analysis
8
+ # runs in worker threads; dynamic probes are subprocesses, so
9
+ # they genuinely parallelize.
10
+ class BundleSweep
11
+ CONCURRENCY = 4
12
+
13
+ Row = Data.define(:name, :version, :verdict, :errors,
14
+ :dep_errors, :warnings, :fixable, :status)
15
+
16
+ VERDICT_ORDER = {
17
+ :not_ready => 0, :blocked => 1, :risky => 2, :ready => 3, nil => 4
18
+ }.freeze
19
+
20
+ # @param lockfile [String] path to a Gemfile.lock
21
+ # @param static_only [Boolean] skip dynamic probes
22
+ # @param timeout [Integer] per-probe timeout in seconds
23
+ # @param concurrency [Integer] worker thread count
24
+ def initialize(lockfile:, static_only: false, timeout: 30,
25
+ concurrency: CONCURRENCY)
26
+ @lockfile = lockfile
27
+ @static_only = static_only
28
+ @timeout = timeout
29
+ @concurrency = concurrency
30
+ end
31
+
32
+ # Audits every locked gem and ranks the results, worst first.
33
+ #
34
+ # @param progress [Proc, nil] called with (row, done, total)
35
+ # as each gem finishes
36
+ # @return [Array<Row>]
37
+ def rows(progress: nil)
38
+ gems = locked_gems
39
+ queue = Thread::Queue.new
40
+ gems.each { |g| queue << g }
41
+ queue.close
42
+
43
+ collected = []
44
+ mutex = Thread::Mutex.new
45
+ @concurrency.times.map do
46
+ Thread.new do
47
+ while (name, version = queue.pop)
48
+ row = audit_gem(name, version)
49
+ mutex.synchronize do
50
+ collected << row
51
+ progress&.call(row, collected.size, gems.size)
52
+ end
53
+ end
54
+ end
55
+ end.each(&:join)
56
+
57
+ collected.sort_by do |row|
58
+ [VERDICT_ORDER.fetch(row.verdict), -row.errors, row.name]
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def locked_gems
65
+ parser = Bundler::LockfileParser.new(File.read(@lockfile))
66
+ parser.specs.map { |s| [s.name, s.version.to_s] }.uniq
67
+ end
68
+
69
+ def audit_gem(name, version)
70
+ target = Target.detect(name)
71
+ findings = static_findings(target)
72
+ results = dynamic_results(target)
73
+ report = Report.new(
74
+ target_type: :gem,
75
+ target_root: target.root,
76
+ findings: findings + results.flat_map(&:findings),
77
+ dynamic_results: results
78
+ )
79
+ counts = report.counts
80
+ Row.new(
81
+ name: name, version: version, verdict: report.verdict,
82
+ errors: counts[:error], dep_errors: counts[:dep_error],
83
+ warnings: counts[:warning], fixable: counts[:fixable],
84
+ status: "ok"
85
+ )
86
+ rescue Error
87
+ Row.new(
88
+ name: name, version: version, verdict: nil, errors: 0,
89
+ dep_errors: 0, warnings: 0, fixable: 0,
90
+ status: "not installed"
91
+ )
92
+ end
93
+
94
+ # Worker threads already parallelize across gems; per-gem
95
+ # scanning stays serial to avoid a Ractor storm.
96
+ def static_findings(target)
97
+ per_file = Static::Analyzer.new
98
+ .analyze_paths(target.ruby_files, workers: 1)
99
+ per_file + Static::GraphAudit.new
100
+ .analyze_paths(target.ruby_files)
101
+ end
102
+
103
+ def dynamic_results(target)
104
+ return [] if @static_only || target.entry.nil?
105
+
106
+ prober = Dynamic::Prober.new(timeout: @timeout)
107
+ [prober.probe(target.entry)]
108
+ end
109
+ end
110
+ end