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 +4 -4
- data/README.md +37 -29
- data/exe/audition +8 -0
- data/lib/audition/dynamic/prober.rb +16 -4
- data/lib/audition/fixer.rb +49 -21
- data/lib/audition/report.rb +4 -3
- data/lib/audition/rewriters.rb +371 -55
- data/lib/audition/static/checks/mutable_constants.rb +91 -9
- data/lib/audition/static/checks/ractor_isolation.rb +1 -1
- data/lib/audition/static/checks/runtime_require.rb +98 -15
- data/lib/audition/static/checks/unsafe_calls.rb +27 -0
- data/lib/audition/static/graph_audit.rb +97 -13
- data/lib/audition/static/literal_classifier.rb +69 -10
- data/lib/audition/static/source_file.rb +26 -8
- data/lib/audition/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 334e1fbfc5a78984722aad73810af312279f67f72bae4b5e60bbbf9433b0f404
|
|
4
|
+
data.tar.gz: 26c3023a7cdeeee748b61619277c8e749b5a0204b82474b4f06220e9c0fd737d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
7
|
-
|
|
8
|
-
the
|
|
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
|
[](https://github.com/yaroslav/audition/releases)
|
|
11
15
|
[](https://rubydoc.info/gems/audition)
|
|
12
16
|
|
|
13
17
|
- **Three probes, one verdict.** Per-file Prism AST checks,
|
|
14
|
-
whole-program semantic analysis
|
|
15
|
-
[rubydex](https://github.com/Shopify/rubydex)
|
|
16
|
-
state is resolved across files and
|
|
17
|
-
in-Ractor execution of the actual
|
|
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,
|
|
26
|
-
memoization
|
|
27
|
-
`
|
|
28
|
-
|
|
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 ||= {}`
|
|
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 : :
|
|
226
|
+
severity: hot ? :error : :info,
|
|
213
227
|
message: "class-level state " \
|
|
214
228
|
"#{entry["ivars"].join(", ")} on " \
|
|
215
229
|
"#{entry["const"]}#{detail}",
|
|
216
|
-
why:
|
|
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
|
)
|
data/lib/audition/fixer.rb
CHANGED
|
@@ -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
|
-
|
|
113
|
+
encoding = plan.source.encoding
|
|
114
|
+
bytes = plan.source.dup.force_encoding(Encoding::BINARY)
|
|
110
115
|
plan.edits.each do |edit|
|
|
111
|
-
|
|
112
|
-
edit.replacement
|
|
116
|
+
bytes[edit.start_offset...edit.end_offset] =
|
|
117
|
+
edit.replacement.dup.force_encoding(Encoding::BINARY)
|
|
113
118
|
end
|
|
114
|
-
|
|
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.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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:
|
|
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
|
data/lib/audition/report.rb
CHANGED
|
@@ -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
|
|