audition 0.3.0 → 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.
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
@@ -175,9 +194,11 @@ module Audition
175
194
 
176
195
  if options[:write_baseline]
177
196
  recorded = Baseline.write(target.root, all)
197
+ s = style(options)
178
198
  @stdout.puts(
179
- "baseline written: #{recorded} finding(s) recorded in " \
180
- "#{Baseline.path_for(target.root)}"
199
+ "#{s.green(s.glyph(:pass))} baseline written: " \
200
+ "#{s.bold("#{recorded} finding(s)")} recorded in " \
201
+ "#{s.cyan(Baseline.path_for(target.root))}"
181
202
  )
182
203
  return 0
183
204
  end
@@ -239,7 +260,10 @@ module Audition
239
260
  "#{s.red("#{introduced.size} introduced")}"
240
261
  )
241
262
  introduced.each do |f|
242
- @stdout.puts(" + #{f.message} (#{f.location})")
263
+ @stdout.puts(
264
+ " #{s.red("+")} #{f.message} " \
265
+ "#{s.cyan("(#{f.location})")}"
266
+ )
243
267
  end
244
268
  end
245
269
 
@@ -275,17 +299,55 @@ module Audition
275
299
  baseline.filter(findings, root: target.root)
276
300
  end
277
301
 
278
- def static_findings(target, config)
302
+ def static_findings(target, config, options)
279
303
  files = target.ruby_files.reject do |file|
280
304
  config.excluded?(file.delete_prefix("#{target.root}/"))
281
305
  end
282
306
  compiled = target.compiled_files.reject do |file|
283
307
  config.excluded?(file.delete_prefix("#{target.root}/"))
284
308
  end
285
- per_file = Static::Analyzer.new.analyze_paths(files)
286
- per_file + Static::GraphAudit.new.analyze_paths(files) +
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 +
287
347
  Static::NativeExtensions.new.analyze(target,
288
348
  compiled_files: compiled)
349
+ ensure
350
+ progress.finish
289
351
  end
290
352
 
291
353
  # Fix chatter goes to stderr: stdout carries the report, which
@@ -299,16 +361,19 @@ module Audition
299
361
 
300
362
  applied = fixer.apply(findings)
301
363
  total = applied.values.sum
364
+ s = style(options, io: @stderr)
302
365
  if total.zero?
303
- @stderr.puts("nothing to fix")
366
+ @stderr.puts(s.dim("nothing to fix"))
304
367
  return findings
305
368
  end
306
369
 
307
370
  @stderr.puts(
308
- "fixed #{total} finding(s) in #{applied.size} file(s)"
371
+ "#{s.cyan(s.glyph(:fix))} #{s.green("fixed #{total} " \
372
+ "finding(s)")} in #{s.bold("#{applied.size} file(s)")}"
309
373
  )
310
- filter(static_findings(target, Config.load(target.root)),
311
- Directives.new, Config.load(target.root))
374
+ config = Config.load(target.root)
375
+ filter(static_findings(target, config, options),
376
+ Directives.new, config)
312
377
  end
313
378
 
314
379
  def render_preview(previews, options)
@@ -316,7 +381,7 @@ module Audition
316
381
  previews.each do |preview|
317
382
  @stderr.puts(s.bold(preview[:path]))
318
383
  preview[:hunks].each do |hunk|
319
- @stderr.puts(" @ line #{hunk[:line]}")
384
+ @stderr.puts(s.cyan(" @ line #{hunk[:line]}"))
320
385
  hunk[:old].each_line do |line|
321
386
  @stderr.puts(s.red(" - #{line.chomp}"))
322
387
  end
@@ -325,7 +390,7 @@ module Audition
325
390
  end
326
391
  end
327
392
  end
328
- @stderr.puts("dry run: no files were changed")
393
+ @stderr.puts(s.dim("dry run: no files were changed"))
329
394
  end
330
395
 
331
396
  def prober(options)
@@ -356,26 +421,32 @@ module Audition
356
421
 
357
422
  File.open(path, "a") { |f| f.puts(markdown) }
358
423
  rescue SystemCallError => e
359
- @stderr.puts(
360
- "audition: cannot write step summary: #{e.message}"
361
- )
424
+ complain("cannot write step summary: #{e.message}")
362
425
  end
363
426
 
427
+ # Diagnostics can precede option parsing, so --plain is
428
+ # honored only once there is something to honor it from.
364
429
  def style(options, io: @stdout)
365
- if options[:plain]
366
- Report::Style.new(color: false, hyperlinks: false)
430
+ if options&.fetch(:plain, false)
431
+ plain_style
367
432
  else
368
433
  Report::Style.detect(io: io)
369
434
  end
370
435
  end
371
436
 
437
+ def plain_style
438
+ Report::Style.new(color: false, hyperlinks: false)
439
+ end
440
+
372
441
  def exit_code(report, options)
373
442
  return 0 if options[:fail_on] == :never
374
443
 
375
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.
376
447
  failed =
377
448
  report.findings.any? do |f|
378
- f.severity_rank >= threshold
449
+ f.severity_rank >= threshold && !f.test?
379
450
  end || report.dynamic_results.any? { |r| !r.passed }
380
451
  failed ? 1 : 0
381
452
  end
@@ -391,30 +462,41 @@ module Audition
391
462
  Target.detect(lockfile)
392
463
  end
393
464
 
394
- VERDICT_CELLS = {
395
- :not_ready => "not ready", :blocked => "blocked",
396
- :risky => "risky", :ready => "ready", nil => "-"
397
- }.freeze
398
-
399
465
  def sweep(target, options)
400
466
  sweeper = BundleSweep.new(
401
467
  lockfile: target.entry[:lockfile],
402
468
  static_only: options[:static_only],
403
469
  timeout: options[:timeout]
404
470
  )
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
471
+ rows = sweep_rows(sweeper, options)
472
+ emit_sweep(Report::Sweep.new(rows, style(options)), options)
412
473
  return 0 if options[:fail_on] == :never
413
474
 
414
475
  threshold = SEVERITIES.fetch(options[:fail_on])
415
476
  (rows.any? { |r| row_failed?(r, threshold) }) ? 1 : 0
416
477
  end
417
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
+
418
500
  # Same contract as a direct audit: a row fails when it carries
419
501
  # findings at or above --fail-on; not-ready rows always fail.
420
502
  def row_failed?(row, threshold)
@@ -426,14 +508,6 @@ module Audition
426
508
  hits.positive?
427
509
  end
428
510
 
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
511
  # Cells arrive preformatted: coercion would render a version
438
512
  # like "3.2" as 3.200. Color follows the CLI's own detection
439
513
  # because the table gem reads the global $stdout and cannot
@@ -446,116 +520,29 @@ module Audition
446
520
  }
447
521
  end
448
522
 
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
523
  def print_capabilities(options)
539
524
  result = prober(options).probe(mode: :capabilities)
540
525
  caps = result.raw["capabilities"]
541
526
  unless caps
542
- @stderr.puts(
543
- "audition: capabilities probe failed: #{result.raw}"
544
- )
527
+ complain("capabilities probe failed: #{result.raw}",
528
+ options)
545
529
  return 2
546
530
  end
547
531
 
532
+ s = style(options)
548
533
  rows = caps.map do |probe, info|
549
534
  {
550
535
  "works in Ractor" => probe,
551
- "ok" => info["ok"] ? "yes" : "no",
552
- "raises" => info["error"] || "-"
536
+ "ok" => s.glyph(info["ok"] ? :pass : :error),
537
+ "raises" => info["error"]
553
538
  }
554
539
  end
555
- @stdout.puts("ruby #{RUBY_VERSION} at #{RbConfig.ruby}")
556
540
  @stdout.puts(
557
- TableTennis.new(rows, **table_opts(options)).to_s
541
+ "#{s.glyph(:section)} #{s.bold("ruby #{RUBY_VERSION}")} " \
542
+ "#{s.dim("at #{RbConfig.ruby}")}"
558
543
  )
544
+ @stdout.puts(TableTennis.new(rows,
545
+ title: "Audition capabilities", **table_opts(options)).to_s)
559
546
  0
560
547
  end
561
548
  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