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.
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, or installed gem name)"
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
- target_arg = options[:args].first
35
- unless target_arg
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
- target = Target.detect(target_arg)
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
- @stderr.puts("audition: #{e.message}")
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)") do |v|
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
- @stderr.puts("audition: #{e.message}")
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(target.entry)
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
- "baseline written: #{recorded} finding(s) recorded in " \
163
- "#{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))}"
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(" + #{f.message} (#{f.location})")
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
- per_file = Static::Analyzer.new.analyze_paths(files)
266
- per_file + Static::GraphAudit.new.analyze_paths(files)
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
- "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)")}"
287
373
  )
288
- filter(static_findings(target, Config.load(target.root)),
289
- Directives.new, Config.load(target.root))
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 then @stdout.puts(report.to_json)
316
- when :github then @stdout.puts(report.to_github)
317
- else @stdout.puts(report.to_text(style: style(options)))
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[:plain]
323
- Report::Style.new(color: false, hyperlinks: false)
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.rows(progress: sweep_progress)
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
- @stderr.puts(
449
- "audition: capabilities probe failed: #{result.raw}"
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"] ? "yes" : "no",
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
- TableTennis.new(rows, **table_opts(options)).to_s
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
@@ -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, or " \
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
- 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