audition 0.2.4 → 0.3.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 +101 -9
- data/lib/audition/bundle_sweep.rb +11 -3
- data/lib/audition/cli.rb +110 -16
- data/lib/audition/config.rb +3 -3
- data/lib/audition/dynamic/harness.rb +113 -20
- data/lib/audition/dynamic/prober.rb +44 -3
- data/lib/audition/report/github.rb +68 -0
- data/lib/audition/report/json.rb +64 -0
- data/lib/audition/report/style.rb +74 -0
- data/lib/audition/report/text.rb +149 -0
- data/lib/audition/report.rb +8 -294
- data/lib/audition/rewriters.rb +4 -4
- data/lib/audition/static/checks/mutable_constants.rb +91 -28
- data/lib/audition/static/checks/runtime_require.rb +4 -1
- data/lib/audition/static/checks/unsafe_calls.rb +7 -6
- data/lib/audition/static/graph_audit.rb +3 -5
- data/lib/audition/static/literal_classifier.rb +78 -7
- data/lib/audition/static/native_extensions.rb +163 -0
- data/lib/audition/static/source_file.rb +70 -0
- data/lib/audition/target.rb +74 -10
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +1 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 28a9914f63873fd6bc4ec2e86154aa69e4e3dbfb08c3d9ee6218fc521e31836b
|
|
4
|
+
data.tar.gz: e1033a810ea825b21980fb3cb406bfd7bda1bf69e970e82c988fb0d149c0d172
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 07b6250748bf1d3e854157b6ac81f8a2574259f77d2e28746d241e880763cb446410b27736740c29f803e4936fed3d5ff0f6ca1b95d73cc6cb664896630c17fe
|
|
7
|
+
data.tar.gz: ab564046d55d28d6e94af9e045991574322e14074e4121e788c6b59f8ce3628f4499351d5f9cac7e670564bc9ea7472b3615056d9b91beaeb64dc3e131babc04
|
data/README.md
CHANGED
|
@@ -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
|
[](https://github.com/yaroslav/audition/releases)
|
|
15
19
|
[](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,
|
|
28
|
-
|
|
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,13 +47,15 @@ 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
|
-
(
|
|
48
|
-
in-place mutation of registry constants,
|
|
49
|
-
`define_method`, copy-on-write rewrites. The
|
|
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
61
|
`path:line` is clickable in supporting terminals. JSON output for
|
|
@@ -112,6 +120,7 @@ proxying) and its verified semantics.
|
|
|
112
120
|
- [Installation](#installation)
|
|
113
121
|
- [Usage](#usage)
|
|
114
122
|
- [Adopting incrementally](#adopting-incrementally)
|
|
123
|
+
- [CI and git hooks](#ci-and-git-hooks)
|
|
115
124
|
- [What it catches](#what-it-catches)
|
|
116
125
|
- [Agent skill](#agent-skill)
|
|
117
126
|
- [Extending](#extending)
|
|
@@ -140,6 +149,7 @@ audition path/to/gem-checkout # a gem working copy (*.gemspec)
|
|
|
140
149
|
audition path/to/rack-app # a config.ru directory
|
|
141
150
|
audition path/to/rails-root # a Rails application
|
|
142
151
|
audition lib # any directory, static-only
|
|
152
|
+
audition a.rb b.rb c.rb # several files at once, statically
|
|
143
153
|
audition Gemfile.lock # sweep every gem in the bundle
|
|
144
154
|
audition path/to/app --deps # same, from the app root
|
|
145
155
|
```
|
|
@@ -154,16 +164,19 @@ Useful flags:
|
|
|
154
164
|
| `--fix-unsafe` | also apply semantics-affecting corrections |
|
|
155
165
|
| `--dry-run` | with a fix flag: preview edits, change nothing |
|
|
156
166
|
| `--format json` | machine-readable report for CI |
|
|
157
|
-
| `--format github` | GitHub Actions annotations
|
|
167
|
+
| `--format github` | GitHub Actions annotations + job summary |
|
|
158
168
|
| `--compare old.json` | delta vs a previous report: fixed/introduced |
|
|
159
169
|
| `--static-only` / `--dynamic-only` | pick one probe layer |
|
|
160
170
|
| `--fail-on warning` | stricter CI gate (default: error) |
|
|
171
|
+
| `--exit-zero` | report findings but never fail the build |
|
|
161
172
|
| `--capabilities` | table of what this Ruby allows in Ractors |
|
|
162
173
|
| `--timeout 60` | dynamic probe budget in seconds |
|
|
163
174
|
| `--plain` | no colors or hyperlinks (also via NO_COLOR, pipes) |
|
|
164
175
|
|
|
165
176
|
Exit codes: `0` clean, `1` findings at or above the `--fail-on`
|
|
166
177
|
threshold (or a failed dynamic probe), `2` usage error.
|
|
178
|
+
`--exit-zero` (alias for `--fail-on never`) always exits `0`
|
|
179
|
+
unless the invocation itself is broken.
|
|
167
180
|
|
|
168
181
|
## Adopting incrementally
|
|
169
182
|
|
|
@@ -202,6 +215,71 @@ checks:
|
|
|
202
215
|
- at-exit
|
|
203
216
|
```
|
|
204
217
|
|
|
218
|
+
## CI and git hooks
|
|
219
|
+
|
|
220
|
+
**GitHub Actions.** `--format github` turns findings into
|
|
221
|
+
workflow-command annotations that land right on the PR diff, and
|
|
222
|
+
appends a verdict-plus-counts markdown table to the job summary
|
|
223
|
+
page. A blocking gate:
|
|
224
|
+
|
|
225
|
+
```yaml
|
|
226
|
+
- uses: ruby/setup-ruby@v1
|
|
227
|
+
with:
|
|
228
|
+
ruby-version: "4.0"
|
|
229
|
+
- run: gem install audition
|
|
230
|
+
- run: audition --format github .
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
To surface findings without failing the build while you adopt
|
|
234
|
+
(the flag other linters call `--exit-zero` too, so it keeps its
|
|
235
|
+
name here):
|
|
236
|
+
|
|
237
|
+
```yaml
|
|
238
|
+
- run: audition --format github --exit-zero .
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`--fail-on never` is the long form, and works from
|
|
242
|
+
`.audition.yml` as well; GitHub's own `continue-on-error: true`
|
|
243
|
+
on the step is the workflow-level equivalent.
|
|
244
|
+
|
|
245
|
+
**Git hooks.** Passing several `.rb` files audits exactly those
|
|
246
|
+
files statically, which is the shape hook managers hand over.
|
|
247
|
+
With [lefthook](https://github.com/evilmartians/lefthook):
|
|
248
|
+
|
|
249
|
+
```yaml
|
|
250
|
+
pre-commit:
|
|
251
|
+
commands:
|
|
252
|
+
audition:
|
|
253
|
+
glob: "*.rb"
|
|
254
|
+
run: audition --static-only --plain {staged_files}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
With [pre-commit](https://pre-commit.com):
|
|
258
|
+
|
|
259
|
+
```yaml
|
|
260
|
+
- repo: local
|
|
261
|
+
hooks:
|
|
262
|
+
- id: audition
|
|
263
|
+
name: audition
|
|
264
|
+
language: system
|
|
265
|
+
entry: audition --static-only --plain
|
|
266
|
+
types: [ruby]
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Config, pragmas, and the baseline resolve against the working
|
|
270
|
+
directory, so a hook run from the repository root honors the
|
|
271
|
+
same `.audition.yml` as a full audit.
|
|
272
|
+
|
|
273
|
+
**This repository eats its own dog food.** Every commit runs
|
|
274
|
+
`audition --static-only` over the staged files through lefthook
|
|
275
|
+
(next to standardrb), and every push and pull request gets a
|
|
276
|
+
full self-audit in CI with annotations and a job summary,
|
|
277
|
+
non-blocking via `--exit-zero`
|
|
278
|
+
([audit.yml](.github/workflows/audit.yml)). Current state: own
|
|
279
|
+
code audits `ready`; the full dynamic probe reports the terminal
|
|
280
|
+
dev-dependencies as `blocked`, which is exactly the distinction
|
|
281
|
+
the verdict system exists to make.
|
|
282
|
+
|
|
205
283
|
## What it catches
|
|
206
284
|
|
|
207
285
|
Static, with file:line precision:
|
|
@@ -235,6 +313,15 @@ Static, with file:line precision:
|
|
|
235
313
|
`class_attribute` migration Rails itself made) and
|
|
236
314
|
`class_attribute` without copy-on-write writes,
|
|
237
315
|
`include Singleton`, `fork`, `ObjectSpace._id2ref`, ENV mutation.
|
|
316
|
+
- **Native extensions that never declare Ractor safety**: a byte
|
|
317
|
+
scan of every compiled `.bundle`/`.so` for the
|
|
318
|
+
`rb_ext_ractor_safe` import, which also covers precompiled
|
|
319
|
+
platform gems that ship no sources; an unbuilt checkout is
|
|
320
|
+
scanned at the source level (`ext/**`, C, Rust, or Zig) instead.
|
|
321
|
+
A silent extension raises `Ractor::UnsafeError` on every call
|
|
322
|
+
from a non-main Ractor, so it rates a warning; a declared one
|
|
323
|
+
gets an info note, because the declaration is the maintainer's
|
|
324
|
+
assertion, not a proof.
|
|
238
325
|
|
|
239
326
|
Dynamic, on the live object graph:
|
|
240
327
|
|
|
@@ -244,6 +331,11 @@ Dynamic, on the live object graph:
|
|
|
244
331
|
`Ractor.shareable?`, and inspects every class and module for
|
|
245
332
|
class-level ivars and class variables, with
|
|
246
333
|
`const_source_location` attribution.
|
|
334
|
+
- Records every compiled extension the load pulled in,
|
|
335
|
+
dependencies included, and byte-scans each for the
|
|
336
|
+
`rb_ext_ractor_safe` import; silent ones are reported against
|
|
337
|
+
the dependency that ships them. Ruby's own extensions are left
|
|
338
|
+
to Ruby.
|
|
247
339
|
- Boots `config.ru` and serves one GET / entirely inside a Ractor,
|
|
248
340
|
the per-worker model of Ractor web servers; then hammers it from
|
|
249
341
|
4 Ractors x 25 requests to surface failures that only appear
|
|
@@ -305,7 +397,7 @@ Audition::Static::Checks.register(NoSleep)
|
|
|
305
397
|
bundle install
|
|
306
398
|
bundle exec rake spec # RSpec suite
|
|
307
399
|
bundle exec rake standard # standardrb lint
|
|
308
|
-
lefthook install # pre-commit
|
|
400
|
+
lefthook install # pre-commit standardrb + audition
|
|
309
401
|
bundle exec exe/audition --capabilities
|
|
310
402
|
```
|
|
311
403
|
|
|
@@ -115,7 +115,8 @@ module Audition
|
|
|
115
115
|
ruby_files: spec.require_paths.flat_map do |rp|
|
|
116
116
|
ruby_files_under(File.join(root, rp))
|
|
117
117
|
end,
|
|
118
|
-
entry: {mode: :require, feature: spec.name, root: root}
|
|
118
|
+
entry: {mode: :require, feature: spec.name, root: root},
|
|
119
|
+
compiled_files: Target.compiled_for(spec)
|
|
119
120
|
)
|
|
120
121
|
end
|
|
121
122
|
|
|
@@ -142,16 +143,23 @@ module Audition
|
|
|
142
143
|
files = target.ruby_files.reject do |file|
|
|
143
144
|
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
144
145
|
end
|
|
146
|
+
compiled = target.compiled_files.reject do |file|
|
|
147
|
+
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
148
|
+
end
|
|
145
149
|
per_file = Static::Analyzer.new
|
|
146
150
|
.analyze_paths(files, workers: 1)
|
|
147
|
-
per_file + Static::GraphAudit.new.analyze_paths(files)
|
|
151
|
+
per_file + Static::GraphAudit.new.analyze_paths(files) +
|
|
152
|
+
Static::NativeExtensions.new.analyze(target,
|
|
153
|
+
compiled_files: compiled)
|
|
148
154
|
end
|
|
149
155
|
|
|
150
156
|
def dynamic_results(target)
|
|
151
157
|
return [] if @static_only || target.entry.nil?
|
|
152
158
|
|
|
153
159
|
prober = Dynamic::Prober.new(timeout: @timeout)
|
|
154
|
-
[prober.probe(
|
|
160
|
+
[prober.probe(
|
|
161
|
+
target.entry.merge(compiled_files: target.compiled_files)
|
|
162
|
+
)]
|
|
155
163
|
end
|
|
156
164
|
end
|
|
157
165
|
end
|
data/lib/audition/cli.rb
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "optparse"
|
|
4
5
|
require "table_tennis"
|
|
5
6
|
|
|
6
7
|
module Audition
|
|
7
8
|
class CLI
|
|
8
|
-
USAGE = "usage: audition [options] TARGET " \
|
|
9
|
+
USAGE = "usage: audition [options] TARGET [FILE...] " \
|
|
9
10
|
"(a .rb script, directory, config.ru dir, Rails root, " \
|
|
10
|
-
"gem dir,
|
|
11
|
+
"gem dir, installed gem name, or several .rb files)"
|
|
11
12
|
|
|
12
13
|
# Entry point used by `exe/audition`.
|
|
13
14
|
#
|
|
@@ -31,13 +32,20 @@ module Audition
|
|
|
31
32
|
|
|
32
33
|
return print_capabilities(options) if options[:capabilities]
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
args = options[:args]
|
|
36
|
+
if args.empty?
|
|
36
37
|
@stderr.puts(USAGE)
|
|
37
38
|
return 2
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
# A single argument keeps full detection (including the
|
|
42
|
+
# dynamic probe); several arguments are the git-hook shape,
|
|
43
|
+
# a plain list of staged .rb files audited statically.
|
|
44
|
+
target = if args.size == 1
|
|
45
|
+
Target.detect(args.first)
|
|
46
|
+
else
|
|
47
|
+
Target.for_files(args)
|
|
48
|
+
end
|
|
41
49
|
target = deps_target(target) if options[:deps]
|
|
42
50
|
if target.type == :bundle
|
|
43
51
|
sweep(target, options)
|
|
@@ -89,11 +97,18 @@ module Audition
|
|
|
89
97
|
"with --fix: show planned edits, change nothing") do
|
|
90
98
|
options[:dry_run] = true
|
|
91
99
|
end
|
|
92
|
-
o.on("--fail-on LEVEL", %w[error warning info],
|
|
93
|
-
"exit 1 threshold (default: error)"
|
|
100
|
+
o.on("--fail-on LEVEL", %w[error warning info never],
|
|
101
|
+
"exit 1 threshold (default: error); never always " \
|
|
102
|
+
"exits 0") do |v|
|
|
94
103
|
options[:fail_on] = v.to_sym
|
|
95
104
|
options[:explicit] << :fail_on
|
|
96
105
|
end
|
|
106
|
+
o.on("--exit-zero",
|
|
107
|
+
"report findings but exit 0 (same as " \
|
|
108
|
+
"--fail-on never)") do
|
|
109
|
+
options[:fail_on] = :never
|
|
110
|
+
options[:explicit] << :fail_on
|
|
111
|
+
end
|
|
97
112
|
o.on("--write-baseline",
|
|
98
113
|
"record current findings as the baseline") do
|
|
99
114
|
options[:write_baseline] = true
|
|
@@ -150,7 +165,9 @@ module Audition
|
|
|
150
165
|
|
|
151
166
|
results = []
|
|
152
167
|
if target.entry && !options[:static_only]
|
|
153
|
-
results << prober(options).probe(
|
|
168
|
+
results << prober(options).probe(
|
|
169
|
+
target.entry.merge(compiled_files: target.compiled_files)
|
|
170
|
+
)
|
|
154
171
|
end
|
|
155
172
|
|
|
156
173
|
all = findings +
|
|
@@ -262,8 +279,13 @@ module Audition
|
|
|
262
279
|
files = target.ruby_files.reject do |file|
|
|
263
280
|
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
264
281
|
end
|
|
282
|
+
compiled = target.compiled_files.reject do |file|
|
|
283
|
+
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
284
|
+
end
|
|
265
285
|
per_file = Static::Analyzer.new.analyze_paths(files)
|
|
266
|
-
per_file + Static::GraphAudit.new.analyze_paths(files)
|
|
286
|
+
per_file + Static::GraphAudit.new.analyze_paths(files) +
|
|
287
|
+
Static::NativeExtensions.new.analyze(target,
|
|
288
|
+
compiled_files: compiled)
|
|
267
289
|
end
|
|
268
290
|
|
|
269
291
|
# Fix chatter goes to stderr: stdout carries the report, which
|
|
@@ -312,12 +334,33 @@ module Audition
|
|
|
312
334
|
|
|
313
335
|
def emit(report, options)
|
|
314
336
|
case options[:format]
|
|
315
|
-
when :json
|
|
316
|
-
|
|
317
|
-
|
|
337
|
+
when :json
|
|
338
|
+
@stdout.puts(Report::Json.new(report).render)
|
|
339
|
+
when :github
|
|
340
|
+
github = Report::Github.new(report)
|
|
341
|
+
@stdout.puts(github.render)
|
|
342
|
+
append_step_summary(github.summary)
|
|
343
|
+
else
|
|
344
|
+
@stdout.puts(
|
|
345
|
+
Report::Text.new(report, style(options)).render
|
|
346
|
+
)
|
|
318
347
|
end
|
|
319
348
|
end
|
|
320
349
|
|
|
350
|
+
# GitHub Actions exposes the job summary as an appendable
|
|
351
|
+
# file; outside Actions the variable is absent and this is a
|
|
352
|
+
# no-op. A broken path must not fail the audit itself.
|
|
353
|
+
def append_step_summary(markdown)
|
|
354
|
+
path = ENV["GITHUB_STEP_SUMMARY"]
|
|
355
|
+
return if path.nil? || path.empty?
|
|
356
|
+
|
|
357
|
+
File.open(path, "a") { |f| f.puts(markdown) }
|
|
358
|
+
rescue SystemCallError => e
|
|
359
|
+
@stderr.puts(
|
|
360
|
+
"audition: cannot write step summary: #{e.message}"
|
|
361
|
+
)
|
|
362
|
+
end
|
|
363
|
+
|
|
321
364
|
def style(options, io: @stdout)
|
|
322
365
|
if options[:plain]
|
|
323
366
|
Report::Style.new(color: false, hyperlinks: false)
|
|
@@ -327,6 +370,8 @@ module Audition
|
|
|
327
370
|
end
|
|
328
371
|
|
|
329
372
|
def exit_code(report, options)
|
|
373
|
+
return 0 if options[:fail_on] == :never
|
|
374
|
+
|
|
330
375
|
threshold = SEVERITIES.fetch(options[:fail_on])
|
|
331
376
|
failed =
|
|
332
377
|
report.findings.any? do |f|
|
|
@@ -359,11 +404,13 @@ module Audition
|
|
|
359
404
|
)
|
|
360
405
|
rows = sweeper.rows(progress: sweep_progress)
|
|
361
406
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
407
|
+
case options[:format]
|
|
408
|
+
when :json then emit_sweep_json(rows)
|
|
409
|
+
when :github then emit_sweep_github(rows)
|
|
410
|
+
else emit_sweep_table(rows, options)
|
|
366
411
|
end
|
|
412
|
+
return 0 if options[:fail_on] == :never
|
|
413
|
+
|
|
367
414
|
threshold = SEVERITIES.fetch(options[:fail_on])
|
|
368
415
|
(rows.any? { |r| row_failed?(r, threshold) }) ? 1 : 0
|
|
369
416
|
end
|
|
@@ -421,6 +468,53 @@ module Audition
|
|
|
421
468
|
)
|
|
422
469
|
end
|
|
423
470
|
|
|
471
|
+
# Sweep rows carry no file or line, so the annotations land on
|
|
472
|
+
# the run summary rather than a diff; the markdown table goes
|
|
473
|
+
# to the job summary page when Actions provides one.
|
|
474
|
+
def emit_sweep_github(rows)
|
|
475
|
+
ready = rows.count { |r| r.verdict == :ready }
|
|
476
|
+
rows.each do |row|
|
|
477
|
+
level = sweep_annotation_level(row)
|
|
478
|
+
next unless level
|
|
479
|
+
|
|
480
|
+
@stdout.puts(
|
|
481
|
+
"::#{level} title=audition::gem #{row.name} " \
|
|
482
|
+
"#{row.version}: #{row.errors} errors, " \
|
|
483
|
+
"#{row.dep_errors} dependency errors, " \
|
|
484
|
+
"#{row.warnings} warnings " \
|
|
485
|
+
"(#{VERDICT_CELLS.fetch(row.verdict)})"
|
|
486
|
+
)
|
|
487
|
+
end
|
|
488
|
+
@stdout.puts("#{ready} of #{rows.size} gems ractor-ready")
|
|
489
|
+
append_step_summary(sweep_summary_markdown(rows, ready))
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def sweep_annotation_level(row)
|
|
493
|
+
if row.verdict == :not_ready ||
|
|
494
|
+
(row.errors + row.dep_errors).positive?
|
|
495
|
+
"error"
|
|
496
|
+
elsif row.warnings.positive?
|
|
497
|
+
"warning"
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def sweep_summary_markdown(rows, ready)
|
|
502
|
+
lines = [
|
|
503
|
+
"## audition bundle sweep", "",
|
|
504
|
+
"| gem | version | verdict | errors | dep errors " \
|
|
505
|
+
"| warnings | fixable |",
|
|
506
|
+
"| --- | --- | --- | --- | --- | --- | --- |"
|
|
507
|
+
]
|
|
508
|
+
rows.each do |r|
|
|
509
|
+
lines << "| #{r.name} | #{r.version} | " \
|
|
510
|
+
"#{VERDICT_CELLS.fetch(r.verdict)} | #{r.errors} | " \
|
|
511
|
+
"#{r.dep_errors} | #{r.warnings} | #{r.fixable} |"
|
|
512
|
+
end
|
|
513
|
+
lines << ""
|
|
514
|
+
lines << "#{ready} of #{rows.size} gems ractor-ready"
|
|
515
|
+
lines.join("\n")
|
|
516
|
+
end
|
|
517
|
+
|
|
424
518
|
def emit_sweep_json(rows)
|
|
425
519
|
@stdout.puts(JSON.pretty_generate(
|
|
426
520
|
"audition" => VERSION,
|
data/lib/audition/config.rb
CHANGED
|
@@ -23,7 +23,7 @@ module Audition
|
|
|
23
23
|
disabled_checks: []}
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
-
FAIL_ON_LEVELS = %w[error warning info].freeze
|
|
26
|
+
FAIL_ON_LEVELS = %w[error warning info never].freeze
|
|
27
27
|
|
|
28
28
|
attr_reader :fail_on, :timeout, :exclude, :disabled_checks
|
|
29
29
|
|
|
@@ -59,8 +59,8 @@ module Audition
|
|
|
59
59
|
FAIL_ON_LEVELS.include?(fail_on.to_s)
|
|
60
60
|
|
|
61
61
|
raise Error,
|
|
62
|
-
"#{path}: fail_on must be one of error, warning,
|
|
63
|
-
"info (got #{fail_on.inspect})"
|
|
62
|
+
"#{path}: fail_on must be one of error, warning, " \
|
|
63
|
+
"info, or never (got #{fail_on.inspect})"
|
|
64
64
|
end
|
|
65
65
|
private_class_method :validate!
|
|
66
66
|
|
|
@@ -12,10 +12,23 @@ Warning[:experimental] = false
|
|
|
12
12
|
Thread.report_on_exception = false
|
|
13
13
|
|
|
14
14
|
require "json"
|
|
15
|
+
require "rbconfig"
|
|
15
16
|
|
|
16
17
|
module AuditionHarness
|
|
17
18
|
MAX_CONSTS = 5000
|
|
18
19
|
|
|
20
|
+
# Directories under the target root that are not the target's
|
|
21
|
+
# own surface. Bundler's deployment mode (and bundler-cache in
|
|
22
|
+
# GitHub Actions) vendors every gem into <root>/vendor/bundle,
|
|
23
|
+
# and attributing those constants to the target would flip its
|
|
24
|
+
# verdict from blocked to not_ready. Must mirror
|
|
25
|
+
# Audition::Target::EXCLUDED_DIRS (this file is a standalone
|
|
26
|
+
# subprocess script and cannot require the gem); a spec keeps
|
|
27
|
+
# the two lists in sync.
|
|
28
|
+
EXCLUDED_DIRS = %w[
|
|
29
|
+
vendor node_modules tmp log coverage pkg .git .bundle
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
19
32
|
# Fixtures for capability probes.
|
|
20
33
|
CAP_CONST = [1, 2] # audition:disable mutable-constants
|
|
21
34
|
|
|
@@ -125,18 +138,45 @@ module AuditionHarness
|
|
|
125
138
|
$LOAD_PATH.unshift(lp) # audition:disable global-variables
|
|
126
139
|
end
|
|
127
140
|
before = Object.constants
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
141
|
+
features = $LOADED_FEATURES.dup # audition:disable global-variables
|
|
142
|
+
require_target(payload.fetch("feature"), payload["root"])
|
|
143
|
+
scan(Object.constants - before, root: payload["root"]).merge(
|
|
144
|
+
"native_extensions" => native_extensions(
|
|
145
|
+
features, payload["root"], payload["known_compiled"]
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
end
|
|
136
149
|
|
|
137
|
-
|
|
150
|
+
# Gem names and entry files diverge in two conventional ways:
|
|
151
|
+
# dashed names ship slashed files (rspec-mocks provides
|
|
152
|
+
# rspec/mocks), and squashed names ship snake_case files
|
|
153
|
+
# (activesupport provides active_support). The second has no
|
|
154
|
+
# rule to invert, so when the target ships exactly one top-level
|
|
155
|
+
# file under lib/, that file is the entry, required by absolute
|
|
156
|
+
# path. An absolute require keeps the path as given, and scan
|
|
157
|
+
# compares constant origins against the realpathed root, so the
|
|
158
|
+
# candidate is built from the realpath too (a symlinked tmpdir,
|
|
159
|
+
# macOS /var, otherwise turns own findings into dependency ones).
|
|
160
|
+
# The error reported is the last one seen: a candidate that loads
|
|
161
|
+
# but fails inside says more than "cannot load such file".
|
|
162
|
+
def require_target(feature, root)
|
|
163
|
+
require feature # audition:disable runtime-require
|
|
164
|
+
rescue LoadError => error
|
|
165
|
+
entry_candidates(feature, root).each do |candidate|
|
|
166
|
+
return require candidate # audition:disable runtime-require
|
|
167
|
+
rescue LoadError => e
|
|
168
|
+
error = e
|
|
138
169
|
end
|
|
139
|
-
|
|
170
|
+
raise error
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def entry_candidates(feature, root)
|
|
174
|
+
candidates = []
|
|
175
|
+
slashed = feature.tr("-", "/")
|
|
176
|
+
candidates << slashed if slashed != feature
|
|
177
|
+
files = root ? Dir[File.join(realpath(root), "lib", "*.rb")] : []
|
|
178
|
+
candidates << files.first.delete_suffix(".rb") if files.size == 1
|
|
179
|
+
candidates
|
|
140
180
|
end
|
|
141
181
|
|
|
142
182
|
# Breadth-first walk of every constant the require introduced:
|
|
@@ -148,13 +188,7 @@ module AuditionHarness
|
|
|
148
188
|
# Loaded features are realpathed by require; the target root
|
|
149
189
|
# must be too, or symlinked paths (macOS /var vs /private/var)
|
|
150
190
|
# break the own-vs-dependency comparison.
|
|
151
|
-
|
|
152
|
-
root = begin
|
|
153
|
-
File.realpath(root)
|
|
154
|
-
rescue
|
|
155
|
-
root
|
|
156
|
-
end
|
|
157
|
-
end
|
|
191
|
+
root = realpath(root)
|
|
158
192
|
unshareable = []
|
|
159
193
|
class_state = []
|
|
160
194
|
class_vars = []
|
|
@@ -218,10 +252,64 @@ module AuditionHarness
|
|
|
218
252
|
end
|
|
219
253
|
# The separator matters: /x/app must not claim /x/app-helpers.
|
|
220
254
|
own = root.nil? || path.nil? || path == root ||
|
|
221
|
-
path.start_with?(root + File::SEPARATOR)
|
|
255
|
+
(path.start_with?(root + File::SEPARATOR) &&
|
|
256
|
+
!excluded?(path, root))
|
|
222
257
|
{"path" => path, "line" => line, "own" => own}
|
|
223
258
|
end
|
|
224
259
|
|
|
260
|
+
# Matches the static scanner's exclusion rule: any excluded or
|
|
261
|
+
# dot-prefixed component in the root-relative path means the
|
|
262
|
+
# file is not the target's own code.
|
|
263
|
+
def excluded?(path, root)
|
|
264
|
+
relative = path.delete_prefix(root + File::SEPARATOR)
|
|
265
|
+
relative.split(File::SEPARATOR).any? do |part|
|
|
266
|
+
EXCLUDED_DIRS.include?(part) || part.start_with?(".")
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
NATIVE = /\.(bundle|so)\z/
|
|
271
|
+
DECLARATION = "rb_ext_ractor_safe"
|
|
272
|
+
|
|
273
|
+
# Compiled extensions the require pulled in, with the one fact
|
|
274
|
+
# that decides their Ractor behavior: whether the file imports
|
|
275
|
+
# rb_ext_ractor_safe. Ruby's own extensions (archdir) are flagged
|
|
276
|
+
# so the prober can leave them to Ruby, and files the static check
|
|
277
|
+
# already covers are flagged as known.
|
|
278
|
+
def native_extensions(before, root, known)
|
|
279
|
+
root = realpath(root)
|
|
280
|
+
known = Array(known).map { |path| realpath(path) }
|
|
281
|
+
archdir = RbConfig::CONFIG["archdir"] + File::SEPARATOR
|
|
282
|
+
loaded = $LOADED_FEATURES - before # audition:disable global-variables
|
|
283
|
+
loaded.grep(NATIVE).map do |path|
|
|
284
|
+
{"path" => path,
|
|
285
|
+
"declares" => declares?(path),
|
|
286
|
+
"ruby" => path.start_with?(archdir),
|
|
287
|
+
"known" => known.include?(path),
|
|
288
|
+
"own" => known.include?(path) || own_path?(path, root)}
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def declares?(path)
|
|
293
|
+
File.binread(path).include?(DECLARATION)
|
|
294
|
+
rescue SystemCallError
|
|
295
|
+
false
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def own_path?(path, root)
|
|
299
|
+
return false unless root
|
|
300
|
+
|
|
301
|
+
path == root ||
|
|
302
|
+
(path.start_with?(root + File::SEPARATOR) && !excluded?(path, root))
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def realpath(path)
|
|
306
|
+
return path if path.nil?
|
|
307
|
+
|
|
308
|
+
File.realpath(path)
|
|
309
|
+
rescue SystemCallError
|
|
310
|
+
path
|
|
311
|
+
end
|
|
312
|
+
|
|
225
313
|
def inspect_module(full, mod, origin, class_state, class_vars)
|
|
226
314
|
ivars = mod.instance_variables
|
|
227
315
|
if ivars.any?
|
|
@@ -360,6 +448,7 @@ module AuditionHarness
|
|
|
360
448
|
def rails(payload)
|
|
361
449
|
environment = payload.fetch("environment")
|
|
362
450
|
before = Object.constants
|
|
451
|
+
features = $LOADED_FEATURES.dup # audition:disable global-variables
|
|
363
452
|
started = Time.now
|
|
364
453
|
require environment # audition:disable runtime-require
|
|
365
454
|
begin
|
|
@@ -369,8 +458,12 @@ module AuditionHarness
|
|
|
369
458
|
end
|
|
370
459
|
boot = {"ok" => true,
|
|
371
460
|
"seconds" => (Time.now - started).round(1)}
|
|
372
|
-
scan(Object.constants - before, root: payload["root"])
|
|
373
|
-
|
|
461
|
+
scan(Object.constants - before, root: payload["root"]).merge(
|
|
462
|
+
"boot" => boot,
|
|
463
|
+
"native_extensions" => native_extensions(
|
|
464
|
+
features, payload["root"], payload["known_compiled"]
|
|
465
|
+
)
|
|
466
|
+
)
|
|
374
467
|
rescue Exception => e
|
|
375
468
|
{"boot" => {"ok" => false, "error" => describe_error(e)}}
|
|
376
469
|
end
|