audition 0.1.0 → 0.2.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: 1605ecba3ed080146001f514aabd1d5b1754ac94623205f9e47500011b3a0894
4
- data.tar.gz: acd2a8e10e2c487bfc56b53960b23051795bd2c0274f065146e5d497bc4e11be
3
+ metadata.gz: 334e1fbfc5a78984722aad73810af312279f67f72bae4b5e60bbbf9433b0f404
4
+ data.tar.gz: 26c3023a7cdeeee748b61619277c8e749b5a0204b82474b4f06220e9c0fd737d
5
5
  SHA512:
6
- metadata.gz: 9135eecfc64dbdac1dc3aef23a449f707384d41adb018d634a2940ef1a3eb93cebf0836e333f3d6a6bcb47d4eff9f1efdf219406379202e5a10bd0624785d39f
7
- data.tar.gz: 2111276ebb2d9006c1fdcd8bafcac5086cb00bcd3fbcb6c57f5a9f30c4e388181f523be0b5839ca35d4d64bb2ebeb60a1332d41286befc13c84e7166f20a1468
6
+ metadata.gz: 4c91b4b8ff157e2ff268533657c9dab0c61dc5cc2c0e568790f6c45bee16dfee05f7b0ff830b90aeff43d150278292b967a52dd8ce9baff926b33b23b0bc4992
7
+ data.tar.gz: 9e639f1a77f804fcc79a196d109fc967a683b07c30411a6832ac9b5315428c30338e1e31b44c7ee448c724fd8a48ce7f7fb6a42411bf0194531b25597cb8e66a
data/README.md CHANGED
@@ -3,18 +3,23 @@
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
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.
6
+ pattern-matching your source: whole-program analysis is powered by
7
+ [rubydex](https://github.com/Shopify/rubydex), Shopify's Ruby
8
+ indexer, and the target is also loaded in a sandboxed subprocess
9
+ to observe real `Ractor::IsolationError`s on the live object
10
+ graph. Some of the checks and fixes were trained on how Rails
11
+ core itself is being ractorized; see the
12
+ [pattern study](docs/rails_core_best_practices.md).
9
13
 
10
14
  [![GitHub Release](https://img.shields.io/github/v/release/yaroslav/audition)](https://github.com/yaroslav/audition/releases)
11
15
  [![Docs](https://img.shields.io/badge/yard-docs-blue.svg)](https://rubydoc.info/gems/audition)
12
16
 
13
17
  - **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
+ whole-program semantic analysis powered by
19
+ [rubydex](https://github.com/Shopify/rubydex) (Shopify's code
20
+ graph; class-level state is resolved across files and
21
+ reopenings), and dynamic in-Ractor execution of the actual
22
+ target.
18
23
  - **Explains, not just flags.** Every finding carries a `why`
19
24
  (which rule of the Ractor model it violates) and a `fix`
20
25
  (what to write instead).
@@ -22,14 +27,28 @@ the live object graph.
22
27
  `.freeze` on string constants, `Ractor.make_shareable(...)` for
23
28
  mutable and shallow-frozen containers and Proc constants, and
24
29
  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.
30
+ semantics-affecting rewrites: magic-comment insertion,
31
+ freeze-on-memoize for class-level memoization (both `@x ||=`
32
+ and `return @x if defined?(@x)` idioms keep their caching, the
33
+ memoized value becomes shareable, Rails-core style;
34
+ `Ractor.store_if_absent` only for block initializers and
35
+ invalidated caches), `autoload` to `require`, and write-once
36
+ globals/class variables to frozen constants. `--dry-run`
37
+ previews everything as a diff.
29
38
  - **Dependency-aware.** Runtime findings are attributed to their
30
39
  source via `const_source_location`; when your own code is clean
31
40
  but a dependency is not, the verdict is a distinct `blocked`
32
41
  state, so `globalid` is not blamed for ActiveSupport's state.
42
+ - **Dogfooding.** The scanner for Ractor compatibility is built
43
+ using Ractors: static analysis fans out across CPU cores on
44
+ Ractor workers, and audition passes its own audit.
45
+ - **Trained on Rails core.** Several checks and fix suggestions
46
+ 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
51
+ [docs/rails_core_best_practices.md](docs/rails_core_best_practices.md).
33
52
  - **Terminal-native output.** Colors, glyphs, and OSC 8 hyperlinks;
34
53
  `path:line` is clickable in supporting terminals. JSON output for
35
54
  CI.
@@ -94,7 +113,6 @@ proxying) and its verified semantics.
94
113
  - [Usage](#usage)
95
114
  - [Adopting incrementally](#adopting-incrementally)
96
115
  - [What it catches](#what-it-catches)
97
- - [Field notes](#field-notes)
98
116
  - [Extending](#extending)
99
117
  - [Development](#development)
100
118
  - [License](#license)
@@ -191,14 +209,19 @@ Static, with file:line precision:
191
209
  - **Class variables**, resolved on the rubydex graph.
192
210
  - **Class-level instance variables**, unified across the class
193
211
  body, `def self.`, and `class << self`, across files; the classic
194
- `@cache ||= {}` memoization.
212
+ `@cache ||= {}` and `return @x if defined?(@x)` memoizations.
195
213
  - **Constants that are not deeply shareable**: bare mutable
196
214
  literals, interpolated strings, and the subtle shallow freeze
197
215
  (`[[1], [2]].freeze` still raises; audition explains why).
198
216
  Honors `# frozen_string_literal:` and
199
217
  `# shareable_constant_value:` magic comments.
200
218
  - **Sync primitives and Procs in constants** (Mutex, Queue,
201
- lambdas).
219
+ lambdas), including `Hash.new { }` default procs, which stay
220
+ unshareable even after `.freeze`.
221
+ - **Registry-style constant mutation** (`RENDERERS << key`,
222
+ `LOOKUP[k] = v`) and **`define_method` with a literal block**
223
+ (the method carries an unshareable Proc); both patterns and
224
+ their fixes come from the Rails core ractorization study.
202
225
  - **Runtime require and autoload** (serializes all Ractors through
203
226
  the main-Ractor proxy).
204
227
  - **`Ractor.new` blocks capturing outer locals** (the ArgumentError
@@ -222,21 +245,6 @@ Dynamic, on the live object graph:
222
245
  - Boots Rails (`config/environment.rb`), eager-loads, and sweeps
223
246
  the application's namespaces.
224
247
 
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
248
  ## Extending
241
249
 
242
250
  Checks are written in a small declarative DSL and can be registered
data/exe/audition CHANGED
@@ -1,6 +1,14 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # When run from a source checkout, prefer the sibling lib over any
5
+ # installed audition gem; installed binstubs never hit this branch
6
+ # because their lib is where they load from anyway.
7
+ lib = File.expand_path("../lib", __dir__)
8
+ if File.file?(File.join(lib, "audition.rb"))
9
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
10
+ end
11
+
4
12
  require "audition"
5
13
 
6
14
  exit Audition::CLI.run(ARGV)
@@ -201,21 +201,33 @@ module Audition
201
201
  findings
202
202
  end
203
203
 
204
+ # Class-level state holding only shareable values is the
205
+ # warmed frozen-memoization shape: reads are legal from any
206
+ # Ractor, so it rates an info note; unshareable values are
207
+ # hard errors.
204
208
  def class_state_finding(entry, label)
205
209
  unshareable = entry.fetch("unshareable", [])
206
210
  hot = unshareable.any?
207
211
  detail =
208
212
  hot ? " (unshareable: #{unshareable.join(", ")})" : ""
213
+ why =
214
+ if hot
215
+ "Writes raise Ractor::IsolationError from non-main " \
216
+ "Ractors; reads raise too while the value is " \
217
+ "unshareable. #{RUNTIME_WHY}"
218
+ else
219
+ "Every value observed here is shareable, so reads " \
220
+ "from non-main Ractors are legal; only late writes " \
221
+ "would raise Ractor::IsolationError. #{RUNTIME_WHY}"
222
+ end
209
223
  runtime_finding(
210
224
  entry, label,
211
225
  check: "runtime-class-state",
212
- severity: hot ? :error : :warning,
226
+ severity: hot ? :error : :info,
213
227
  message: "class-level state " \
214
228
  "#{entry["ivars"].join(", ")} on " \
215
229
  "#{entry["const"]}#{detail}",
216
- why: "Writes raise Ractor::IsolationError from non-main " \
217
- "Ractors; reads raise too while the value is " \
218
- "unshareable. #{RUNTIME_WHY}",
230
+ why: why,
219
231
  fix: "Precompute and freeze at load, use " \
220
232
  "Ractor.store_if_absent, or keep per-Ractor state."
221
233
  )
@@ -105,37 +105,65 @@ module Audition
105
105
  accepted
106
106
  end
107
107
 
108
+ # Prism offsets are byte offsets; splicing must happen on a
109
+ # binary copy or every edit after the first multibyte
110
+ # character lands short (addressable's unicode tables were
111
+ # the crash test).
108
112
  def patched(plan)
109
- source = plan.source.dup
113
+ encoding = plan.source.encoding
114
+ bytes = plan.source.dup.force_encoding(Encoding::BINARY)
110
115
  plan.edits.each do |edit|
111
- source[edit.start_offset...edit.end_offset] =
112
- edit.replacement
116
+ bytes[edit.start_offset...edit.end_offset] =
117
+ edit.replacement.dup.force_encoding(Encoding::BINARY)
113
118
  end
114
- source
119
+ bytes.force_encoding(encoding)
115
120
  end
116
121
 
122
+ # Edits whose line windows overlap (a guard deletion runs into
123
+ # the write it pairs with) render as one hunk, so the preview
124
+ # never repeats a line in two half-applied states. All window
125
+ # math runs on a binary copy: the offsets are bytes.
117
126
  def hunks(plan)
118
- plan.edits.sort_by(&:start_offset).map do |edit|
119
- line_start =
120
- if edit.start_offset.zero?
121
- 0
122
- else
123
- before = plan.source.rindex("\n", edit.start_offset - 1)
124
- before ? before + 1 : 0
125
- end
126
- line_end = plan.source.index("\n", edit.end_offset) ||
127
- plan.source.length
128
- old = plan.source[line_start...line_end]
127
+ encoding = plan.source.encoding
128
+ raw = plan.source.dup.force_encoding(Encoding::BINARY)
129
+ groups = []
130
+ plan.edits.sort_by(&:start_offset).each do |edit|
131
+ from, upto = line_window(raw, edit)
132
+ if groups.any? && from <= groups.last[:upto]
133
+ last = groups.last
134
+ last[:upto] = [last[:upto], upto].max
135
+ last[:edits] << edit
136
+ else
137
+ groups << {from: from, upto: upto, edits: [edit]}
138
+ end
139
+ end
140
+ groups.map do |group|
141
+ old = raw[group[:from]...group[:upto]]
129
142
  updated = old.dup
130
- span = ((edit.start_offset - line_start)...
131
- (edit.end_offset - line_start))
132
- updated[span] = edit.replacement
143
+ group[:edits].reverse_each do |edit|
144
+ span = ((edit.start_offset - group[:from])...
145
+ (edit.end_offset - group[:from]))
146
+ updated[span] =
147
+ edit.replacement.dup.force_encoding(Encoding::BINARY)
148
+ end
133
149
  {
134
- line: plan.source[0, edit.start_offset].count("\n") + 1,
135
- old: old,
136
- new: updated.chomp
150
+ line: raw[0, group[:from]].count("\n") + 1,
151
+ old: old.force_encoding(encoding),
152
+ new: updated.chomp.force_encoding(encoding)
137
153
  }
138
154
  end
139
155
  end
156
+
157
+ def line_window(raw, edit)
158
+ from =
159
+ if edit.start_offset.zero?
160
+ 0
161
+ else
162
+ before = raw.rindex("\n", edit.start_offset - 1)
163
+ before ? before + 1 : 0
164
+ end
165
+ upto = raw.index("\n", edit.end_offset) || raw.length
166
+ [from, upto]
167
+ end
140
168
  end
141
169
  end
@@ -39,7 +39,7 @@ module Audition
39
39
  end
40
40
 
41
41
  PAINTS.each do |name|
42
- define_method(name) do |text|
42
+ define_method(name) do |text| # audition:disable unsafe-calls
43
43
  @pastel.public_send(name, text)
44
44
  end
45
45
  end
@@ -110,9 +110,10 @@ module Audition
110
110
  return :not_ready if own_errors?
111
111
  return :blocked if dependency_errors? ||
112
112
  dynamic_results.any? { |r| !r.passed }
113
- return :risky if counts[:warning].positive? ||
114
- counts[:info].positive?
113
+ return :risky if counts[:warning].positive?
115
114
 
115
+ # Info notes describe things that work on Ruby 4.0 and are
116
+ # only worth knowing; they do not taint the verdict.
116
117
  :ready
117
118
  end
118
119