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.
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
- @stderr.puts("audition: #{e.message}")
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
- @stderr.puts("audition: #{e.message}")
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
- "baseline written: #{recorded} finding(s) recorded in " \
180
- "#{Baseline.path_for(target.root)}"
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(" + #{f.message} (#{f.location})")
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
- per_file = Static::Analyzer.new.analyze_paths(files)
286
- per_file + Static::GraphAudit.new.analyze_paths(files) +
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
- "fixed #{total} finding(s) in #{applied.size} file(s)"
372
+ "#{s.cyan(s.glyph(:fix))} #{s.green("fixed #{total} " \
373
+ "finding(s)")} in #{s.bold("#{applied.size} file(s)")}"
309
374
  )
310
- filter(static_findings(target, Config.load(target.root)),
311
- Directives.new, Config.load(target.root))
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
- @stderr.puts(
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[:plain]
366
- Report::Style.new(color: false, hyperlinks: false)
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.rows(progress: sweep_progress)
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
- @stderr.puts(
543
- "audition: capabilities probe failed: #{result.raw}"
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"] ? "yes" : "no",
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
- TableTennis.new(rows, **table_opts(options)).to_s
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
@@ -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
- def initialize(fail_on:, timeout:, exclude:, disabled_checks:)
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