audition 0.1.0 → 0.2.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 +4 -4
- data/README.md +57 -26
- data/exe/audition +8 -0
- data/lib/audition/bundle_sweep.rb +60 -13
- data/lib/audition/cli.rb +82 -29
- data/lib/audition/config.rb +28 -3
- data/lib/audition/directives.rb +26 -8
- data/lib/audition/dynamic/harness.rb +31 -5
- data/lib/audition/dynamic/prober.rb +72 -16
- data/lib/audition/fixer.rb +59 -25
- data/lib/audition/report.rb +41 -11
- data/lib/audition/rewriters.rb +725 -85
- data/lib/audition/static/analyzer.rb +7 -1
- data/lib/audition/static/checks/base.rb +19 -4
- data/lib/audition/static/checks/global_variables.rb +2 -1
- data/lib/audition/static/checks/mutable_constants.rb +168 -15
- data/lib/audition/static/checks/ractor_isolation.rb +5 -1
- data/lib/audition/static/checks/runtime_require.rb +98 -15
- data/lib/audition/static/checks/unsafe_calls.rb +28 -1
- data/lib/audition/static/graph_audit.rb +242 -14
- data/lib/audition/static/literal_classifier.rb +76 -12
- data/lib/audition/static/source_file.rb +92 -11
- data/lib/audition/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e82f1a081cebcd3209cfbced17ecb539949cf4590c785e1b6873535473090756
|
|
4
|
+
data.tar.gz: 7d6c4358bae0e8514e15452c0f0dba8bde7a53a9dd342ec9b96e9cf53a4c853f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1ddb8750389aa6734d895a4a84e32fb8a0cb0feca7d26c83e0803e8f28abfd6f16fd3255f352ba3e4d3534192636da3d6f73f651fd90dff8d18654384f779bf
|
|
7
|
+
data.tar.gz: dfe09ec14b4f3058f2df766129a78d6cfed1dfd65bd72ce512a6cfeea65e1ae42ef01bf0cb9d0c9f1d890973dcbcc052396dbca9cf445e0bf79d08676d8f3c45
|
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,7 @@ proxying) and its verified semantics.
|
|
|
94
113
|
- [Usage](#usage)
|
|
95
114
|
- [Adopting incrementally](#adopting-incrementally)
|
|
96
115
|
- [What it catches](#what-it-catches)
|
|
97
|
-
- [
|
|
116
|
+
- [Agent skill](#agent-skill)
|
|
98
117
|
- [Extending](#extending)
|
|
99
118
|
- [Development](#development)
|
|
100
119
|
- [License](#license)
|
|
@@ -191,14 +210,19 @@ Static, with file:line precision:
|
|
|
191
210
|
- **Class variables**, resolved on the rubydex graph.
|
|
192
211
|
- **Class-level instance variables**, unified across the class
|
|
193
212
|
body, `def self.`, and `class << self`, across files; the classic
|
|
194
|
-
`@cache ||= {}`
|
|
213
|
+
`@cache ||= {}` and `return @x if defined?(@x)` memoizations.
|
|
195
214
|
- **Constants that are not deeply shareable**: bare mutable
|
|
196
215
|
literals, interpolated strings, and the subtle shallow freeze
|
|
197
216
|
(`[[1], [2]].freeze` still raises; audition explains why).
|
|
198
217
|
Honors `# frozen_string_literal:` and
|
|
199
218
|
`# shareable_constant_value:` magic comments.
|
|
200
219
|
- **Sync primitives and Procs in constants** (Mutex, Queue,
|
|
201
|
-
lambdas).
|
|
220
|
+
lambdas), including `Hash.new { }` default procs, which stay
|
|
221
|
+
unshareable even after `.freeze`.
|
|
222
|
+
- **Registry-style constant mutation** (`RENDERERS << key`,
|
|
223
|
+
`LOOKUP[k] = v`) and **`define_method` with a literal block**
|
|
224
|
+
(the method carries an unshareable Proc); both patterns and
|
|
225
|
+
their fixes come from the Rails core ractorization study.
|
|
202
226
|
- **Runtime require and autoload** (serializes all Ractors through
|
|
203
227
|
the main-Ractor proxy).
|
|
204
228
|
- **`Ractor.new` blocks capturing outer locals** (the ArgumentError
|
|
@@ -222,20 +246,27 @@ Dynamic, on the live object graph:
|
|
|
222
246
|
- Boots Rails (`config/environment.rb`), eager-loads, and sweeps
|
|
223
247
|
the application's namespaces.
|
|
224
248
|
|
|
225
|
-
##
|
|
249
|
+
## Agent skill
|
|
226
250
|
|
|
227
|
-
|
|
228
|
-
|
|
251
|
+
This repository ships a `ractor-readiness` skill that teaches
|
|
252
|
+
coding agents (Claude Code and friends) the full audition
|
|
253
|
+
workflow: audit, fix tiers, suite-parity verification, and
|
|
254
|
+
incremental adoption. It lives in
|
|
255
|
+
[skills/ractor-readiness/SKILL.md](skills/ractor-readiness/SKILL.md).
|
|
229
256
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
257
|
+
Install into Claude Code as a plugin:
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
/plugin marketplace add yaroslav/audition
|
|
261
|
+
/plugin install audition@audition
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Or install the skill with the
|
|
265
|
+
[skills CLI](https://github.com/vercel-labs/skills):
|
|
266
|
+
|
|
267
|
+
```console
|
|
268
|
+
$ npx skills add yaroslav/audition
|
|
269
|
+
```
|
|
239
270
|
|
|
240
271
|
## Extending
|
|
241
272
|
|
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)
|
|
@@ -11,7 +11,7 @@ module Audition
|
|
|
11
11
|
CONCURRENCY = 4
|
|
12
12
|
|
|
13
13
|
Row = Data.define(:name, :version, :verdict, :errors,
|
|
14
|
-
:dep_errors, :warnings, :fixable, :status)
|
|
14
|
+
:dep_errors, :warnings, :infos, :fixable, :status)
|
|
15
15
|
|
|
16
16
|
VERDICT_ORDER = {
|
|
17
17
|
:not_ready => 0, :blocked => 1, :risky => 2, :ready => 3, nil => 4
|
|
@@ -66,38 +66,85 @@ module Audition
|
|
|
66
66
|
parser.specs.map { |s| [s.name, s.version.to_s] }.uniq
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# Mirrors CLI#audit: the locked version's own spec is audited,
|
|
70
|
+
# and findings pass through the gem's inline pragmas and its
|
|
71
|
+
# .audition.yml (exclude plus checks.disable) so a sweep row
|
|
72
|
+
# agrees with a direct audit of the same gem.
|
|
69
73
|
def audit_gem(name, version)
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
spec = Gem::Specification.find_by_name(name, version)
|
|
75
|
+
target = gem_target(spec)
|
|
76
|
+
config = Config.load(target.root)
|
|
77
|
+
directives = Directives.new
|
|
78
|
+
findings = filter(static_findings(target, config),
|
|
79
|
+
directives, config)
|
|
72
80
|
results = dynamic_results(target)
|
|
81
|
+
findings += filter(results.flat_map(&:findings),
|
|
82
|
+
directives, config)
|
|
73
83
|
report = Report.new(
|
|
74
84
|
target_type: :gem,
|
|
75
85
|
target_root: target.root,
|
|
76
|
-
findings: findings
|
|
86
|
+
findings: findings,
|
|
77
87
|
dynamic_results: results
|
|
78
88
|
)
|
|
79
89
|
counts = report.counts
|
|
80
90
|
Row.new(
|
|
81
91
|
name: name, version: version, verdict: report.verdict,
|
|
82
92
|
errors: counts[:error], dep_errors: counts[:dep_error],
|
|
83
|
-
warnings: counts[:warning],
|
|
84
|
-
status: "ok"
|
|
93
|
+
warnings: counts[:warning], infos: counts[:info],
|
|
94
|
+
fixable: counts[:fixable], status: "ok"
|
|
85
95
|
)
|
|
86
|
-
rescue
|
|
96
|
+
rescue Gem::MissingSpecError
|
|
97
|
+
failed_row(name, version, "not installed")
|
|
98
|
+
rescue => e
|
|
99
|
+
failed_row(name, version, "failed: #{e.class}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def failed_row(name, version, status)
|
|
87
103
|
Row.new(
|
|
88
104
|
name: name, version: version, verdict: nil, errors: 0,
|
|
89
|
-
dep_errors: 0, warnings: 0, fixable: 0,
|
|
90
|
-
status:
|
|
105
|
+
dep_errors: 0, warnings: 0, infos: 0, fixable: 0,
|
|
106
|
+
status: status
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def gem_target(spec)
|
|
111
|
+
root = spec.gem_dir
|
|
112
|
+
Target.new(
|
|
113
|
+
type: :gem,
|
|
114
|
+
root: root,
|
|
115
|
+
ruby_files: spec.require_paths.flat_map do |rp|
|
|
116
|
+
ruby_files_under(File.join(root, rp))
|
|
117
|
+
end,
|
|
118
|
+
entry: {mode: :require, feature: spec.name, root: root}
|
|
91
119
|
)
|
|
92
120
|
end
|
|
93
121
|
|
|
122
|
+
# Same glob discipline as Target: skip vendored trees and
|
|
123
|
+
# dotdirs.
|
|
124
|
+
def ruby_files_under(dir)
|
|
125
|
+
Dir[File.join(dir, "**", "*.rb")].reject do |path|
|
|
126
|
+
path.delete_prefix("#{dir}/").split("/").any? do |part|
|
|
127
|
+
Target::EXCLUDED_DIRS.include?(part) ||
|
|
128
|
+
part.start_with?(".")
|
|
129
|
+
end
|
|
130
|
+
end.sort
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def filter(findings, directives, config)
|
|
134
|
+
directives.filter(findings).reject do |finding|
|
|
135
|
+
config.check_disabled?(finding.check)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
94
139
|
# Worker threads already parallelize across gems; per-gem
|
|
95
140
|
# scanning stays serial to avoid a Ractor storm.
|
|
96
|
-
def static_findings(target)
|
|
141
|
+
def static_findings(target, config)
|
|
142
|
+
files = target.ruby_files.reject do |file|
|
|
143
|
+
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
144
|
+
end
|
|
97
145
|
per_file = Static::Analyzer.new
|
|
98
|
-
.analyze_paths(
|
|
99
|
-
per_file + Static::GraphAudit.new
|
|
100
|
-
.analyze_paths(target.ruby_files)
|
|
146
|
+
.analyze_paths(files, workers: 1)
|
|
147
|
+
per_file + Static::GraphAudit.new.analyze_paths(files)
|
|
101
148
|
end
|
|
102
149
|
|
|
103
150
|
def dynamic_results(target)
|
data/lib/audition/cli.rb
CHANGED
|
@@ -135,6 +135,9 @@ module Audition
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def audit(target, options)
|
|
138
|
+
# Read the comparison report up front: a missing or broken
|
|
139
|
+
# file should be a fast usage error, not a post-audit crash.
|
|
140
|
+
compare = load_compare(options[:compare])
|
|
138
141
|
config = Config.load(target.root)
|
|
139
142
|
options = apply_config(options, config)
|
|
140
143
|
directives = Directives.new
|
|
@@ -162,6 +165,7 @@ module Audition
|
|
|
162
165
|
return 0
|
|
163
166
|
end
|
|
164
167
|
|
|
168
|
+
visible = all
|
|
165
169
|
all, baselined = apply_baseline(all, target, options)
|
|
166
170
|
|
|
167
171
|
report = Report.new(
|
|
@@ -173,34 +177,43 @@ module Audition
|
|
|
173
177
|
baselined: baselined
|
|
174
178
|
)
|
|
175
179
|
emit(report, options)
|
|
176
|
-
if
|
|
177
|
-
emit_comparison(
|
|
180
|
+
if compare && options[:format] != :json
|
|
181
|
+
emit_comparison(compare, visible, target, options)
|
|
178
182
|
end
|
|
179
183
|
exit_code(report, options)
|
|
180
184
|
end
|
|
181
185
|
|
|
182
|
-
def
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
def load_compare(path)
|
|
187
|
+
return nil unless path
|
|
188
|
+
|
|
189
|
+
JSON.parse(File.read(path))
|
|
190
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
191
|
+
raise Error, "cannot read #{path}: #{e.message}"
|
|
192
|
+
end
|
|
188
193
|
|
|
194
|
+
# Fingerprints are root-relative on both sides, so an absolute
|
|
195
|
+
# old run compares cleanly against a relative new run. The
|
|
196
|
+
# comparison sees pre-baseline visible findings: a baselined
|
|
197
|
+
# finding still exists and must not count as fixed.
|
|
198
|
+
def emit_comparison(old, findings, target, options)
|
|
199
|
+
old_root = old.dig("target", "root").to_s
|
|
189
200
|
budget = Hash.new(0)
|
|
190
201
|
old.fetch("findings", []).each do |f|
|
|
191
|
-
|
|
202
|
+
key = [f["check"], relativize(f["path"].to_s, old_root),
|
|
203
|
+
f["message"]]
|
|
204
|
+
budget[key] += 1
|
|
192
205
|
end
|
|
193
206
|
total_old = budget.values.sum
|
|
194
207
|
|
|
195
|
-
introduced =
|
|
196
|
-
key = [f.check, f.path,
|
|
208
|
+
introduced = findings.reject do |f|
|
|
209
|
+
key = [f.check, relativize(f.path.to_s, target.root),
|
|
210
|
+
f.message]
|
|
197
211
|
next false unless budget[key].positive?
|
|
198
212
|
|
|
199
213
|
budget[key] -= 1
|
|
200
214
|
true
|
|
201
215
|
end
|
|
202
|
-
fixed = total_old -
|
|
203
|
-
(report.findings.size - introduced.size)
|
|
216
|
+
fixed = total_old - (findings.size - introduced.size)
|
|
204
217
|
|
|
205
218
|
s = style(options)
|
|
206
219
|
@stdout.puts(
|
|
@@ -213,6 +226,12 @@ module Audition
|
|
|
213
226
|
end
|
|
214
227
|
end
|
|
215
228
|
|
|
229
|
+
def relativize(path, root)
|
|
230
|
+
return path if root.empty?
|
|
231
|
+
|
|
232
|
+
path.delete_prefix("#{root}/")
|
|
233
|
+
end
|
|
234
|
+
|
|
216
235
|
def apply_config(options, config)
|
|
217
236
|
merged = options.dup
|
|
218
237
|
if config.fail_on && !options[:explicit].include?(:fail_on)
|
|
@@ -247,6 +266,8 @@ module Audition
|
|
|
247
266
|
per_file + Static::GraphAudit.new.analyze_paths(files)
|
|
248
267
|
end
|
|
249
268
|
|
|
269
|
+
# Fix chatter goes to stderr: stdout carries the report, which
|
|
270
|
+
# must stay parseable under --format json.
|
|
250
271
|
def run_fix(target, findings, options)
|
|
251
272
|
fixer = Fixer.new(unsafe: options[:unsafe])
|
|
252
273
|
if options[:dry_run]
|
|
@@ -256,30 +277,33 @@ module Audition
|
|
|
256
277
|
|
|
257
278
|
applied = fixer.apply(findings)
|
|
258
279
|
total = applied.values.sum
|
|
259
|
-
|
|
280
|
+
if total.zero?
|
|
281
|
+
@stderr.puts("nothing to fix")
|
|
282
|
+
return findings
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
@stderr.puts(
|
|
260
286
|
"fixed #{total} finding(s) in #{applied.size} file(s)"
|
|
261
287
|
)
|
|
262
|
-
return findings unless total.positive?
|
|
263
|
-
|
|
264
288
|
filter(static_findings(target, Config.load(target.root)),
|
|
265
289
|
Directives.new, Config.load(target.root))
|
|
266
290
|
end
|
|
267
291
|
|
|
268
292
|
def render_preview(previews, options)
|
|
269
|
-
s = style(options)
|
|
293
|
+
s = style(options, io: @stderr)
|
|
270
294
|
previews.each do |preview|
|
|
271
|
-
@
|
|
295
|
+
@stderr.puts(s.bold(preview[:path]))
|
|
272
296
|
preview[:hunks].each do |hunk|
|
|
273
|
-
@
|
|
297
|
+
@stderr.puts(" @ line #{hunk[:line]}")
|
|
274
298
|
hunk[:old].each_line do |line|
|
|
275
|
-
@
|
|
299
|
+
@stderr.puts(s.red(" - #{line.chomp}"))
|
|
276
300
|
end
|
|
277
301
|
hunk[:new].each_line do |line|
|
|
278
|
-
@
|
|
302
|
+
@stderr.puts(s.green(" + #{line.chomp}"))
|
|
279
303
|
end
|
|
280
304
|
end
|
|
281
305
|
end
|
|
282
|
-
@
|
|
306
|
+
@stderr.puts("dry run: no files were changed")
|
|
283
307
|
end
|
|
284
308
|
|
|
285
309
|
def prober(options)
|
|
@@ -294,11 +318,11 @@ module Audition
|
|
|
294
318
|
end
|
|
295
319
|
end
|
|
296
320
|
|
|
297
|
-
def style(options)
|
|
321
|
+
def style(options, io: @stdout)
|
|
298
322
|
if options[:plain]
|
|
299
323
|
Report::Style.new(color: false, hyperlinks: false)
|
|
300
324
|
else
|
|
301
|
-
Report::Style.detect(io:
|
|
325
|
+
Report::Style.detect(io: io)
|
|
302
326
|
end
|
|
303
327
|
end
|
|
304
328
|
|
|
@@ -338,9 +362,21 @@ module Audition
|
|
|
338
362
|
if options[:format] == :json
|
|
339
363
|
emit_sweep_json(rows)
|
|
340
364
|
else
|
|
341
|
-
emit_sweep_table(rows)
|
|
365
|
+
emit_sweep_table(rows, options)
|
|
342
366
|
end
|
|
343
|
-
|
|
367
|
+
threshold = SEVERITIES.fetch(options[:fail_on])
|
|
368
|
+
(rows.any? { |r| row_failed?(r, threshold) }) ? 1 : 0
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Same contract as a direct audit: a row fails when it carries
|
|
372
|
+
# findings at or above --fail-on; not-ready rows always fail.
|
|
373
|
+
def row_failed?(row, threshold)
|
|
374
|
+
return true if row.verdict == :not_ready
|
|
375
|
+
|
|
376
|
+
hits = row.errors + row.dep_errors
|
|
377
|
+
hits += row.warnings if threshold <= SEVERITIES[:warning]
|
|
378
|
+
hits += row.infos if threshold <= SEVERITIES[:info]
|
|
379
|
+
hits.positive?
|
|
344
380
|
end
|
|
345
381
|
|
|
346
382
|
def sweep_progress
|
|
@@ -351,7 +387,19 @@ module Audition
|
|
|
351
387
|
end
|
|
352
388
|
end
|
|
353
389
|
|
|
354
|
-
|
|
390
|
+
# Cells arrive preformatted: coercion would render a version
|
|
391
|
+
# like "3.2" as 3.200. Color follows the CLI's own detection
|
|
392
|
+
# because the table gem reads the global $stdout and cannot
|
|
393
|
+
# see --plain. Autolayout only on ttys; pipes keep full width.
|
|
394
|
+
def table_opts(options)
|
|
395
|
+
tty = @stdout.respond_to?(:tty?) && @stdout.tty?
|
|
396
|
+
{
|
|
397
|
+
color: style(options).color?, coerce: false,
|
|
398
|
+
layout: tty, placeholder: "-"
|
|
399
|
+
}
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def emit_sweep_table(rows, options)
|
|
355
403
|
ready = rows.count { |r| r.verdict == :ready }
|
|
356
404
|
table = rows.map do |r|
|
|
357
405
|
{
|
|
@@ -365,7 +413,9 @@ module Audition
|
|
|
365
413
|
"status" => r.status
|
|
366
414
|
}
|
|
367
415
|
end
|
|
368
|
-
@stdout.puts(TableTennis.new(
|
|
416
|
+
@stdout.puts(TableTennis.new(
|
|
417
|
+
table, zebra: true, **table_opts(options)
|
|
418
|
+
).to_s)
|
|
369
419
|
@stdout.puts(
|
|
370
420
|
"#{ready} of #{rows.size} gems ractor-ready"
|
|
371
421
|
)
|
|
@@ -383,6 +433,7 @@ module Audition
|
|
|
383
433
|
"errors" => r.errors,
|
|
384
434
|
"dependency_errors" => r.dep_errors,
|
|
385
435
|
"warnings" => r.warnings,
|
|
436
|
+
"infos" => r.infos,
|
|
386
437
|
"fixable" => r.fixable,
|
|
387
438
|
"status" => r.status
|
|
388
439
|
}
|
|
@@ -408,7 +459,9 @@ module Audition
|
|
|
408
459
|
}
|
|
409
460
|
end
|
|
410
461
|
@stdout.puts("ruby #{RUBY_VERSION} at #{RbConfig.ruby}")
|
|
411
|
-
@stdout.puts(
|
|
462
|
+
@stdout.puts(
|
|
463
|
+
TableTennis.new(rows, **table_opts(options)).to_s
|
|
464
|
+
)
|
|
412
465
|
0
|
|
413
466
|
end
|
|
414
467
|
end
|
data/lib/audition/config.rb
CHANGED
|
@@ -23,16 +23,20 @@ module Audition
|
|
|
23
23
|
disabled_checks: []}
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
+
FAIL_ON_LEVELS = %w[error warning info].freeze
|
|
27
|
+
|
|
26
28
|
attr_reader :fail_on, :timeout, :exclude, :disabled_checks
|
|
27
29
|
|
|
28
30
|
# @param root [String] directory that may contain .audition.yml
|
|
29
31
|
# @return [Config] empty config when the file is absent
|
|
30
|
-
# @raise [Audition::Error] on malformed YAML
|
|
32
|
+
# @raise [Audition::Error] on malformed YAML, a non-mapping
|
|
33
|
+
# document, or an unknown fail_on level
|
|
31
34
|
def self.load(root)
|
|
32
35
|
path = File.join(root.to_s, FILE)
|
|
33
36
|
return new(**EMPTY) unless File.file?(path)
|
|
34
37
|
|
|
35
38
|
data = YAML.safe_load_file(path) || {}
|
|
39
|
+
validate!(path, data)
|
|
36
40
|
new(
|
|
37
41
|
fail_on: data["fail_on"]&.to_sym,
|
|
38
42
|
timeout: data["timeout"],
|
|
@@ -44,6 +48,22 @@ module Audition
|
|
|
44
48
|
raise Error, "#{path}: #{e.message}"
|
|
45
49
|
end
|
|
46
50
|
|
|
51
|
+
def self.validate!(path, data)
|
|
52
|
+
unless data.is_a?(Hash)
|
|
53
|
+
raise Error,
|
|
54
|
+
"#{path}: expected a YAML mapping, got #{data.class}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
fail_on = data["fail_on"]
|
|
58
|
+
return if fail_on.nil? ||
|
|
59
|
+
FAIL_ON_LEVELS.include?(fail_on.to_s)
|
|
60
|
+
|
|
61
|
+
raise Error,
|
|
62
|
+
"#{path}: fail_on must be one of error, warning, or " \
|
|
63
|
+
"info (got #{fail_on.inspect})"
|
|
64
|
+
end
|
|
65
|
+
private_class_method :validate!
|
|
66
|
+
|
|
47
67
|
def initialize(fail_on:, timeout:, exclude:, disabled_checks:)
|
|
48
68
|
@fail_on = fail_on
|
|
49
69
|
@timeout = timeout
|
|
@@ -51,9 +71,14 @@ module Audition
|
|
|
51
71
|
@disabled_checks = disabled_checks
|
|
52
72
|
end
|
|
53
73
|
|
|
74
|
+
# Globs follow .gitignore-style expectations: `*` stays within
|
|
75
|
+
# one directory level, `dir/**` covers the whole subtree, and a
|
|
76
|
+
# leading `./` is tolerated.
|
|
54
77
|
def excluded?(relative_path)
|
|
55
|
-
exclude.any? do |
|
|
56
|
-
|
|
78
|
+
exclude.any? do |raw|
|
|
79
|
+
pattern = raw.delete_prefix("./")
|
|
80
|
+
next true if File.fnmatch?(pattern, relative_path,
|
|
81
|
+
File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
57
82
|
|
|
58
83
|
prefix = pattern[%r{\A(.+?)/\*\*(?:/\*+)?\z}, 1]
|
|
59
84
|
prefix && relative_path.start_with?("#{prefix}/")
|
data/lib/audition/directives.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
3
5
|
module Audition
|
|
4
6
|
# Same-line suppression pragmas:
|
|
5
7
|
#
|
|
@@ -8,9 +10,11 @@ module Audition
|
|
|
8
10
|
#
|
|
9
11
|
# A bare pragma silences every check on that line; otherwise only
|
|
10
12
|
# the listed check names. Applied to any finding carrying a real
|
|
11
|
-
# path and line, including runtime findings.
|
|
13
|
+
# path and line, including runtime findings. Pragmas are read
|
|
14
|
+
# from Prism's comment list, never from raw lines: pragma-shaped
|
|
15
|
+
# text inside a string literal is data, not a directive.
|
|
12
16
|
class Directives
|
|
13
|
-
PATTERN = /#\s*audition:disable\b[ \t]*(?<list>[
|
|
17
|
+
PATTERN = /#\s*audition:disable\b[ \t]*(?<list>[\w \t,-]*)/
|
|
14
18
|
|
|
15
19
|
def initialize
|
|
16
20
|
@by_path = {}
|
|
@@ -37,16 +41,30 @@ module Audition
|
|
|
37
41
|
@by_path[path] ||= scan(path)
|
|
38
42
|
end
|
|
39
43
|
|
|
44
|
+
# Every pragma comment on a line contributes; an explicit
|
|
45
|
+
# disable is never shadowed by an earlier pragma, and a bare
|
|
46
|
+
# pragma (empty list) wins over any list.
|
|
40
47
|
def scan(path)
|
|
41
48
|
return {} unless File.file?(path)
|
|
42
49
|
|
|
43
50
|
directives = {}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
Prism.parse_file(path).comments.each do |comment|
|
|
52
|
+
comment.slice.scan(PATTERN) do
|
|
53
|
+
line = comment.location.start_line
|
|
54
|
+
checks = Regexp.last_match[:list]
|
|
55
|
+
.split(/[,\s]+/).reject(&:empty?)
|
|
56
|
+
if directives.key?(line)
|
|
57
|
+
existing = directives[line]
|
|
58
|
+
directives[line] =
|
|
59
|
+
if existing.empty? || checks.empty?
|
|
60
|
+
[]
|
|
61
|
+
else
|
|
62
|
+
existing | checks
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
directives[line] = checks
|
|
66
|
+
end
|
|
67
|
+
end
|
|
50
68
|
end
|
|
51
69
|
directives
|
|
52
70
|
rescue SystemCallError
|
|
@@ -55,13 +55,27 @@ module AuditionHarness
|
|
|
55
55
|
end
|
|
56
56
|
out.puts(JSON.generate(result))
|
|
57
57
|
rescue Exception => e
|
|
58
|
-
|
|
58
|
+
begin
|
|
59
|
+
out.puts(JSON.generate("error" => describe_error(e)))
|
|
60
|
+
rescue Exception
|
|
61
|
+
out.puts('{"error":{"class":"HarnessFailure",' \
|
|
62
|
+
'"message":"unreportable error"}}')
|
|
63
|
+
end
|
|
59
64
|
end
|
|
60
65
|
|
|
66
|
+
# Exception messages can carry arbitrary bytes (C extensions,
|
|
67
|
+
# binary filenames); unscrubbed they blow up JSON.generate
|
|
68
|
+
# inside the rescue and the harness dies without output.
|
|
61
69
|
def describe_error(error)
|
|
62
70
|
root = unwrap(error)
|
|
63
|
-
{"class" => root.class.name,
|
|
64
|
-
"message" => root.message.to_s[0, 500]}
|
|
71
|
+
{"class" => scrub(root.class.name.to_s),
|
|
72
|
+
"message" => scrub(root.message.to_s)[0, 500]}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def scrub(text)
|
|
76
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
77
|
+
rescue Exception
|
|
78
|
+
"(unprintable)"
|
|
65
79
|
end
|
|
66
80
|
|
|
67
81
|
def unwrap(error)
|
|
@@ -111,7 +125,17 @@ module AuditionHarness
|
|
|
111
125
|
$LOAD_PATH.unshift(lp) # audition:disable global-variables
|
|
112
126
|
end
|
|
113
127
|
before = Object.constants
|
|
114
|
-
|
|
128
|
+
feature = payload.fetch("feature")
|
|
129
|
+
begin
|
|
130
|
+
require feature # audition:disable runtime-require
|
|
131
|
+
rescue LoadError
|
|
132
|
+
# Dashed gem names conventionally ship slashed entry files
|
|
133
|
+
# (rspec-mocks provides rspec/mocks).
|
|
134
|
+
slashed = feature.tr("-", "/")
|
|
135
|
+
raise if slashed == feature
|
|
136
|
+
|
|
137
|
+
require slashed # audition:disable runtime-require
|
|
138
|
+
end
|
|
115
139
|
scan(Object.constants - before, root: payload["root"])
|
|
116
140
|
end
|
|
117
141
|
|
|
@@ -192,7 +216,9 @@ module AuditionHarness
|
|
|
192
216
|
rescue Exception
|
|
193
217
|
nil
|
|
194
218
|
end
|
|
195
|
-
|
|
219
|
+
# The separator matters: /x/app must not claim /x/app-helpers.
|
|
220
|
+
own = root.nil? || path.nil? || path == root ||
|
|
221
|
+
path.start_with?(root + File::SEPARATOR)
|
|
196
222
|
{"path" => path, "line" => line, "own" => own}
|
|
197
223
|
end
|
|
198
224
|
|