audition 0.2.4 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76a1b0c57c7a373182f7b9f88f765fa8e0167a492e28d028998335bc246c10d9
4
- data.tar.gz: 2c5ecb49ed0a63ebd7a58cb3db9e8b5e8f9893953198d1c3e9f0cc5ca74cfb3a
3
+ metadata.gz: 52c12c94d193fa6946553c81ab22b8c21272f269ad4f6f758e99fa4a1c05217e
4
+ data.tar.gz: 858d3ef4e5742ca430337656b9ecea865a89c3f3525ef990073499c8eb438bca
5
5
  SHA512:
6
- metadata.gz: 633d141cdc23a3e2e24df8b983b9112ffa9a759194669c4f9a6f62f0bf07219b507bd72cea8235de3c7193ab934c60ca8c98ee2095eabe21fad2e80f682ea8e2
7
- data.tar.gz: bfd863839b4d5fbea291d22282487de79d261fd55bba5662d82d285e013827290257ca61d5b637000581453ec8f3a0510b1571bf6492d1b9f6f9e7de9f3af225
6
+ metadata.gz: 60d78e8622cfe35764e7dd30be43ba7e1ceda4f0faf10796230dd6ddb85d952736b345bd380b58823cf97c6687209f6c0fe709193f58326464b61950af1c8a70
7
+ data.tar.gz: 8edc6f7ba79f531317e26f5f1866540ec784d3791db0b6a43ae56d990b22bd173b5234a05618a8b75ba4c5bf1d95bb06a9898c726b493456c36604cdb7c90b66
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Point it at a Ruby script, a gem, a Rack app, or a Rails root and it
4
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
5
+ and how to fix it. Unlike a linter, Audition does not stop at
6
6
  pattern-matching your source: whole-program analysis is powered by
7
7
  [rubydex](https://github.com/Shopify/rubydex), Shopify's Ruby
8
8
  indexer, and the target is also loaded in a sandboxed subprocess
@@ -11,6 +11,10 @@ graph. Some of the checks and fixes were trained on how Rails
11
11
  core itself is being ractorized; see the
12
12
  [pattern study](docs/rails_core_best_practices.md).
13
13
 
14
+ <div align="center">
15
+ <img src="https://raw.githubusercontent.com/yaroslav/audition/refs/heads/main/assets/images/audition.png" width="512" height="512" alt="gem audition" title="gem audition">
16
+ </div>
17
+
14
18
  [![GitHub Release](https://img.shields.io/github/v/release/yaroslav/audition)](https://github.com/yaroslav/audition/releases)
15
19
  [![Docs](https://img.shields.io/badge/yard-docs-blue.svg)](https://rubydoc.info/gems/audition)
16
20
 
@@ -24,8 +28,10 @@ core itself is being ractorized; see the
24
28
  (which rule of the Ractor model it violates) and a `fix`
25
29
  (what to write instead).
26
30
  - **`--fix` like RuboCop, in two tiers.** Safe corrections:
27
- `.freeze` on string constants, `Ractor.make_shareable(...)` for
28
- mutable and shallow-frozen containers and Proc constants, and
31
+ `.freeze` on string constants, sentinels, and containers whose
32
+ elements are all shareable, `Ractor.make_shareable(...)` for
33
+ the remaining mutable and shallow-frozen containers and for
34
+ Proc constants, and
29
35
  boot-time hoisting of method-body requires. `--fix-unsafe` adds
30
36
  semantics-affecting rewrites: magic-comment insertion,
31
37
  freeze-on-memoize for class-level memoization (both `@x ||=`
@@ -41,35 +47,35 @@ core itself is being ractorized; see the
41
47
  state, so `globalid` is not blamed for ActiveSupport's state.
42
48
  - **Dogfooding.** The scanner for Ractor compatibility is built
43
49
  using Ractors: static analysis fans out across CPU cores on
44
- Ractor workers, and audition passes its own audit.
50
+ Ractor workers, and Audition passes its own audit. It runs on
51
+ itself on every commit (a lefthook pre-commit over the staged
52
+ files) and on every push (a full self-audit in CI).
45
53
  - **Trained on Rails core.** Several checks and fix suggestions
46
54
  come straight from studying the Rails ractorization effort
47
- (about 75 substantive commits): `Hash.new` default procs,
48
- in-place mutation of registry constants, closure-carrying
49
- `define_method`, copy-on-write rewrites. The findings are
50
- documented in
55
+ (some two hundred commits and pull requests): `Hash.new`
56
+ default procs, in-place mutation of registry constants,
57
+ closure-carrying `define_method`, copy-on-write rewrites. The
58
+ findings are documented in
51
59
  [docs/rails_core_best_practices.md](docs/rails_core_best_practices.md).
52
60
  - **Terminal-native output.** Colors, glyphs, and OSC 8 hyperlinks;
53
- `path:line` is clickable in supporting terminals. JSON output for
54
- CI.
61
+ `path:line` is clickable in supporting terminals. Long scans
62
+ narrate their phases on stderr. JSON output for CI.
55
63
 
56
64
  ```console
57
65
  $ audition worker.rb
58
- * audition 0.1.0 ruby 4.0.6 · script at .
66
+ * Audition 0.3.0 ruby 4.0.6 · script at .
59
67
 
60
68
  worker.rb
61
- x raises inside a Ractor: Ractor::IsolationError: can not
62
- access global variable $jobs from non-main Ractor
63
- why: The script ran fine on the main Ractor but failed under
64
- Ractor.new; the static findings usually pinpoint the line.
65
- x worker.rb:1 write to global variable $jobs
66
- why: Non-main Ractors cannot access global variables; this
67
- raises Ractor::IsolationError the moment the line executes
68
- in a Ractor (verified on Ruby 4.0).
69
- fix: Pass the value into the Ractor explicitly
70
- (Ractor.new(value) { |v| ... }) or over a Ractor::Port; for
71
- per-Ractor state use Ractor.current[:key].
72
- x worker.rb:4 read of global variable $jobs
69
+ x worker.rb:1 write to global variable $jobs global-variables
70
+ why: Non-main Ractors cannot access global variables; this raises
71
+ Ractor::IsolationError the moment the line executes in a Ractor
72
+ (verified on Ruby 4.0).
73
+ fix: Pass the value into the Ractor explicitly (Ractor.new(value) {
74
+ |v| ... }) or over a Ractor::Port; for per-Ractor state use
75
+ Ractor.current[:key]; do one-time process setup on the main Ractor
76
+ before spawning.
77
+ x worker.rb:1 raises inside a Ractor: Ractor::IsolationError ...
78
+ x worker.rb:4 read of global variable $jobs global-variables
73
79
  ...
74
80
 
75
81
  dynamic probes
@@ -85,16 +91,29 @@ And the whole-bundle view:
85
91
 
86
92
  ```console
87
93
  $ audition Gemfile.lock --static-only
88
- ╭───────────────┬─────────┬───────────┬────────┬──────────┬─────────╮
89
- gem │ version verdict │ errors │ warnings │ fixable │
90
- ├───────────────┼─────────┼───────────┼────────┼──────────┼─────────┤
91
- activesupport8.1.0 not ready 157 97 77
92
- │ i18n │ 1.14.7 │ not ready │ 48 │ 40 │ 45 │
93
- mail 2.9.1 │ not ready │ 27 413
94
- rack │ 3.2.6 │ not ready │ 234560
95
- ... │ │
96
- ╰───────────────┴─────────┴───────────┴────────┴──────────┴─────────╯
97
- 0 of 11 gems ractor-ready
94
+ ╭───────────────────────────────────────────────────────────────────────────────────────╮
95
+ Audition bundle sweep
96
+ ├──────────┬──────────┬─────────────┬────────┬────────────┬──────────┬─────────┬────────┤
97
+ gem │ version verdict errors dep errors warnings fixable status
98
+ ├──────────┼──────────┼─────────────┼────────┼────────────┼──────────┼─────────┼────────┤
99
+ rubocop 1.88.2x not ready │ 194- 203 28 │ ok │
100
+ parser │ 3.3.12.0 x not ready │ 154 - │ 2626 ok │
101
+ rack 3.2.7 x not ready 31 - 61 13 │ ok │
102
+ │ rake │ 13.4.2 │ x not ready │ 30 │ - │ 24 │ 5 │ ok │
103
+ │ prism │ 1.9.0 x not ready │ 16 │ - │ 88 │ 15 │ ok │
104
+ │ json │ 2.21.2 │ x not ready │ 7 │ - │ 5 │ - │ ok │
105
+ │ pastel │ 0.8.0 │ x not ready │ 2 │ - │ 1 │ - │ ok │
106
+ │ tty-link │ 0.2.0 │ x not ready │ 1 │ - │ 1 │ - │ ok │
107
+ ╰──────────┴──────────┴─────────────┴────────┴────────────┴──────────┴─────────┴────────╯
108
+
109
+ x 0 of 8 gems ractor-ready · 8 not ready
110
+ ```
111
+
112
+ Verdicts are colored by severity, clean counts are left blank, and
113
+ the sweep names each gem as it finishes:
114
+
115
+ ```console
116
+ ◆ Audition sweep rubocop 8/8 (100%) 5.8s
98
117
  ```
99
118
 
100
119
  **Requires Ruby 4.0 or newer**, strictly: the tool targets the modern
@@ -112,6 +131,7 @@ proxying) and its verified semantics.
112
131
  - [Installation](#installation)
113
132
  - [Usage](#usage)
114
133
  - [Adopting incrementally](#adopting-incrementally)
134
+ - [CI and git hooks](#ci-and-git-hooks)
115
135
  - [What it catches](#what-it-catches)
116
136
  - [Agent skill](#agent-skill)
117
137
  - [Extending](#extending)
@@ -140,6 +160,7 @@ audition path/to/gem-checkout # a gem working copy (*.gemspec)
140
160
  audition path/to/rack-app # a config.ru directory
141
161
  audition path/to/rails-root # a Rails application
142
162
  audition lib # any directory, static-only
163
+ audition a.rb b.rb c.rb # several files at once, statically
143
164
  audition Gemfile.lock # sweep every gem in the bundle
144
165
  audition path/to/app --deps # same, from the app root
145
166
  ```
@@ -154,16 +175,33 @@ Useful flags:
154
175
  | `--fix-unsafe` | also apply semantics-affecting corrections |
155
176
  | `--dry-run` | with a fix flag: preview edits, change nothing |
156
177
  | `--format json` | machine-readable report for CI |
157
- | `--format github` | GitHub Actions annotations on PR diffs |
178
+ | `--format github` | GitHub Actions annotations + job summary |
158
179
  | `--compare old.json` | delta vs a previous report: fixed/introduced |
159
180
  | `--static-only` / `--dynamic-only` | pick one probe layer |
160
181
  | `--fail-on warning` | stricter CI gate (default: error) |
182
+ | `--exit-zero` | report findings but never fail the build |
161
183
  | `--capabilities` | table of what this Ruby allows in Ractors |
162
184
  | `--timeout 60` | dynamic probe budget in seconds |
163
185
  | `--plain` | no colors or hyperlinks (also via NO_COLOR, pipes) |
186
+ | `--progress` / `--no-progress` | force / suppress the stderr narration |
187
+ | `-j 4` / `--workers 4` | scan Ractors (default: cores, capped by `RUBY_MAX_CPU`) |
188
+
189
+ Long runs narrate themselves on stderr, so stdout stays pipeable:
190
+ a rewriting status line on a terminal, one line per phase off it.
191
+
192
+ ```
193
+ ◆ Audition checking 50/919 5% (0.2s, on 8 ractors)
194
+ ```
195
+
196
+ A phase running in Ractors says how many. The narration turns on
197
+ by itself for a large tree or a bundle sweep and stays off for
198
+ `--format json` and `--format github`; the flags force either
199
+ way.
164
200
 
165
201
  Exit codes: `0` clean, `1` findings at or above the `--fail-on`
166
202
  threshold (or a failed dynamic probe), `2` usage error.
203
+ `--exit-zero` (alias for `--fail-on never`) always exits `0`
204
+ unless the invocation itself is broken.
167
205
 
168
206
  ## Adopting incrementally
169
207
 
@@ -197,11 +235,85 @@ timeout: 60
197
235
  exclude:
198
236
  - legacy/**
199
237
  - db/schema.rb
238
+ test_dirs:
239
+ - qa
200
240
  checks:
201
241
  disable:
202
242
  - at-exit
203
243
  ```
204
244
 
245
+ `test_dirs` names the directories that hold tests rather than
246
+ code a production boot loads; findings in them are tagged
247
+ `test` and rated as test code. It defaults to `test`, `spec`,
248
+ `features`, and replaces that list rather than adding to it—the
249
+ `_test.rb` and `_spec.rb` suffixes always count, whatever it
250
+ says.
251
+
252
+ ## CI and git hooks
253
+
254
+ **GitHub Actions.** `--format github` turns findings into
255
+ workflow-command annotations that land right on the PR diff, and
256
+ appends a verdict-plus-counts markdown table to the job summary
257
+ page. A blocking gate:
258
+
259
+ ```yaml
260
+ - uses: ruby/setup-ruby@v1
261
+ with:
262
+ ruby-version: "4.0"
263
+ - run: gem install audition
264
+ - run: audition --format github .
265
+ ```
266
+
267
+ To surface findings without failing the build while you adopt
268
+ (the flag other linters call `--exit-zero` too, so it keeps its
269
+ name here):
270
+
271
+ ```yaml
272
+ - run: audition --format github --exit-zero .
273
+ ```
274
+
275
+ `--fail-on never` is the long form, and works from
276
+ `.audition.yml` as well; GitHub's own `continue-on-error: true`
277
+ on the step is the workflow-level equivalent.
278
+
279
+ **Git hooks.** Passing several `.rb` files audits exactly those
280
+ files statically, which is the shape hook managers hand over.
281
+ With [lefthook](https://github.com/evilmartians/lefthook):
282
+
283
+ ```yaml
284
+ pre-commit:
285
+ commands:
286
+ audition:
287
+ glob: "*.rb"
288
+ run: audition --static-only --plain {staged_files}
289
+ ```
290
+
291
+ With [pre-commit](https://pre-commit.com):
292
+
293
+ ```yaml
294
+ - repo: local
295
+ hooks:
296
+ - id: audition
297
+ name: Audition
298
+ language: system
299
+ entry: audition --static-only --plain
300
+ types: [ruby]
301
+ ```
302
+
303
+ Config, pragmas, and the baseline resolve against the working
304
+ directory, so a hook run from the repository root honors the
305
+ same `.audition.yml` as a full audit.
306
+
307
+ **This repository eats its own dog food.** Every commit runs
308
+ `audition --static-only` over the staged files through lefthook
309
+ (next to standardrb), and every push and pull request gets a
310
+ full self-audit in CI with annotations and a job summary,
311
+ non-blocking via `--exit-zero`
312
+ ([audit.yml](.github/workflows/audit.yml)). Current state: own
313
+ code audits `ready`; the full dynamic probe reports the terminal
314
+ dev-dependencies as `blocked`, which is exactly the distinction
315
+ the verdict system exists to make.
316
+
205
317
  ## What it catches
206
318
 
207
319
  Static, with file:line precision:
@@ -214,7 +326,7 @@ Static, with file:line precision:
214
326
  `@cache ||= {}` and `return @x if defined?(@x)` memoizations.
215
327
  - **Constants that are not deeply shareable**: bare mutable
216
328
  literals, interpolated strings, the subtle shallow freeze
217
- (`[[1], [2]].freeze` still raises; audition explains why), and
329
+ (`[[1], [2]].freeze` still raises; Audition explains why), and
218
330
  call results the magic comment never covers (`X.tr(":", "")`,
219
331
  `Regexp.new`, `Regexp.union`, `format`), the shapes Rails fixed
220
332
  last in its own ractorization. Honors `# frozen_string_literal:`
@@ -235,6 +347,25 @@ Static, with file:line precision:
235
347
  `class_attribute` migration Rails itself made) and
236
348
  `class_attribute` without copy-on-write writes,
237
349
  `include Singleton`, `fork`, `ObjectSpace._id2ref`, ENV mutation.
350
+ - **Native extensions that never declare Ractor safety**: a byte
351
+ scan of every compiled `.bundle`/`.so` for the
352
+ `rb_ext_ractor_safe` import, which also covers precompiled
353
+ platform gems that ship no sources; an unbuilt checkout is
354
+ scanned at the source level instead (C, Rust, or Zig sources
355
+ beside their `extconf.rb`, `Cargo.toml` or `build.zig`).
356
+ A silent extension raises `Ractor::UnsafeError` on every call
357
+ from a non-main Ractor, so it rates a warning; a declared one
358
+ gets an info note, because the declaration is the maintainer's
359
+ assertion, not a proof.
360
+ - **The static pass's own blind spots**: where rubydex reports an
361
+ expression it could not resolve and the shape could hide what
362
+ the checks above look for, the hole is reported rather than
363
+ read as a clean line — a singleton opened on a runtime receiver
364
+ that writes class-level state, a superclass or `include`
365
+ argument computed at runtime, a constant assigned through an
366
+ unresolved path. A clean report for such a class covers only
367
+ what the class itself declares; the dynamic probe reaches the
368
+ rest.
238
369
 
239
370
  Dynamic, on the live object graph:
240
371
 
@@ -244,6 +375,11 @@ Dynamic, on the live object graph:
244
375
  `Ractor.shareable?`, and inspects every class and module for
245
376
  class-level ivars and class variables, with
246
377
  `const_source_location` attribution.
378
+ - Records every compiled extension the load pulled in,
379
+ dependencies included, and byte-scans each for the
380
+ `rb_ext_ractor_safe` import; silent ones are reported against
381
+ the dependency that ships them. Ruby's own extensions are left
382
+ to Ruby.
247
383
  - Boots `config.ru` and serves one GET / entirely inside a Ractor,
248
384
  the per-worker model of Ractor web servers; then hammers it from
249
385
  4 Ractors x 25 requests to surface failures that only appear
@@ -254,7 +390,7 @@ Dynamic, on the live object graph:
254
390
  ## Agent skill
255
391
 
256
392
  This repository ships a `ractor-readiness` skill that teaches
257
- coding agents (Claude Code and friends) the full audition
393
+ coding agents (Claude Code and friends) the full Audition
258
394
  workflow: audit, fix tiers, suite-parity verification, and
259
395
  incremental adoption. It lives in
260
396
  [skills/ractor-readiness/SKILL.md](skills/ractor-readiness/SKILL.md).
@@ -305,12 +441,12 @@ Audition::Static::Checks.register(NoSleep)
305
441
  bundle install
306
442
  bundle exec rake spec # RSpec suite
307
443
  bundle exec rake standard # standardrb lint
308
- lefthook install # pre-commit lint hook
444
+ lefthook install # pre-commit standardrb + audition
309
445
  bundle exec exe/audition --capabilities
310
446
  ```
311
447
 
312
448
  Static scanning is Ractor-parallel on large targets (one worker
313
- per core, minus one for the main Ractor); audition's own `lib/`
449
+ per core, minus one for the main Ractor); Audition's own `lib/`
314
450
  passes `audition lib` clean.
315
451
 
316
452
  The design notes in `docs/design.md` include the empirically
@@ -321,7 +457,7 @@ calibrated against.
321
457
 
322
458
  The whole-program checks stand on
323
459
  [rubydex](https://github.com/Shopify/rubydex), Shopify's
324
- high-performance static analysis suite for Ruby: audition feeds
460
+ high-performance static analysis suite for Ruby: Audition feeds
325
461
  every file into its graph and reads state ownership back out.
326
462
  Thanks to its authors, in particular the top five contributors:
327
463
  [Alexandre Terrasa](https://github.com/Morriar),
@@ -31,10 +31,10 @@ module Audition
31
31
 
32
32
  # Audits every locked gem and ranks the results, worst first.
33
33
  #
34
- # @param progress [Proc, nil] called with (row, done, total)
35
- # as each gem finishes
34
+ # @param progress [Progress] named per gem as each finishes,
35
+ # which is the only unit a sweep has
36
36
  # @return [Array<Row>]
37
- def rows(progress: nil)
37
+ def rows(progress: Progress::SILENT)
38
38
  gems = locked_gems
39
39
  queue = Thread::Queue.new
40
40
  gems.each { |g| queue << g }
@@ -42,17 +42,19 @@ module Audition
42
42
 
43
43
  collected = []
44
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)
45
+ progress.phase("sweep", total: gems.size, unit: "gems") do
46
+ @concurrency.times.map do
47
+ Thread.new do
48
+ while (name, version = queue.pop)
49
+ row = audit_gem(name, version)
50
+ mutex.synchronize do
51
+ collected << row
52
+ progress.item(row.name)
53
+ end
52
54
  end
53
55
  end
54
- end
55
- end.each(&:join)
56
+ end.each(&:join)
57
+ end
56
58
 
57
59
  collected.sort_by do |row|
58
60
  [VERDICT_ORDER.fetch(row.verdict), -row.errors, row.name]
@@ -115,7 +117,10 @@ module Audition
115
117
  ruby_files: spec.require_paths.flat_map do |rp|
116
118
  ruby_files_under(File.join(root, rp))
117
119
  end,
118
- entry: {mode: :require, feature: spec.name, root: root}
120
+ entry: {mode: :require, feature: spec.name,
121
+ load_paths: spec.full_require_paths, root: root},
122
+ compiled_files: Target.compiled_for(spec),
123
+ stub_files: Target.stubs(root)
119
124
  )
120
125
  end
121
126
 
@@ -142,16 +147,28 @@ module Audition
142
147
  files = target.ruby_files.reject do |file|
143
148
  config.excluded?(file.delete_prefix("#{target.root}/"))
144
149
  end
150
+ compiled = target.compiled_files.reject do |file|
151
+ config.excluded?(file.delete_prefix("#{target.root}/"))
152
+ end
145
153
  per_file = Static::Analyzer.new
146
154
  .analyze_paths(files, workers: 1)
147
- per_file + Static::GraphAudit.new.analyze_paths(files)
155
+ gem_calls = Static::GemCalls
156
+ .new(root: target.root, stubs: target.stub_files)
157
+ .analyze_paths(files)
158
+ per_file + gem_calls + Static::GraphAudit.new
159
+ .analyze_paths(files, constant_findings: per_file + gem_calls,
160
+ workers: 1) +
161
+ Static::NativeExtensions.new.analyze(target,
162
+ compiled_files: compiled)
148
163
  end
149
164
 
150
165
  def dynamic_results(target)
151
166
  return [] if @static_only || target.entry.nil?
152
167
 
153
168
  prober = Dynamic::Prober.new(timeout: @timeout)
154
- [prober.probe(target.entry)]
169
+ [prober.probe(
170
+ target.entry.merge(compiled_files: target.compiled_files)
171
+ )]
155
172
  end
156
173
  end
157
174
  end