audition 0.3.0 → 0.4.1

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: 28a9914f63873fd6bc4ec2e86154aa69e4e3dbfb08c3d9ee6218fc521e31836b
4
- data.tar.gz: e1033a810ea825b21980fb3cb406bfd7bda1bf69e970e82c988fb0d149c0d172
3
+ metadata.gz: c47dfccb03a9d164583e3ae0a5530c74a41cc176f62865807fa4a4d1ed11d7f4
4
+ data.tar.gz: 75697dacb090c469d8f2966d5174fb08186e1808b85737872b0db5f6034a688d
5
5
  SHA512:
6
- metadata.gz: 07b6250748bf1d3e854157b6ac81f8a2574259f77d2e28746d241e880763cb446410b27736740c29f803e4936fed3d5ff0f6ca1b95d73cc6cb664896630c17fe
7
- data.tar.gz: ab564046d55d28d6e94af9e045991574322e14074e4121e788c6b59f8ce3628f4499351d5f9cac7e670564bc9ea7472b3615056d9b91beaeb64dc3e131babc04
6
+ metadata.gz: a631d1f7cd378ff04c47dcffb9608d332bfb048fc37dbda66179b085c487e936206359b3ea61a1e8fb5c70d121b6b7d94275b2e6d436f32772e1ea68a540fbd4
7
+ data.tar.gz: 974bbcd0a531588edf1d2474e9c8afb7bd5d3869a985b483ebb2c628007837ed20e50d10a580f6f0204db95e42128bb3de061237bbdaeab28f3d7008d7838a37
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
@@ -28,12 +28,13 @@ core itself is being ractorized; see the
28
28
  (which rule of the Ractor model it violates) and a `fix`
29
29
  (what to write instead).
30
30
  - **`--fix` like RuboCop, in two tiers.** Safe corrections:
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
35
- boot-time hoisting of method-body requires. `--fix-unsafe` adds
36
- semantics-affecting rewrites: magic-comment insertion,
31
+ `.freeze` on string constants (literals as well as the fresh
32
+ strings core methods return), sentinels, and containers whose
33
+ elements are all shareable, and boot-time hoisting of
34
+ method-body requires. `--fix-unsafe` adds semantics-affecting
35
+ rewrites: `Ractor.make_shareable(...)` for the remaining mutable
36
+ and shallow-frozen containers, for containers a core method
37
+ allocates, and for Proc constants, magic-comment insertion,
37
38
  freeze-on-memoize for class-level memoization (both `@x ||=`
38
39
  and `return @x if defined?(@x)` idioms keep their caching, the
39
40
  memoized value becomes shareable, Rails-core style;
@@ -47,7 +48,7 @@ core itself is being ractorized; see the
47
48
  state, so `globalid` is not blamed for ActiveSupport's state.
48
49
  - **Dogfooding.** The scanner for Ractor compatibility is built
49
50
  using Ractors: static analysis fans out across CPU cores on
50
- Ractor workers, and audition passes its own audit. It runs on
51
+ Ractor workers, and Audition passes its own audit. It runs on
51
52
  itself on every commit (a lefthook pre-commit over the staged
52
53
  files) and on every push (a full self-audit in CI).
53
54
  - **Trained on Rails core.** Several checks and fix suggestions
@@ -58,26 +59,24 @@ core itself is being ractorized; see the
58
59
  findings are documented in
59
60
  [docs/rails_core_best_practices.md](docs/rails_core_best_practices.md).
60
61
  - **Terminal-native output.** Colors, glyphs, and OSC 8 hyperlinks;
61
- `path:line` is clickable in supporting terminals. JSON output for
62
- CI.
62
+ `path:line` is clickable in supporting terminals. Long scans
63
+ narrate their phases on stderr. JSON output for CI.
63
64
 
64
65
  ```console
65
66
  $ audition worker.rb
66
- * audition 0.1.0 ruby 4.0.6 · script at .
67
+ * Audition 0.3.0 ruby 4.0.6 · script at .
67
68
 
68
69
  worker.rb
69
- x raises inside a Ractor: Ractor::IsolationError: can not
70
- access global variable $jobs from non-main Ractor
71
- why: The script ran fine on the main Ractor but failed under
72
- Ractor.new; the static findings usually pinpoint the line.
73
- x worker.rb:1 write to global variable $jobs
74
- why: Non-main Ractors cannot access global variables; this
75
- raises Ractor::IsolationError the moment the line executes
76
- in a Ractor (verified on Ruby 4.0).
77
- fix: Pass the value into the Ractor explicitly
78
- (Ractor.new(value) { |v| ... }) or over a Ractor::Port; for
79
- per-Ractor state use Ractor.current[:key].
80
- x worker.rb:4 read of global variable $jobs
70
+ x worker.rb:1 write to global variable $jobs global-variables
71
+ why: Non-main Ractors cannot access global variables; this raises
72
+ Ractor::IsolationError the moment the line executes in a Ractor
73
+ (verified on Ruby 4.0).
74
+ fix: Pass the value into the Ractor explicitly (Ractor.new(value) {
75
+ |v| ... }) or over a Ractor::Port; for per-Ractor state use
76
+ Ractor.current[:key]; do one-time process setup on the main Ractor
77
+ before spawning.
78
+ x worker.rb:1 raises inside a Ractor: Ractor::IsolationError ...
79
+ x worker.rb:4 read of global variable $jobs global-variables
81
80
  ...
82
81
 
83
82
  dynamic probes
@@ -93,16 +92,29 @@ And the whole-bundle view:
93
92
 
94
93
  ```console
95
94
  $ audition Gemfile.lock --static-only
96
- ╭───────────────┬─────────┬───────────┬────────┬──────────┬─────────╮
97
- gem │ version verdict │ errors │ warnings │ fixable │
98
- ├───────────────┼─────────┼───────────┼────────┼──────────┼─────────┤
99
- activesupport8.1.0 not ready 157 97 77
100
- │ i18n │ 1.14.7 │ not ready │ 48 │ 40 │ 45 │
101
- mail 2.9.1 │ not ready │ 27 413
102
- rack │ 3.2.6 │ not ready │ 234560
103
- ... │ │
104
- ╰───────────────┴─────────┴───────────┴────────┴──────────┴─────────╯
105
- 0 of 11 gems ractor-ready
95
+ ╭───────────────────────────────────────────────────────────────────────────────────────╮
96
+ Audition bundle sweep
97
+ ├──────────┬──────────┬─────────────┬────────┬────────────┬──────────┬─────────┬────────┤
98
+ gem │ version verdict errors dep errors warnings fixable status
99
+ ├──────────┼──────────┼─────────────┼────────┼────────────┼──────────┼─────────┼────────┤
100
+ rubocop 1.88.2x not ready │ 194- 203 28 │ ok │
101
+ parser │ 3.3.12.0 x not ready │ 154 - │ 2626 ok │
102
+ rack 3.2.7 x not ready 31 - 61 13 │ ok │
103
+ │ rake │ 13.4.2 │ x not ready │ 30 │ - │ 24 │ 5 │ ok │
104
+ │ prism │ 1.9.0 x not ready │ 16 │ - │ 88 │ 15 │ ok │
105
+ │ json │ 2.21.2 │ x not ready │ 7 │ - │ 5 │ - │ ok │
106
+ │ pastel │ 0.8.0 │ x not ready │ 2 │ - │ 1 │ - │ ok │
107
+ │ tty-link │ 0.2.0 │ x not ready │ 1 │ - │ 1 │ - │ ok │
108
+ ╰──────────┴──────────┴─────────────┴────────┴────────────┴──────────┴─────────┴────────╯
109
+
110
+ x 0 of 8 gems ractor-ready · 8 not ready
111
+ ```
112
+
113
+ Verdicts are colored by severity, clean counts are left blank, and
114
+ the sweep names each gem as it finishes:
115
+
116
+ ```console
117
+ ◆ Audition sweep rubocop 8/8 (100%) 5.8s
106
118
  ```
107
119
 
108
120
  **Requires Ruby 4.0 or newer**, strictly: the tool targets the modern
@@ -172,6 +184,20 @@ Useful flags:
172
184
  | `--capabilities` | table of what this Ruby allows in Ractors |
173
185
  | `--timeout 60` | dynamic probe budget in seconds |
174
186
  | `--plain` | no colors or hyperlinks (also via NO_COLOR, pipes) |
187
+ | `--progress` / `--no-progress` | force / suppress the stderr narration |
188
+ | `-j 4` / `--workers 4` | scan Ractors (default: cores, capped by `RUBY_MAX_CPU`) |
189
+
190
+ Long runs narrate themselves on stderr, so stdout stays pipeable:
191
+ a rewriting status line on a terminal, one line per phase off it.
192
+
193
+ ```
194
+ ◆ Audition checking 50/919 5% (0.2s, on 8 ractors)
195
+ ```
196
+
197
+ A phase running in Ractors says how many. The narration turns on
198
+ by itself for a large tree or a bundle sweep and stays off for
199
+ `--format json` and `--format github`; the flags force either
200
+ way.
175
201
 
176
202
  Exit codes: `0` clean, `1` findings at or above the `--fail-on`
177
203
  threshold (or a failed dynamic probe), `2` usage error.
@@ -210,11 +236,20 @@ timeout: 60
210
236
  exclude:
211
237
  - legacy/**
212
238
  - db/schema.rb
239
+ test_dirs:
240
+ - qa
213
241
  checks:
214
242
  disable:
215
243
  - at-exit
216
244
  ```
217
245
 
246
+ `test_dirs` names the directories that hold tests rather than
247
+ code a production boot loads; findings in them are tagged
248
+ `test` and rated as test code. It defaults to `test`, `spec`,
249
+ `features`, and replaces that list rather than adding to it—the
250
+ `_test.rb` and `_spec.rb` suffixes always count, whatever it
251
+ says.
252
+
218
253
  ## CI and git hooks
219
254
 
220
255
  **GitHub Actions.** `--format github` turns findings into
@@ -260,7 +295,7 @@ With [pre-commit](https://pre-commit.com):
260
295
  - repo: local
261
296
  hooks:
262
297
  - id: audition
263
- name: audition
298
+ name: Audition
264
299
  language: system
265
300
  entry: audition --static-only --plain
266
301
  types: [ruby]
@@ -289,14 +324,29 @@ Static, with file:line precision:
289
324
  - **Class variables**, resolved on the rubydex graph.
290
325
  - **Class-level instance variables**, unified across the class
291
326
  body, `def self.`, and `class << self`, across files; the classic
292
- `@cache ||= {}` and `return @x if defined?(@x)` memoizations.
327
+ `@cache ||= {}` and `return @x if defined?(@x)` memoizations. A
328
+ memo whose write is proxied to the main Ractor
329
+ (`@x || on_main(self) { @x ||= ... }`) is reported as that
330
+ escape hatch, not as a raw memo.
293
331
  - **Constants that are not deeply shareable**: bare mutable
294
332
  literals, interpolated strings, the subtle shallow freeze
295
- (`[[1], [2]].freeze` still raises; audition explains why), and
296
- call results the magic comment never covers (`X.tr(":", "")`,
297
- `Regexp.new`, `Regexp.union`, `format`), the shapes Rails fixed
298
- last in its own ractorization. Honors `# frozen_string_literal:`
299
- and `# shareable_constant_value:` magic comments.
333
+ (`[[1], [2]].freeze` still raises; Audition explains why), and
334
+ call results the magic comment never covers, from the
335
+ return-type contracts of core methods: fresh strings
336
+ (`X.tr(":", "")`, `[8, 2, 0].join(".")`, `Regexp.new`,
337
+ `format`), fresh containers (`TYPES.keys`, `LIST.map { }`,
338
+ `BASE + [:x]`, `DEFAULTS.merge(...)`, `.dup`), string splitters
339
+ under a shallow freeze (`".*".chars.freeze`), and Method
340
+ objects, which no freeze makes shareable. A spec executes the
341
+ tables against the running Ruby. Integer arithmetic,
342
+ comparisons, and negation are recognized as shareable. Honors
343
+ `# frozen_string_literal:` and `# shareable_constant_value:`
344
+ magic comments.
345
+ - **Instance memoization on classes that get frozen**: a lazy
346
+ `@x ||=` on a class whose initialize freezes self raises
347
+ FrozenError on first use; on a class with a `freeze` override
348
+ the memo must be warmed inside the override before `super`
349
+ (compute on freeze), and one left cold is reported.
300
350
  - **Sync primitives and Procs in constants** (Mutex, Queue,
301
351
  lambdas), including `Hash.new { }` default procs, which stay
302
352
  unshareable even after `.freeze`.
@@ -307,7 +357,12 @@ Static, with file:line precision:
307
357
  - **Runtime require and autoload** (serializes all Ractors through
308
358
  the main-Ractor proxy).
309
359
  - **`Ractor.new` blocks capturing outer locals** (the ArgumentError
310
- at creation time), resolved through Prism's exact scope depths.
360
+ at creation time), resolved through Prism's exact scope depths;
361
+ and **blocks that `Ractor.shareable_proc` would refuse**, handed
362
+ to it directly or to a Rails callback macro (`before_create`,
363
+ `validate`, `on_load`, ...), because a captured local holds a
364
+ provably unshareable value (`prefix = +"Draft: "`) or is
365
+ assigned more than once.
311
366
  - **Hostile or removed APIs**: `Ractor.yield`/`take` (gone in 4.0),
312
367
  ActiveSupport `cattr_*`/`mattr_*` class variables (with the
313
368
  `class_attribute` migration Rails itself made) and
@@ -317,20 +372,35 @@ Static, with file:line precision:
317
372
  scan of every compiled `.bundle`/`.so` for the
318
373
  `rb_ext_ractor_safe` import, which also covers precompiled
319
374
  platform gems that ship no sources; an unbuilt checkout is
320
- scanned at the source level (`ext/**`, C, Rust, or Zig) instead.
375
+ scanned at the source level instead (C, Rust, or Zig sources
376
+ beside their `extconf.rb`, `Cargo.toml` or `build.zig`).
321
377
  A silent extension raises `Ractor::UnsafeError` on every call
322
378
  from a non-main Ractor, so it rates a warning; a declared one
323
379
  gets an info note, because the declaration is the maintainer's
324
380
  assertion, not a proof.
381
+ - **The static pass's own blind spots**: where rubydex reports an
382
+ expression it could not resolve and the shape could hide what
383
+ the checks above look for, the hole is reported rather than
384
+ read as a clean line — a singleton opened on a runtime receiver
385
+ that writes class-level state, a superclass or `include`
386
+ argument computed at runtime, a constant assigned through an
387
+ unresolved path. A clean report for such a class covers only
388
+ what the class itself declares; the dynamic probe reaches the
389
+ rest.
325
390
 
326
391
  Dynamic, on the live object graph:
327
392
 
328
393
  - Runs scripts inside a real Ractor (via `load`, which is not
329
394
  proxied) and reports the actual exception.
330
- - Requires a library, then sweeps every constant it introduced with
395
+ - Requires a library, then sweeps every constant it introduced,
396
+ under new namespaces and pre-existing ones alike, with
331
397
  `Ractor.shareable?`, and inspects every class and module for
332
398
  class-level ivars and class variables, with
333
- `const_source_location` attribution.
399
+ `const_source_location` attribution. What the sweep proves
400
+ shareable retires the static guesses about the same objects:
401
+ an unproven constant warning disappears, and class-level state
402
+ that held only shareable values after boot keeps an info note
403
+ instead of an error.
334
404
  - Records every compiled extension the load pulled in,
335
405
  dependencies included, and byte-scans each for the
336
406
  `rb_ext_ractor_safe` import; silent ones are reported against
@@ -340,13 +410,19 @@ Dynamic, on the live object graph:
340
410
  the per-worker model of Ractor web servers; then hammers it from
341
411
  4 Ractors x 25 requests to surface failures that only appear
342
412
  under concurrency.
343
- - Boots Rails (`config/environment.rb`), eager-loads, and sweeps
344
- the application's namespaces.
413
+ - Boots Rails (`config/environment.rb`) with
414
+ `unshareable_proc_action` armed, so every callback block Rails
415
+ cannot make shareable is reported at the Proc's definition site;
416
+ eager-loads; on Rails 8.2 calls `ractorize!` to freeze the
417
+ application graph, then serves one GET / on the main Ractor
418
+ (where a lazy memo on a now-frozen object raises FrozenError)
419
+ and one inside a Ractor; and sweeps the application's
420
+ namespaces. Without `ractorize!` an info note says so.
345
421
 
346
422
  ## Agent skill
347
423
 
348
424
  This repository ships a `ractor-readiness` skill that teaches
349
- coding agents (Claude Code and friends) the full audition
425
+ coding agents (Claude Code and friends) the full Audition
350
426
  workflow: audit, fix tiers, suite-parity verification, and
351
427
  incremental adoption. It lives in
352
428
  [skills/ractor-readiness/SKILL.md](skills/ractor-readiness/SKILL.md).
@@ -402,7 +478,7 @@ bundle exec exe/audition --capabilities
402
478
  ```
403
479
 
404
480
  Static scanning is Ractor-parallel on large targets (one worker
405
- per core, minus one for the main Ractor); audition's own `lib/`
481
+ per core, minus one for the main Ractor); Audition's own `lib/`
406
482
  passes `audition lib` clean.
407
483
 
408
484
  The design notes in `docs/design.md` include the empirically
@@ -413,7 +489,7 @@ calibrated against.
413
489
 
414
490
  The whole-program checks stand on
415
491
  [rubydex](https://github.com/Shopify/rubydex), Shopify's
416
- high-performance static analysis suite for Ruby: audition feeds
492
+ high-performance static analysis suite for Ruby: Audition feeds
417
493
  every file into its graph and reads state ownership back out.
418
494
  Thanks to its authors, in particular the top five contributors:
419
495
  [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]
@@ -78,6 +80,7 @@ module Audition
78
80
  findings = filter(static_findings(target, config),
79
81
  directives, config)
80
82
  results = dynamic_results(target)
83
+ findings = Reconciliation.apply(findings, results)
81
84
  findings += filter(results.flat_map(&:findings),
82
85
  directives, config)
83
86
  report = Report.new(
@@ -115,8 +118,10 @@ module Audition
115
118
  ruby_files: spec.require_paths.flat_map do |rp|
116
119
  ruby_files_under(File.join(root, rp))
117
120
  end,
118
- entry: {mode: :require, feature: spec.name, root: root},
119
- compiled_files: Target.compiled_for(spec)
121
+ entry: {mode: :require, feature: spec.name,
122
+ load_paths: spec.full_require_paths, root: root},
123
+ compiled_files: Target.compiled_for(spec),
124
+ stub_files: Target.stubs(root)
120
125
  )
121
126
  end
122
127
 
@@ -148,7 +153,12 @@ module Audition
148
153
  end
149
154
  per_file = Static::Analyzer.new
150
155
  .analyze_paths(files, workers: 1)
151
- per_file + Static::GraphAudit.new.analyze_paths(files) +
156
+ gem_calls = Static::GemCalls
157
+ .new(root: target.root, stubs: target.stub_files)
158
+ .analyze_paths(files)
159
+ per_file + gem_calls + Static::GraphAudit.new
160
+ .analyze_paths(files, constant_findings: per_file + gem_calls,
161
+ workers: 1) +
152
162
  Static::NativeExtensions.new.analyze(target,
153
163
  compiled_files: compiled)
154
164
  end