audition 0.2.4 → 0.4.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 +175 -39
- data/lib/audition/bundle_sweep.rb +32 -15
- data/lib/audition/cli.rb +181 -100
- data/lib/audition/config.rb +33 -7
- data/lib/audition/dynamic/harness.rb +323 -31
- data/lib/audition/dynamic/prober.rb +190 -43
- data/lib/audition/finding.rb +10 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/report/github.rb +69 -0
- data/lib/audition/report/json.rb +68 -0
- data/lib/audition/report/style.rb +74 -0
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +160 -0
- data/lib/audition/report.rb +16 -295
- data/lib/audition/rewriters.rb +8 -5
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/mutable_constants.rb +162 -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/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +5 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1053 -12
- data/lib/audition/static/literal_classifier.rb +191 -20
- data/lib/audition/static/native_extensions.rb +175 -0
- data/lib/audition/static/source_file.rb +70 -0
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +147 -14
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +3 -0
- metadata +15 -4
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
|
-
|
|
36
|
-
@stderr.puts(USAGE)
|
|
35
|
+
args = options[:args]
|
|
36
|
+
if args.empty?
|
|
37
|
+
@stderr.puts(style(options, io: @stderr).dim(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)
|
|
@@ -45,7 +53,7 @@ module Audition
|
|
|
45
53
|
audit(target, options)
|
|
46
54
|
end
|
|
47
55
|
rescue Error => e
|
|
48
|
-
|
|
56
|
+
complain(e.message, options)
|
|
49
57
|
2
|
|
50
58
|
end
|
|
51
59
|
|
|
@@ -57,7 +65,7 @@ module Audition
|
|
|
57
65
|
dynamic_only: false, fix: false, unsafe: false,
|
|
58
66
|
dry_run: false, capabilities: false, plain: false,
|
|
59
67
|
timeout: 30, write_baseline: false, no_baseline: false,
|
|
60
|
-
deps: false, explicit: []
|
|
68
|
+
deps: false, progress: nil, workers: nil, explicit: []
|
|
61
69
|
}
|
|
62
70
|
parser = OptionParser.new do |o|
|
|
63
71
|
o.banner = USAGE
|
|
@@ -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
|
|
@@ -114,9 +129,20 @@ module Audition
|
|
|
114
129
|
options[:timeout] = v
|
|
115
130
|
options[:explicit] << :timeout
|
|
116
131
|
end
|
|
132
|
+
o.on("-j", "--workers COUNT", Integer,
|
|
133
|
+
"scan Ractors (default: cores, capped by " \
|
|
134
|
+
"RUBY_MAX_CPU)") do |v|
|
|
135
|
+
options[:workers] = v
|
|
136
|
+
options[:explicit] << :workers
|
|
137
|
+
end
|
|
117
138
|
o.on("--plain", "disable colors and hyperlinks") do
|
|
118
139
|
options[:plain] = true
|
|
119
140
|
end
|
|
141
|
+
o.on("--[no-]progress",
|
|
142
|
+
"narrate scan phases on stderr (default: on for a " \
|
|
143
|
+
"large tree on a terminal)") do |v|
|
|
144
|
+
options[:progress] = v
|
|
145
|
+
end
|
|
120
146
|
o.on("-v", "--version") do
|
|
121
147
|
@stdout.puts(VERSION)
|
|
122
148
|
return 0
|
|
@@ -129,11 +155,19 @@ module Audition
|
|
|
129
155
|
options[:args] = parser.parse(argv)
|
|
130
156
|
options
|
|
131
157
|
rescue OptionParser::ParseError => e
|
|
132
|
-
|
|
133
|
-
@stderr.puts(USAGE)
|
|
158
|
+
complain(e.message)
|
|
159
|
+
@stderr.puts(style(nil, io: @stderr).dim(USAGE))
|
|
134
160
|
2
|
|
135
161
|
end
|
|
136
162
|
|
|
163
|
+
def complain(message, options = nil)
|
|
164
|
+
s = style(options, io: @stderr)
|
|
165
|
+
@stderr.puts(
|
|
166
|
+
"#{s.red(s.glyph(:error))} #{s.bold("Audition")}: " \
|
|
167
|
+
"#{message}"
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
137
171
|
def audit(target, options)
|
|
138
172
|
# Read the comparison report up front: a missing or broken
|
|
139
173
|
# file should be a fast usage error, not a post-audit crash.
|
|
@@ -143,14 +177,16 @@ module Audition
|
|
|
143
177
|
directives = Directives.new
|
|
144
178
|
findings = []
|
|
145
179
|
unless options[:dynamic_only]
|
|
146
|
-
findings = filter(static_findings(target, config),
|
|
180
|
+
findings = filter(static_findings(target, config, options),
|
|
147
181
|
directives, config)
|
|
148
182
|
findings = run_fix(target, findings, options) if options[:fix]
|
|
149
183
|
end
|
|
150
184
|
|
|
151
185
|
results = []
|
|
152
186
|
if target.entry && !options[:static_only]
|
|
153
|
-
results << prober(options).probe(
|
|
187
|
+
results << prober(options).probe(
|
|
188
|
+
target.entry.merge(compiled_files: target.compiled_files)
|
|
189
|
+
)
|
|
154
190
|
end
|
|
155
191
|
|
|
156
192
|
all = findings +
|
|
@@ -158,9 +194,11 @@ module Audition
|
|
|
158
194
|
|
|
159
195
|
if options[:write_baseline]
|
|
160
196
|
recorded = Baseline.write(target.root, all)
|
|
197
|
+
s = style(options)
|
|
161
198
|
@stdout.puts(
|
|
162
|
-
"
|
|
163
|
-
"#{
|
|
199
|
+
"#{s.green(s.glyph(:pass))} baseline written: " \
|
|
200
|
+
"#{s.bold("#{recorded} finding(s)")} recorded in " \
|
|
201
|
+
"#{s.cyan(Baseline.path_for(target.root))}"
|
|
164
202
|
)
|
|
165
203
|
return 0
|
|
166
204
|
end
|
|
@@ -222,7 +260,10 @@ module Audition
|
|
|
222
260
|
"#{s.red("#{introduced.size} introduced")}"
|
|
223
261
|
)
|
|
224
262
|
introduced.each do |f|
|
|
225
|
-
@stdout.puts(
|
|
263
|
+
@stdout.puts(
|
|
264
|
+
" #{s.red("+")} #{f.message} " \
|
|
265
|
+
"#{s.cyan("(#{f.location})")}"
|
|
266
|
+
)
|
|
226
267
|
end
|
|
227
268
|
end
|
|
228
269
|
|
|
@@ -258,12 +299,55 @@ module Audition
|
|
|
258
299
|
baseline.filter(findings, root: target.root)
|
|
259
300
|
end
|
|
260
301
|
|
|
261
|
-
def static_findings(target, config)
|
|
302
|
+
def static_findings(target, config, options)
|
|
262
303
|
files = target.ruby_files.reject do |file|
|
|
263
304
|
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
264
305
|
end
|
|
265
|
-
|
|
266
|
-
|
|
306
|
+
compiled = target.compiled_files.reject do |file|
|
|
307
|
+
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
308
|
+
end
|
|
309
|
+
stubs = target.stub_files.reject do |file|
|
|
310
|
+
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
311
|
+
end
|
|
312
|
+
progress = Progress.for(units: files.size,
|
|
313
|
+
wanted: options[:progress], format: options[:format],
|
|
314
|
+
io: @stderr, style: options[:plain] ? plain_style : nil)
|
|
315
|
+
scan(target, files, stubs, compiled, progress,
|
|
316
|
+
workers: options[:workers]).map do |f|
|
|
317
|
+
if target.test_file?(f.path, dirs: config.test_dirs)
|
|
318
|
+
f.with(test: true)
|
|
319
|
+
else
|
|
320
|
+
f
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Phases are named for what a waiting reader wants to know;
|
|
326
|
+
# progress shares stderr with fix chatter, for the same reason.
|
|
327
|
+
def scan(target, files, stubs, compiled, progress, workers: nil)
|
|
328
|
+
progress.phase("learning", total: files.size + stubs.size) do |p|
|
|
329
|
+
Static::Checks::UnshareableReads.learn(files + stubs,
|
|
330
|
+
progress: p)
|
|
331
|
+
Static::Checks::DependencyClassState.learn(stubs)
|
|
332
|
+
end
|
|
333
|
+
per_file = progress.phase("checking", total: files.size) do |p|
|
|
334
|
+
Static::Analyzer.new.analyze_paths(files, workers: workers,
|
|
335
|
+
progress: p)
|
|
336
|
+
end
|
|
337
|
+
gem_calls = progress.phase("gem calls") do |p|
|
|
338
|
+
Static::GemCalls.new(root: target.root, stubs: stubs)
|
|
339
|
+
.analyze_paths(files, progress: p)
|
|
340
|
+
end
|
|
341
|
+
graph = progress.phase("graph") do |p|
|
|
342
|
+
Static::GraphAudit.new.analyze_paths(files,
|
|
343
|
+
constant_findings: per_file + gem_calls,
|
|
344
|
+
workers: workers, progress: p)
|
|
345
|
+
end
|
|
346
|
+
per_file + gem_calls + graph +
|
|
347
|
+
Static::NativeExtensions.new.analyze(target,
|
|
348
|
+
compiled_files: compiled)
|
|
349
|
+
ensure
|
|
350
|
+
progress.finish
|
|
267
351
|
end
|
|
268
352
|
|
|
269
353
|
# Fix chatter goes to stderr: stdout carries the report, which
|
|
@@ -277,16 +361,19 @@ module Audition
|
|
|
277
361
|
|
|
278
362
|
applied = fixer.apply(findings)
|
|
279
363
|
total = applied.values.sum
|
|
364
|
+
s = style(options, io: @stderr)
|
|
280
365
|
if total.zero?
|
|
281
|
-
@stderr.puts("nothing to fix")
|
|
366
|
+
@stderr.puts(s.dim("nothing to fix"))
|
|
282
367
|
return findings
|
|
283
368
|
end
|
|
284
369
|
|
|
285
370
|
@stderr.puts(
|
|
286
|
-
"
|
|
371
|
+
"#{s.cyan(s.glyph(:fix))} #{s.green("fixed #{total} " \
|
|
372
|
+
"finding(s)")} in #{s.bold("#{applied.size} file(s)")}"
|
|
287
373
|
)
|
|
288
|
-
|
|
289
|
-
|
|
374
|
+
config = Config.load(target.root)
|
|
375
|
+
filter(static_findings(target, config, options),
|
|
376
|
+
Directives.new, config)
|
|
290
377
|
end
|
|
291
378
|
|
|
292
379
|
def render_preview(previews, options)
|
|
@@ -294,7 +381,7 @@ module Audition
|
|
|
294
381
|
previews.each do |preview|
|
|
295
382
|
@stderr.puts(s.bold(preview[:path]))
|
|
296
383
|
preview[:hunks].each do |hunk|
|
|
297
|
-
@stderr.puts(" @ line #{hunk[:line]}")
|
|
384
|
+
@stderr.puts(s.cyan(" @ line #{hunk[:line]}"))
|
|
298
385
|
hunk[:old].each_line do |line|
|
|
299
386
|
@stderr.puts(s.red(" - #{line.chomp}"))
|
|
300
387
|
end
|
|
@@ -303,7 +390,7 @@ module Audition
|
|
|
303
390
|
end
|
|
304
391
|
end
|
|
305
392
|
end
|
|
306
|
-
@stderr.puts("dry run: no files were changed")
|
|
393
|
+
@stderr.puts(s.dim("dry run: no files were changed"))
|
|
307
394
|
end
|
|
308
395
|
|
|
309
396
|
def prober(options)
|
|
@@ -312,25 +399,54 @@ module Audition
|
|
|
312
399
|
|
|
313
400
|
def emit(report, options)
|
|
314
401
|
case options[:format]
|
|
315
|
-
when :json
|
|
316
|
-
|
|
317
|
-
|
|
402
|
+
when :json
|
|
403
|
+
@stdout.puts(Report::Json.new(report).render)
|
|
404
|
+
when :github
|
|
405
|
+
github = Report::Github.new(report)
|
|
406
|
+
@stdout.puts(github.render)
|
|
407
|
+
append_step_summary(github.summary)
|
|
408
|
+
else
|
|
409
|
+
@stdout.puts(
|
|
410
|
+
Report::Text.new(report, style(options)).render
|
|
411
|
+
)
|
|
318
412
|
end
|
|
319
413
|
end
|
|
320
414
|
|
|
415
|
+
# GitHub Actions exposes the job summary as an appendable
|
|
416
|
+
# file; outside Actions the variable is absent and this is a
|
|
417
|
+
# no-op. A broken path must not fail the audit itself.
|
|
418
|
+
def append_step_summary(markdown)
|
|
419
|
+
path = ENV["GITHUB_STEP_SUMMARY"]
|
|
420
|
+
return if path.nil? || path.empty?
|
|
421
|
+
|
|
422
|
+
File.open(path, "a") { |f| f.puts(markdown) }
|
|
423
|
+
rescue SystemCallError => e
|
|
424
|
+
complain("cannot write step summary: #{e.message}")
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Diagnostics can precede option parsing, so --plain is
|
|
428
|
+
# honored only once there is something to honor it from.
|
|
321
429
|
def style(options, io: @stdout)
|
|
322
|
-
if options
|
|
323
|
-
|
|
430
|
+
if options&.fetch(:plain, false)
|
|
431
|
+
plain_style
|
|
324
432
|
else
|
|
325
433
|
Report::Style.detect(io: io)
|
|
326
434
|
end
|
|
327
435
|
end
|
|
328
436
|
|
|
437
|
+
def plain_style
|
|
438
|
+
Report::Style.new(color: false, hyperlinks: false)
|
|
439
|
+
end
|
|
440
|
+
|
|
329
441
|
def exit_code(report, options)
|
|
442
|
+
return 0 if options[:fail_on] == :never
|
|
443
|
+
|
|
330
444
|
threshold = SEVERITIES.fetch(options[:fail_on])
|
|
445
|
+
# Test findings never load in a production boot; they are
|
|
446
|
+
# reported but do not fail the run, matching the verdict.
|
|
331
447
|
failed =
|
|
332
448
|
report.findings.any? do |f|
|
|
333
|
-
f.severity_rank >= threshold
|
|
449
|
+
f.severity_rank >= threshold && !f.test?
|
|
334
450
|
end || report.dynamic_results.any? { |r| !r.passed }
|
|
335
451
|
failed ? 1 : 0
|
|
336
452
|
end
|
|
@@ -346,28 +462,41 @@ module Audition
|
|
|
346
462
|
Target.detect(lockfile)
|
|
347
463
|
end
|
|
348
464
|
|
|
349
|
-
VERDICT_CELLS = {
|
|
350
|
-
:not_ready => "not ready", :blocked => "blocked",
|
|
351
|
-
:risky => "risky", :ready => "ready", nil => "-"
|
|
352
|
-
}.freeze
|
|
353
|
-
|
|
354
465
|
def sweep(target, options)
|
|
355
466
|
sweeper = BundleSweep.new(
|
|
356
467
|
lockfile: target.entry[:lockfile],
|
|
357
468
|
static_only: options[:static_only],
|
|
358
469
|
timeout: options[:timeout]
|
|
359
470
|
)
|
|
360
|
-
rows = sweeper
|
|
471
|
+
rows = sweep_rows(sweeper, options)
|
|
472
|
+
emit_sweep(Report::Sweep.new(rows, style(options)), options)
|
|
473
|
+
return 0 if options[:fail_on] == :never
|
|
361
474
|
|
|
362
|
-
if options[:format] == :json
|
|
363
|
-
emit_sweep_json(rows)
|
|
364
|
-
else
|
|
365
|
-
emit_sweep_table(rows, options)
|
|
366
|
-
end
|
|
367
475
|
threshold = SEVERITIES.fetch(options[:fail_on])
|
|
368
476
|
(rows.any? { |r| row_failed?(r, threshold) }) ? 1 : 0
|
|
369
477
|
end
|
|
370
478
|
|
|
479
|
+
# No unit count: every gem is a scan of its own, so even a
|
|
480
|
+
# short lockfile runs long enough to narrate.
|
|
481
|
+
def sweep_rows(sweeper, options)
|
|
482
|
+
progress = Progress.for(wanted: options[:progress],
|
|
483
|
+
format: options[:format], io: @stderr,
|
|
484
|
+
style: options[:plain] ? plain_style : nil)
|
|
485
|
+
sweeper.rows(progress: progress)
|
|
486
|
+
ensure
|
|
487
|
+
progress.finish
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def emit_sweep(sweep, options)
|
|
491
|
+
case options[:format]
|
|
492
|
+
when :json then @stdout.puts(sweep.json)
|
|
493
|
+
when :github
|
|
494
|
+
@stdout.puts(sweep.annotations)
|
|
495
|
+
append_step_summary(sweep.markdown)
|
|
496
|
+
else @stdout.puts(sweep.render(**table_opts(options)))
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
371
500
|
# Same contract as a direct audit: a row fails when it carries
|
|
372
501
|
# findings at or above --fail-on; not-ready rows always fail.
|
|
373
502
|
def row_failed?(row, threshold)
|
|
@@ -379,14 +508,6 @@ module Audition
|
|
|
379
508
|
hits.positive?
|
|
380
509
|
end
|
|
381
510
|
|
|
382
|
-
def sweep_progress
|
|
383
|
-
return nil unless @stderr.respond_to?(:tty?) && @stderr.tty?
|
|
384
|
-
|
|
385
|
-
lambda do |row, done, total|
|
|
386
|
-
@stderr.puts("audited #{row.name} (#{done}/#{total})")
|
|
387
|
-
end
|
|
388
|
-
end
|
|
389
|
-
|
|
390
511
|
# Cells arrive preformatted: coercion would render a version
|
|
391
512
|
# like "3.2" as 3.200. Color follows the CLI's own detection
|
|
392
513
|
# because the table gem reads the global $stdout and cannot
|
|
@@ -399,69 +520,29 @@ module Audition
|
|
|
399
520
|
}
|
|
400
521
|
end
|
|
401
522
|
|
|
402
|
-
def emit_sweep_table(rows, options)
|
|
403
|
-
ready = rows.count { |r| r.verdict == :ready }
|
|
404
|
-
table = rows.map do |r|
|
|
405
|
-
{
|
|
406
|
-
"gem" => r.name,
|
|
407
|
-
"version" => r.version,
|
|
408
|
-
"verdict" => VERDICT_CELLS.fetch(r.verdict),
|
|
409
|
-
"errors" => r.errors,
|
|
410
|
-
"dep errors" => r.dep_errors,
|
|
411
|
-
"warnings" => r.warnings,
|
|
412
|
-
"fixable" => r.fixable,
|
|
413
|
-
"status" => r.status
|
|
414
|
-
}
|
|
415
|
-
end
|
|
416
|
-
@stdout.puts(TableTennis.new(
|
|
417
|
-
table, zebra: true, **table_opts(options)
|
|
418
|
-
).to_s)
|
|
419
|
-
@stdout.puts(
|
|
420
|
-
"#{ready} of #{rows.size} gems ractor-ready"
|
|
421
|
-
)
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
def emit_sweep_json(rows)
|
|
425
|
-
@stdout.puts(JSON.pretty_generate(
|
|
426
|
-
"audition" => VERSION,
|
|
427
|
-
"ruby" => RUBY_VERSION,
|
|
428
|
-
"bundle" => rows.map do |r|
|
|
429
|
-
{
|
|
430
|
-
"gem" => r.name,
|
|
431
|
-
"version" => r.version,
|
|
432
|
-
"verdict" => r.verdict&.to_s,
|
|
433
|
-
"errors" => r.errors,
|
|
434
|
-
"dependency_errors" => r.dep_errors,
|
|
435
|
-
"warnings" => r.warnings,
|
|
436
|
-
"infos" => r.infos,
|
|
437
|
-
"fixable" => r.fixable,
|
|
438
|
-
"status" => r.status
|
|
439
|
-
}
|
|
440
|
-
end
|
|
441
|
-
))
|
|
442
|
-
end
|
|
443
|
-
|
|
444
523
|
def print_capabilities(options)
|
|
445
524
|
result = prober(options).probe(mode: :capabilities)
|
|
446
525
|
caps = result.raw["capabilities"]
|
|
447
526
|
unless caps
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
)
|
|
527
|
+
complain("capabilities probe failed: #{result.raw}",
|
|
528
|
+
options)
|
|
451
529
|
return 2
|
|
452
530
|
end
|
|
453
531
|
|
|
532
|
+
s = style(options)
|
|
454
533
|
rows = caps.map do |probe, info|
|
|
455
534
|
{
|
|
456
535
|
"works in Ractor" => probe,
|
|
457
|
-
"ok" => info["ok"] ?
|
|
458
|
-
"raises" => info["error"]
|
|
536
|
+
"ok" => s.glyph(info["ok"] ? :pass : :error),
|
|
537
|
+
"raises" => info["error"]
|
|
459
538
|
}
|
|
460
539
|
end
|
|
461
|
-
@stdout.puts("ruby #{RUBY_VERSION} at #{RbConfig.ruby}")
|
|
462
540
|
@stdout.puts(
|
|
463
|
-
|
|
541
|
+
"#{s.glyph(:section)} #{s.bold("ruby #{RUBY_VERSION}")} " \
|
|
542
|
+
"#{s.dim("at #{RbConfig.ruby}")}"
|
|
464
543
|
)
|
|
544
|
+
@stdout.puts(TableTennis.new(rows,
|
|
545
|
+
title: "Audition capabilities", **table_opts(options)).to_s)
|
|
465
546
|
0
|
|
466
547
|
end
|
|
467
548
|
end
|
data/lib/audition/config.rb
CHANGED
|
@@ -10,6 +10,8 @@ module Audition
|
|
|
10
10
|
# exclude:
|
|
11
11
|
# - legacy/**
|
|
12
12
|
# - db/schema.rb
|
|
13
|
+
# test_dirs:
|
|
14
|
+
# - qa
|
|
13
15
|
# checks:
|
|
14
16
|
# disable:
|
|
15
17
|
# - at-exit
|
|
@@ -20,10 +22,10 @@ module Audition
|
|
|
20
22
|
|
|
21
23
|
EMPTY = Ractor.make_shareable(
|
|
22
24
|
{fail_on: nil, timeout: nil, exclude: [],
|
|
23
|
-
disabled_checks: []}
|
|
25
|
+
disabled_checks: [], test_dirs: nil}
|
|
24
26
|
)
|
|
25
27
|
|
|
26
|
-
FAIL_ON_LEVELS = %w[error warning info].freeze
|
|
28
|
+
FAIL_ON_LEVELS = %w[error warning info never].freeze
|
|
27
29
|
|
|
28
30
|
attr_reader :fail_on, :timeout, :exclude, :disabled_checks
|
|
29
31
|
|
|
@@ -42,7 +44,8 @@ module Audition
|
|
|
42
44
|
timeout: data["timeout"],
|
|
43
45
|
exclude: Array(data["exclude"]).map(&:to_s),
|
|
44
46
|
disabled_checks:
|
|
45
|
-
Array(data.dig("checks", "disable")).map(&:to_s)
|
|
47
|
+
Array(data.dig("checks", "disable")).map(&:to_s),
|
|
48
|
+
test_dirs: data["test_dirs"]&.map(&:to_s)
|
|
46
49
|
)
|
|
47
50
|
rescue Psych::Exception => e
|
|
48
51
|
raise Error, "#{path}: #{e.message}"
|
|
@@ -54,21 +57,44 @@ module Audition
|
|
|
54
57
|
"#{path}: expected a YAML mapping, got #{data.class}"
|
|
55
58
|
end
|
|
56
59
|
|
|
60
|
+
validate_test_dirs!(path, data["test_dirs"])
|
|
57
61
|
fail_on = data["fail_on"]
|
|
58
62
|
return if fail_on.nil? ||
|
|
59
63
|
FAIL_ON_LEVELS.include?(fail_on.to_s)
|
|
60
64
|
|
|
61
65
|
raise Error,
|
|
62
|
-
"#{path}: fail_on must be one of error, warning,
|
|
63
|
-
"info (got #{fail_on.inspect})"
|
|
66
|
+
"#{path}: fail_on must be one of error, warning, " \
|
|
67
|
+
"info, or never (got #{fail_on.inspect})"
|
|
64
68
|
end
|
|
65
|
-
private_class_method :validate!
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
# Each name is matched against one path segment, so a nested
|
|
71
|
+
# path would silently match nothing.
|
|
72
|
+
def self.validate_test_dirs!(path, dirs)
|
|
73
|
+
return if dirs.nil? || (dirs.is_a?(Array) &&
|
|
74
|
+
dirs.none? { |dir| dir.to_s.include?("/") })
|
|
75
|
+
|
|
76
|
+
raise Error,
|
|
77
|
+
"#{path}: test_dirs must be a list of directory names " \
|
|
78
|
+
"without slashes (got #{dirs.inspect})"
|
|
79
|
+
end
|
|
80
|
+
private_class_method :validate!, :validate_test_dirs!
|
|
81
|
+
|
|
82
|
+
def initialize(fail_on:, timeout:, exclude:, disabled_checks:,
|
|
83
|
+
test_dirs: nil)
|
|
68
84
|
@fail_on = fail_on
|
|
69
85
|
@timeout = timeout
|
|
70
86
|
@exclude = exclude
|
|
71
87
|
@disabled_checks = disabled_checks
|
|
88
|
+
@test_dirs = test_dirs
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Directories whose findings are tagged as test code rather
|
|
92
|
+
# than code a production boot loads. An explicit empty list
|
|
93
|
+
# leaves only the `_test.rb`/`_spec.rb` suffixes.
|
|
94
|
+
#
|
|
95
|
+
# @return [Array<String>] Target::TEST_DIRS unless configured
|
|
96
|
+
def test_dirs
|
|
97
|
+
@test_dirs || Target::TEST_DIRS
|
|
72
98
|
end
|
|
73
99
|
|
|
74
100
|
# Globs follow .gitignore-style expectations: `*` stays within
|