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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +290 -0
- data/exe/audition +6 -0
- data/lib/audition/baseline.rb +66 -0
- data/lib/audition/bundle_sweep.rb +110 -0
- data/lib/audition/cli.rb +415 -0
- data/lib/audition/config.rb +67 -0
- data/lib/audition/directives.rb +56 -0
- data/lib/audition/dynamic/harness.rb +423 -0
- data/lib/audition/dynamic/prober.rb +362 -0
- data/lib/audition/finding.rb +94 -0
- data/lib/audition/fixer.rb +141 -0
- data/lib/audition/report.rb +344 -0
- data/lib/audition/rewriters.rb +459 -0
- data/lib/audition/static/analyzer.rb +96 -0
- data/lib/audition/static/checks/base.rb +160 -0
- data/lib/audition/static/checks/global_variables.rb +67 -0
- data/lib/audition/static/checks/mutable_constants.rb +145 -0
- data/lib/audition/static/checks/ractor_isolation.rb +105 -0
- data/lib/audition/static/checks/runtime_require.rb +126 -0
- data/lib/audition/static/checks/unsafe_calls.rb +166 -0
- data/lib/audition/static/checks.rb +46 -0
- data/lib/audition/static/graph_audit.rb +143 -0
- data/lib/audition/static/literal_classifier.rb +145 -0
- data/lib/audition/static/source_file.rb +109 -0
- data/lib/audition/target.rb +172 -0
- data/lib/audition/version.rb +5 -0
- data/lib/audition.rb +37 -0
- metadata +143 -0
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
|
+
[](https://github.com/yaroslav/audition/releases)
|
|
11
|
+
[](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,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
|