audition 0.3.0 → 0.4.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 +125 -49
- data/lib/audition/bundle_sweep.rb +25 -15
- data/lib/audition/cli.rb +128 -140
- data/lib/audition/config.rb +30 -4
- data/lib/audition/dynamic/harness.rb +415 -41
- data/lib/audition/dynamic/prober.rb +271 -54
- data/lib/audition/finding.rb +14 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/reconciliation.rb +86 -0
- data/lib/audition/report/github.rb +4 -3
- data/lib/audition/report/json.rb +5 -1
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +13 -2
- data/lib/audition/report.rb +8 -1
- data/lib/audition/rewriters.rb +29 -2
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/instance_memoization.rb +226 -0
- data/lib/audition/static/checks/mutable_constants.rb +172 -9
- data/lib/audition/static/checks/ractor_isolation.rb +214 -15
- data/lib/audition/static/checks/unsafe_calls.rb +7 -4
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +6 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1113 -15
- data/lib/audition/static/literal_classifier.rb +385 -20
- data/lib/audition/static/native_extensions.rb +28 -16
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +82 -13
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +3 -0
- metadata +12 -4
data/lib/audition/cli.rb
CHANGED
|
@@ -34,7 +34,7 @@ module Audition
|
|
|
34
34
|
|
|
35
35
|
args = options[:args]
|
|
36
36
|
if args.empty?
|
|
37
|
-
@stderr.puts(USAGE)
|
|
37
|
+
@stderr.puts(style(options, io: @stderr).dim(USAGE))
|
|
38
38
|
return 2
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -53,7 +53,7 @@ module Audition
|
|
|
53
53
|
audit(target, options)
|
|
54
54
|
end
|
|
55
55
|
rescue Error => e
|
|
56
|
-
|
|
56
|
+
complain(e.message, options)
|
|
57
57
|
2
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -65,7 +65,7 @@ module Audition
|
|
|
65
65
|
dynamic_only: false, fix: false, unsafe: false,
|
|
66
66
|
dry_run: false, capabilities: false, plain: false,
|
|
67
67
|
timeout: 30, write_baseline: false, no_baseline: false,
|
|
68
|
-
deps: false, explicit: []
|
|
68
|
+
deps: false, progress: nil, workers: nil, explicit: []
|
|
69
69
|
}
|
|
70
70
|
parser = OptionParser.new do |o|
|
|
71
71
|
o.banner = USAGE
|
|
@@ -129,9 +129,20 @@ module Audition
|
|
|
129
129
|
options[:timeout] = v
|
|
130
130
|
options[:explicit] << :timeout
|
|
131
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
|
|
132
138
|
o.on("--plain", "disable colors and hyperlinks") do
|
|
133
139
|
options[:plain] = true
|
|
134
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
|
|
135
146
|
o.on("-v", "--version") do
|
|
136
147
|
@stdout.puts(VERSION)
|
|
137
148
|
return 0
|
|
@@ -144,11 +155,19 @@ module Audition
|
|
|
144
155
|
options[:args] = parser.parse(argv)
|
|
145
156
|
options
|
|
146
157
|
rescue OptionParser::ParseError => e
|
|
147
|
-
|
|
148
|
-
@stderr.puts(USAGE)
|
|
158
|
+
complain(e.message)
|
|
159
|
+
@stderr.puts(style(nil, io: @stderr).dim(USAGE))
|
|
149
160
|
2
|
|
150
161
|
end
|
|
151
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
|
+
|
|
152
171
|
def audit(target, options)
|
|
153
172
|
# Read the comparison report up front: a missing or broken
|
|
154
173
|
# file should be a fast usage error, not a post-audit crash.
|
|
@@ -158,7 +177,7 @@ module Audition
|
|
|
158
177
|
directives = Directives.new
|
|
159
178
|
findings = []
|
|
160
179
|
unless options[:dynamic_only]
|
|
161
|
-
findings = filter(static_findings(target, config),
|
|
180
|
+
findings = filter(static_findings(target, config, options),
|
|
162
181
|
directives, config)
|
|
163
182
|
findings = run_fix(target, findings, options) if options[:fix]
|
|
164
183
|
end
|
|
@@ -170,14 +189,17 @@ module Audition
|
|
|
170
189
|
)
|
|
171
190
|
end
|
|
172
191
|
|
|
192
|
+
findings = Reconciliation.apply(findings, results)
|
|
173
193
|
all = findings +
|
|
174
194
|
filter(results.flat_map(&:findings), directives, config)
|
|
175
195
|
|
|
176
196
|
if options[:write_baseline]
|
|
177
197
|
recorded = Baseline.write(target.root, all)
|
|
198
|
+
s = style(options)
|
|
178
199
|
@stdout.puts(
|
|
179
|
-
"
|
|
180
|
-
"#{
|
|
200
|
+
"#{s.green(s.glyph(:pass))} baseline written: " \
|
|
201
|
+
"#{s.bold("#{recorded} finding(s)")} recorded in " \
|
|
202
|
+
"#{s.cyan(Baseline.path_for(target.root))}"
|
|
181
203
|
)
|
|
182
204
|
return 0
|
|
183
205
|
end
|
|
@@ -239,7 +261,10 @@ module Audition
|
|
|
239
261
|
"#{s.red("#{introduced.size} introduced")}"
|
|
240
262
|
)
|
|
241
263
|
introduced.each do |f|
|
|
242
|
-
@stdout.puts(
|
|
264
|
+
@stdout.puts(
|
|
265
|
+
" #{s.red("+")} #{f.message} " \
|
|
266
|
+
"#{s.cyan("(#{f.location})")}"
|
|
267
|
+
)
|
|
243
268
|
end
|
|
244
269
|
end
|
|
245
270
|
|
|
@@ -275,17 +300,55 @@ module Audition
|
|
|
275
300
|
baseline.filter(findings, root: target.root)
|
|
276
301
|
end
|
|
277
302
|
|
|
278
|
-
def static_findings(target, config)
|
|
303
|
+
def static_findings(target, config, options)
|
|
279
304
|
files = target.ruby_files.reject do |file|
|
|
280
305
|
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
281
306
|
end
|
|
282
307
|
compiled = target.compiled_files.reject do |file|
|
|
283
308
|
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
284
309
|
end
|
|
285
|
-
|
|
286
|
-
|
|
310
|
+
stubs = target.stub_files.reject do |file|
|
|
311
|
+
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
312
|
+
end
|
|
313
|
+
progress = Progress.for(units: files.size,
|
|
314
|
+
wanted: options[:progress], format: options[:format],
|
|
315
|
+
io: @stderr, style: options[:plain] ? plain_style : nil)
|
|
316
|
+
scan(target, files, stubs, compiled, progress,
|
|
317
|
+
workers: options[:workers]).map do |f|
|
|
318
|
+
if target.test_file?(f.path, dirs: config.test_dirs)
|
|
319
|
+
f.with(test: true)
|
|
320
|
+
else
|
|
321
|
+
f
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Phases are named for what a waiting reader wants to know;
|
|
327
|
+
# progress shares stderr with fix chatter, for the same reason.
|
|
328
|
+
def scan(target, files, stubs, compiled, progress, workers: nil)
|
|
329
|
+
progress.phase("learning", total: files.size + stubs.size) do |p|
|
|
330
|
+
Static::Checks::UnshareableReads.learn(files + stubs,
|
|
331
|
+
progress: p)
|
|
332
|
+
Static::Checks::DependencyClassState.learn(stubs)
|
|
333
|
+
end
|
|
334
|
+
per_file = progress.phase("checking", total: files.size) do |p|
|
|
335
|
+
Static::Analyzer.new.analyze_paths(files, workers: workers,
|
|
336
|
+
progress: p)
|
|
337
|
+
end
|
|
338
|
+
gem_calls = progress.phase("gem calls") do |p|
|
|
339
|
+
Static::GemCalls.new(root: target.root, stubs: stubs)
|
|
340
|
+
.analyze_paths(files, progress: p)
|
|
341
|
+
end
|
|
342
|
+
graph = progress.phase("graph") do |p|
|
|
343
|
+
Static::GraphAudit.new.analyze_paths(files,
|
|
344
|
+
constant_findings: per_file + gem_calls,
|
|
345
|
+
workers: workers, progress: p)
|
|
346
|
+
end
|
|
347
|
+
per_file + gem_calls + graph +
|
|
287
348
|
Static::NativeExtensions.new.analyze(target,
|
|
288
349
|
compiled_files: compiled)
|
|
350
|
+
ensure
|
|
351
|
+
progress.finish
|
|
289
352
|
end
|
|
290
353
|
|
|
291
354
|
# Fix chatter goes to stderr: stdout carries the report, which
|
|
@@ -299,16 +362,19 @@ module Audition
|
|
|
299
362
|
|
|
300
363
|
applied = fixer.apply(findings)
|
|
301
364
|
total = applied.values.sum
|
|
365
|
+
s = style(options, io: @stderr)
|
|
302
366
|
if total.zero?
|
|
303
|
-
@stderr.puts("nothing to fix")
|
|
367
|
+
@stderr.puts(s.dim("nothing to fix"))
|
|
304
368
|
return findings
|
|
305
369
|
end
|
|
306
370
|
|
|
307
371
|
@stderr.puts(
|
|
308
|
-
"
|
|
372
|
+
"#{s.cyan(s.glyph(:fix))} #{s.green("fixed #{total} " \
|
|
373
|
+
"finding(s)")} in #{s.bold("#{applied.size} file(s)")}"
|
|
309
374
|
)
|
|
310
|
-
|
|
311
|
-
|
|
375
|
+
config = Config.load(target.root)
|
|
376
|
+
filter(static_findings(target, config, options),
|
|
377
|
+
Directives.new, config)
|
|
312
378
|
end
|
|
313
379
|
|
|
314
380
|
def render_preview(previews, options)
|
|
@@ -316,7 +382,7 @@ module Audition
|
|
|
316
382
|
previews.each do |preview|
|
|
317
383
|
@stderr.puts(s.bold(preview[:path]))
|
|
318
384
|
preview[:hunks].each do |hunk|
|
|
319
|
-
@stderr.puts(" @ line #{hunk[:line]}")
|
|
385
|
+
@stderr.puts(s.cyan(" @ line #{hunk[:line]}"))
|
|
320
386
|
hunk[:old].each_line do |line|
|
|
321
387
|
@stderr.puts(s.red(" - #{line.chomp}"))
|
|
322
388
|
end
|
|
@@ -325,7 +391,7 @@ module Audition
|
|
|
325
391
|
end
|
|
326
392
|
end
|
|
327
393
|
end
|
|
328
|
-
@stderr.puts("dry run: no files were changed")
|
|
394
|
+
@stderr.puts(s.dim("dry run: no files were changed"))
|
|
329
395
|
end
|
|
330
396
|
|
|
331
397
|
def prober(options)
|
|
@@ -356,26 +422,32 @@ module Audition
|
|
|
356
422
|
|
|
357
423
|
File.open(path, "a") { |f| f.puts(markdown) }
|
|
358
424
|
rescue SystemCallError => e
|
|
359
|
-
|
|
360
|
-
"audition: cannot write step summary: #{e.message}"
|
|
361
|
-
)
|
|
425
|
+
complain("cannot write step summary: #{e.message}")
|
|
362
426
|
end
|
|
363
427
|
|
|
428
|
+
# Diagnostics can precede option parsing, so --plain is
|
|
429
|
+
# honored only once there is something to honor it from.
|
|
364
430
|
def style(options, io: @stdout)
|
|
365
|
-
if options
|
|
366
|
-
|
|
431
|
+
if options&.fetch(:plain, false)
|
|
432
|
+
plain_style
|
|
367
433
|
else
|
|
368
434
|
Report::Style.detect(io: io)
|
|
369
435
|
end
|
|
370
436
|
end
|
|
371
437
|
|
|
438
|
+
def plain_style
|
|
439
|
+
Report::Style.new(color: false, hyperlinks: false)
|
|
440
|
+
end
|
|
441
|
+
|
|
372
442
|
def exit_code(report, options)
|
|
373
443
|
return 0 if options[:fail_on] == :never
|
|
374
444
|
|
|
375
445
|
threshold = SEVERITIES.fetch(options[:fail_on])
|
|
446
|
+
# Test findings never load in a production boot; they are
|
|
447
|
+
# reported but do not fail the run, matching the verdict.
|
|
376
448
|
failed =
|
|
377
449
|
report.findings.any? do |f|
|
|
378
|
-
f.severity_rank >= threshold
|
|
450
|
+
f.severity_rank >= threshold && !f.test?
|
|
379
451
|
end || report.dynamic_results.any? { |r| !r.passed }
|
|
380
452
|
failed ? 1 : 0
|
|
381
453
|
end
|
|
@@ -391,30 +463,41 @@ module Audition
|
|
|
391
463
|
Target.detect(lockfile)
|
|
392
464
|
end
|
|
393
465
|
|
|
394
|
-
VERDICT_CELLS = {
|
|
395
|
-
:not_ready => "not ready", :blocked => "blocked",
|
|
396
|
-
:risky => "risky", :ready => "ready", nil => "-"
|
|
397
|
-
}.freeze
|
|
398
|
-
|
|
399
466
|
def sweep(target, options)
|
|
400
467
|
sweeper = BundleSweep.new(
|
|
401
468
|
lockfile: target.entry[:lockfile],
|
|
402
469
|
static_only: options[:static_only],
|
|
403
470
|
timeout: options[:timeout]
|
|
404
471
|
)
|
|
405
|
-
rows = sweeper
|
|
406
|
-
|
|
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)
|
|
411
|
-
end
|
|
472
|
+
rows = sweep_rows(sweeper, options)
|
|
473
|
+
emit_sweep(Report::Sweep.new(rows, style(options)), options)
|
|
412
474
|
return 0 if options[:fail_on] == :never
|
|
413
475
|
|
|
414
476
|
threshold = SEVERITIES.fetch(options[:fail_on])
|
|
415
477
|
(rows.any? { |r| row_failed?(r, threshold) }) ? 1 : 0
|
|
416
478
|
end
|
|
417
479
|
|
|
480
|
+
# No unit count: every gem is a scan of its own, so even a
|
|
481
|
+
# short lockfile runs long enough to narrate.
|
|
482
|
+
def sweep_rows(sweeper, options)
|
|
483
|
+
progress = Progress.for(wanted: options[:progress],
|
|
484
|
+
format: options[:format], io: @stderr,
|
|
485
|
+
style: options[:plain] ? plain_style : nil)
|
|
486
|
+
sweeper.rows(progress: progress)
|
|
487
|
+
ensure
|
|
488
|
+
progress.finish
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def emit_sweep(sweep, options)
|
|
492
|
+
case options[:format]
|
|
493
|
+
when :json then @stdout.puts(sweep.json)
|
|
494
|
+
when :github
|
|
495
|
+
@stdout.puts(sweep.annotations)
|
|
496
|
+
append_step_summary(sweep.markdown)
|
|
497
|
+
else @stdout.puts(sweep.render(**table_opts(options)))
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
418
501
|
# Same contract as a direct audit: a row fails when it carries
|
|
419
502
|
# findings at or above --fail-on; not-ready rows always fail.
|
|
420
503
|
def row_failed?(row, threshold)
|
|
@@ -426,14 +509,6 @@ module Audition
|
|
|
426
509
|
hits.positive?
|
|
427
510
|
end
|
|
428
511
|
|
|
429
|
-
def sweep_progress
|
|
430
|
-
return nil unless @stderr.respond_to?(:tty?) && @stderr.tty?
|
|
431
|
-
|
|
432
|
-
lambda do |row, done, total|
|
|
433
|
-
@stderr.puts("audited #{row.name} (#{done}/#{total})")
|
|
434
|
-
end
|
|
435
|
-
end
|
|
436
|
-
|
|
437
512
|
# Cells arrive preformatted: coercion would render a version
|
|
438
513
|
# like "3.2" as 3.200. Color follows the CLI's own detection
|
|
439
514
|
# because the table gem reads the global $stdout and cannot
|
|
@@ -446,116 +521,29 @@ module Audition
|
|
|
446
521
|
}
|
|
447
522
|
end
|
|
448
523
|
|
|
449
|
-
def emit_sweep_table(rows, options)
|
|
450
|
-
ready = rows.count { |r| r.verdict == :ready }
|
|
451
|
-
table = rows.map do |r|
|
|
452
|
-
{
|
|
453
|
-
"gem" => r.name,
|
|
454
|
-
"version" => r.version,
|
|
455
|
-
"verdict" => VERDICT_CELLS.fetch(r.verdict),
|
|
456
|
-
"errors" => r.errors,
|
|
457
|
-
"dep errors" => r.dep_errors,
|
|
458
|
-
"warnings" => r.warnings,
|
|
459
|
-
"fixable" => r.fixable,
|
|
460
|
-
"status" => r.status
|
|
461
|
-
}
|
|
462
|
-
end
|
|
463
|
-
@stdout.puts(TableTennis.new(
|
|
464
|
-
table, zebra: true, **table_opts(options)
|
|
465
|
-
).to_s)
|
|
466
|
-
@stdout.puts(
|
|
467
|
-
"#{ready} of #{rows.size} gems ractor-ready"
|
|
468
|
-
)
|
|
469
|
-
end
|
|
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
|
-
|
|
518
|
-
def emit_sweep_json(rows)
|
|
519
|
-
@stdout.puts(JSON.pretty_generate(
|
|
520
|
-
"audition" => VERSION,
|
|
521
|
-
"ruby" => RUBY_VERSION,
|
|
522
|
-
"bundle" => rows.map do |r|
|
|
523
|
-
{
|
|
524
|
-
"gem" => r.name,
|
|
525
|
-
"version" => r.version,
|
|
526
|
-
"verdict" => r.verdict&.to_s,
|
|
527
|
-
"errors" => r.errors,
|
|
528
|
-
"dependency_errors" => r.dep_errors,
|
|
529
|
-
"warnings" => r.warnings,
|
|
530
|
-
"infos" => r.infos,
|
|
531
|
-
"fixable" => r.fixable,
|
|
532
|
-
"status" => r.status
|
|
533
|
-
}
|
|
534
|
-
end
|
|
535
|
-
))
|
|
536
|
-
end
|
|
537
|
-
|
|
538
524
|
def print_capabilities(options)
|
|
539
525
|
result = prober(options).probe(mode: :capabilities)
|
|
540
526
|
caps = result.raw["capabilities"]
|
|
541
527
|
unless caps
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
)
|
|
528
|
+
complain("capabilities probe failed: #{result.raw}",
|
|
529
|
+
options)
|
|
545
530
|
return 2
|
|
546
531
|
end
|
|
547
532
|
|
|
533
|
+
s = style(options)
|
|
548
534
|
rows = caps.map do |probe, info|
|
|
549
535
|
{
|
|
550
536
|
"works in Ractor" => probe,
|
|
551
|
-
"ok" => info["ok"] ?
|
|
552
|
-
"raises" => info["error"]
|
|
537
|
+
"ok" => s.glyph(info["ok"] ? :pass : :error),
|
|
538
|
+
"raises" => info["error"]
|
|
553
539
|
}
|
|
554
540
|
end
|
|
555
|
-
@stdout.puts("ruby #{RUBY_VERSION} at #{RbConfig.ruby}")
|
|
556
541
|
@stdout.puts(
|
|
557
|
-
|
|
542
|
+
"#{s.glyph(:section)} #{s.bold("ruby #{RUBY_VERSION}")} " \
|
|
543
|
+
"#{s.dim("at #{RbConfig.ruby}")}"
|
|
558
544
|
)
|
|
545
|
+
@stdout.puts(TableTennis.new(rows,
|
|
546
|
+
title: "Audition capabilities", **table_opts(options)).to_s)
|
|
559
547
|
0
|
|
560
548
|
end
|
|
561
549
|
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,7 +22,7 @@ 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
28
|
FAIL_ON_LEVELS = %w[error warning info never].freeze
|
|
@@ -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,6 +57,7 @@ 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)
|
|
@@ -62,13 +66,35 @@ module Audition
|
|
|
62
66
|
"#{path}: fail_on must be one of error, warning, " \
|
|
63
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
|