audition 0.1.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.
@@ -0,0 +1,415 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "table_tennis"
5
+
6
+ module Audition
7
+ class CLI
8
+ USAGE = "usage: audition [options] TARGET " \
9
+ "(a .rb script, directory, config.ru dir, Rails root, " \
10
+ "gem dir, or installed gem name)"
11
+
12
+ # Entry point used by `exe/audition`.
13
+ #
14
+ # @param argv [Array<String>] command line arguments
15
+ # @param stdout [IO]
16
+ # @param stderr [IO]
17
+ # @return [Integer] exit code: 0 clean, 1 findings at or above
18
+ # the `--fail-on` threshold, 2 usage error
19
+ def self.run(argv, stdout: $stdout, stderr: $stderr)
20
+ new(stdout: stdout, stderr: stderr).run(argv)
21
+ end
22
+
23
+ def initialize(stdout:, stderr:)
24
+ @stdout = stdout
25
+ @stderr = stderr
26
+ end
27
+
28
+ def run(argv)
29
+ options = parse(argv.dup)
30
+ return options if options.is_a?(Integer)
31
+
32
+ return print_capabilities(options) if options[:capabilities]
33
+
34
+ target_arg = options[:args].first
35
+ unless target_arg
36
+ @stderr.puts(USAGE)
37
+ return 2
38
+ end
39
+
40
+ target = Target.detect(target_arg)
41
+ target = deps_target(target) if options[:deps]
42
+ if target.type == :bundle
43
+ sweep(target, options)
44
+ else
45
+ audit(target, options)
46
+ end
47
+ rescue Error => e
48
+ @stderr.puts("audition: #{e.message}")
49
+ 2
50
+ end
51
+
52
+ private
53
+
54
+ def parse(argv)
55
+ options = {
56
+ format: :text, fail_on: :error, static_only: false,
57
+ dynamic_only: false, fix: false, unsafe: false,
58
+ dry_run: false, capabilities: false, plain: false,
59
+ timeout: 30, write_baseline: false, no_baseline: false,
60
+ deps: false, explicit: []
61
+ }
62
+ parser = OptionParser.new do |o|
63
+ o.banner = USAGE
64
+ o.on("-f", "--format FORMAT", %w[text json github],
65
+ "output format: text (default), json, or github " \
66
+ "(workflow command annotations)") do |v|
67
+ options[:format] = v.to_sym
68
+ end
69
+ o.on("--compare PATH",
70
+ "report deltas against a previous --format json " \
71
+ "report (text/github formats)") do |v|
72
+ options[:compare] = v
73
+ end
74
+ o.on("--static-only", "skip dynamic in-Ractor probing") do
75
+ options[:static_only] = true
76
+ end
77
+ o.on("--dynamic-only", "skip static analysis") do
78
+ options[:dynamic_only] = true
79
+ end
80
+ o.on("--fix", "apply safe corrections, then re-check") do
81
+ options[:fix] = true
82
+ end
83
+ o.on("--fix-unsafe",
84
+ "also apply semantics-affecting corrections") do
85
+ options[:fix] = true
86
+ options[:unsafe] = true
87
+ end
88
+ o.on("--dry-run",
89
+ "with --fix: show planned edits, change nothing") do
90
+ options[:dry_run] = true
91
+ end
92
+ o.on("--fail-on LEVEL", %w[error warning info],
93
+ "exit 1 threshold (default: error)") do |v|
94
+ options[:fail_on] = v.to_sym
95
+ options[:explicit] << :fail_on
96
+ end
97
+ o.on("--write-baseline",
98
+ "record current findings as the baseline") do
99
+ options[:write_baseline] = true
100
+ end
101
+ o.on("--no-baseline", "ignore an existing baseline") do
102
+ options[:no_baseline] = true
103
+ end
104
+ o.on("--capabilities",
105
+ "probe what this Ruby allows inside Ractors") do
106
+ options[:capabilities] = true
107
+ end
108
+ o.on("--deps",
109
+ "sweep the target's Gemfile.lock gem by gem") do
110
+ options[:deps] = true
111
+ end
112
+ o.on("--timeout SECONDS", Integer,
113
+ "dynamic probe timeout (default: 30)") do |v|
114
+ options[:timeout] = v
115
+ options[:explicit] << :timeout
116
+ end
117
+ o.on("--plain", "disable colors and hyperlinks") do
118
+ options[:plain] = true
119
+ end
120
+ o.on("-v", "--version") do
121
+ @stdout.puts(VERSION)
122
+ return 0
123
+ end
124
+ o.on("-h", "--help") do
125
+ @stdout.puts(o)
126
+ return 0
127
+ end
128
+ end
129
+ options[:args] = parser.parse(argv)
130
+ options
131
+ rescue OptionParser::ParseError => e
132
+ @stderr.puts("audition: #{e.message}")
133
+ @stderr.puts(USAGE)
134
+ 2
135
+ end
136
+
137
+ def audit(target, options)
138
+ config = Config.load(target.root)
139
+ options = apply_config(options, config)
140
+ directives = Directives.new
141
+ findings = []
142
+ unless options[:dynamic_only]
143
+ findings = filter(static_findings(target, config),
144
+ directives, config)
145
+ findings = run_fix(target, findings, options) if options[:fix]
146
+ end
147
+
148
+ results = []
149
+ if target.entry && !options[:static_only]
150
+ results << prober(options).probe(target.entry)
151
+ end
152
+
153
+ all = findings +
154
+ filter(results.flat_map(&:findings), directives, config)
155
+
156
+ if options[:write_baseline]
157
+ recorded = Baseline.write(target.root, all)
158
+ @stdout.puts(
159
+ "baseline written: #{recorded} finding(s) recorded in " \
160
+ "#{Baseline.path_for(target.root)}"
161
+ )
162
+ return 0
163
+ end
164
+
165
+ all, baselined = apply_baseline(all, target, options)
166
+
167
+ report = Report.new(
168
+ target_type: target.type,
169
+ target_root: target.root,
170
+ findings: all,
171
+ dynamic_results: results,
172
+ unsafe_fixes: options[:unsafe] ? 0 : Fixer.unsafe_gain(all),
173
+ baselined: baselined
174
+ )
175
+ emit(report, options)
176
+ if options[:compare] && options[:format] != :json
177
+ emit_comparison(report, options)
178
+ end
179
+ exit_code(report, options)
180
+ end
181
+
182
+ def emit_comparison(report, options)
183
+ old = begin
184
+ JSON.parse(File.read(options[:compare]))
185
+ rescue JSON::ParserError, SystemCallError => e
186
+ raise Error, "cannot read #{options[:compare]}: #{e.message}"
187
+ end
188
+
189
+ budget = Hash.new(0)
190
+ old.fetch("findings", []).each do |f|
191
+ budget[[f["check"], f["path"], f["message"]]] += 1
192
+ end
193
+ total_old = budget.values.sum
194
+
195
+ introduced = report.findings.reject do |f|
196
+ key = [f.check, f.path, f.message]
197
+ next false unless budget[key].positive?
198
+
199
+ budget[key] -= 1
200
+ true
201
+ end
202
+ fixed = total_old -
203
+ (report.findings.size - introduced.size)
204
+
205
+ s = style(options)
206
+ @stdout.puts(
207
+ " compared to #{options[:compare]}: " \
208
+ "#{s.green("#{fixed} fixed")} · " \
209
+ "#{s.red("#{introduced.size} introduced")}"
210
+ )
211
+ introduced.each do |f|
212
+ @stdout.puts(" + #{f.message} (#{f.location})")
213
+ end
214
+ end
215
+
216
+ def apply_config(options, config)
217
+ merged = options.dup
218
+ if config.fail_on && !options[:explicit].include?(:fail_on)
219
+ merged[:fail_on] = config.fail_on
220
+ end
221
+ if config.timeout && !options[:explicit].include?(:timeout)
222
+ merged[:timeout] = config.timeout
223
+ end
224
+ merged
225
+ end
226
+
227
+ def filter(findings, directives, config)
228
+ directives.filter(findings).reject do |finding|
229
+ config.check_disabled?(finding.check)
230
+ end
231
+ end
232
+
233
+ def apply_baseline(findings, target, options)
234
+ return [findings, 0] if options[:no_baseline]
235
+
236
+ baseline = Baseline.load(target.root)
237
+ return [findings, 0] unless baseline
238
+
239
+ baseline.filter(findings, root: target.root)
240
+ end
241
+
242
+ def static_findings(target, config)
243
+ files = target.ruby_files.reject do |file|
244
+ config.excluded?(file.delete_prefix("#{target.root}/"))
245
+ end
246
+ per_file = Static::Analyzer.new.analyze_paths(files)
247
+ per_file + Static::GraphAudit.new.analyze_paths(files)
248
+ end
249
+
250
+ def run_fix(target, findings, options)
251
+ fixer = Fixer.new(unsafe: options[:unsafe])
252
+ if options[:dry_run]
253
+ render_preview(fixer.preview(findings), options)
254
+ return findings
255
+ end
256
+
257
+ applied = fixer.apply(findings)
258
+ total = applied.values.sum
259
+ @stdout.puts(
260
+ "fixed #{total} finding(s) in #{applied.size} file(s)"
261
+ )
262
+ return findings unless total.positive?
263
+
264
+ filter(static_findings(target, Config.load(target.root)),
265
+ Directives.new, Config.load(target.root))
266
+ end
267
+
268
+ def render_preview(previews, options)
269
+ s = style(options)
270
+ previews.each do |preview|
271
+ @stdout.puts(s.bold(preview[:path]))
272
+ preview[:hunks].each do |hunk|
273
+ @stdout.puts(" @ line #{hunk[:line]}")
274
+ hunk[:old].each_line do |line|
275
+ @stdout.puts(s.red(" - #{line.chomp}"))
276
+ end
277
+ hunk[:new].each_line do |line|
278
+ @stdout.puts(s.green(" + #{line.chomp}"))
279
+ end
280
+ end
281
+ end
282
+ @stdout.puts("dry run: no files were changed")
283
+ end
284
+
285
+ def prober(options)
286
+ Dynamic::Prober.new(timeout: options[:timeout])
287
+ end
288
+
289
+ def emit(report, options)
290
+ case options[:format]
291
+ when :json then @stdout.puts(report.to_json)
292
+ when :github then @stdout.puts(report.to_github)
293
+ else @stdout.puts(report.to_text(style: style(options)))
294
+ end
295
+ end
296
+
297
+ def style(options)
298
+ if options[:plain]
299
+ Report::Style.new(color: false, hyperlinks: false)
300
+ else
301
+ Report::Style.detect(io: @stdout)
302
+ end
303
+ end
304
+
305
+ def exit_code(report, options)
306
+ threshold = SEVERITIES.fetch(options[:fail_on])
307
+ failed =
308
+ report.findings.any? do |f|
309
+ f.severity_rank >= threshold
310
+ end || report.dynamic_results.any? { |r| !r.passed }
311
+ failed ? 1 : 0
312
+ end
313
+
314
+ def deps_target(target)
315
+ return target if target.type == :bundle
316
+
317
+ lockfile = File.join(target.root, "Gemfile.lock")
318
+ unless File.file?(lockfile)
319
+ raise Error, "no Gemfile.lock found in #{target.root}"
320
+ end
321
+
322
+ Target.detect(lockfile)
323
+ end
324
+
325
+ VERDICT_CELLS = {
326
+ :not_ready => "not ready", :blocked => "blocked",
327
+ :risky => "risky", :ready => "ready", nil => "-"
328
+ }.freeze
329
+
330
+ def sweep(target, options)
331
+ sweeper = BundleSweep.new(
332
+ lockfile: target.entry[:lockfile],
333
+ static_only: options[:static_only],
334
+ timeout: options[:timeout]
335
+ )
336
+ rows = sweeper.rows(progress: sweep_progress)
337
+
338
+ if options[:format] == :json
339
+ emit_sweep_json(rows)
340
+ else
341
+ emit_sweep_table(rows)
342
+ end
343
+ (rows.any? { |r| r.verdict == :not_ready }) ? 1 : 0
344
+ end
345
+
346
+ def sweep_progress
347
+ return nil unless @stderr.respond_to?(:tty?) && @stderr.tty?
348
+
349
+ lambda do |row, done, total|
350
+ @stderr.puts("audited #{row.name} (#{done}/#{total})")
351
+ end
352
+ end
353
+
354
+ def emit_sweep_table(rows)
355
+ ready = rows.count { |r| r.verdict == :ready }
356
+ table = rows.map do |r|
357
+ {
358
+ "gem" => r.name,
359
+ "version" => r.version,
360
+ "verdict" => VERDICT_CELLS.fetch(r.verdict),
361
+ "errors" => r.errors,
362
+ "dep errors" => r.dep_errors,
363
+ "warnings" => r.warnings,
364
+ "fixable" => r.fixable,
365
+ "status" => r.status
366
+ }
367
+ end
368
+ @stdout.puts(TableTennis.new(table, layout: false).to_s)
369
+ @stdout.puts(
370
+ "#{ready} of #{rows.size} gems ractor-ready"
371
+ )
372
+ end
373
+
374
+ def emit_sweep_json(rows)
375
+ @stdout.puts(JSON.pretty_generate(
376
+ "audition" => VERSION,
377
+ "ruby" => RUBY_VERSION,
378
+ "bundle" => rows.map do |r|
379
+ {
380
+ "gem" => r.name,
381
+ "version" => r.version,
382
+ "verdict" => r.verdict&.to_s,
383
+ "errors" => r.errors,
384
+ "dependency_errors" => r.dep_errors,
385
+ "warnings" => r.warnings,
386
+ "fixable" => r.fixable,
387
+ "status" => r.status
388
+ }
389
+ end
390
+ ))
391
+ end
392
+
393
+ def print_capabilities(options)
394
+ result = prober(options).probe(mode: :capabilities)
395
+ caps = result.raw["capabilities"]
396
+ unless caps
397
+ @stderr.puts(
398
+ "audition: capabilities probe failed: #{result.raw}"
399
+ )
400
+ return 2
401
+ end
402
+
403
+ rows = caps.map do |probe, info|
404
+ {
405
+ "works in Ractor" => probe,
406
+ "ok" => info["ok"] ? "yes" : "no",
407
+ "raises" => info["error"] || "-"
408
+ }
409
+ end
410
+ @stdout.puts("ruby #{RUBY_VERSION} at #{RbConfig.ruby}")
411
+ @stdout.puts(TableTennis.new(rows, layout: false).to_s)
412
+ 0
413
+ end
414
+ end
415
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Audition
6
+ # Project configuration from .audition.yml at the target root:
7
+ #
8
+ # fail_on: warning
9
+ # timeout: 60
10
+ # exclude:
11
+ # - legacy/**
12
+ # - db/schema.rb
13
+ # checks:
14
+ # disable:
15
+ # - at-exit
16
+ #
17
+ # CLI flags always win over config values.
18
+ class Config
19
+ FILE = ".audition.yml"
20
+
21
+ EMPTY = Ractor.make_shareable(
22
+ {fail_on: nil, timeout: nil, exclude: [],
23
+ disabled_checks: []}
24
+ )
25
+
26
+ attr_reader :fail_on, :timeout, :exclude, :disabled_checks
27
+
28
+ # @param root [String] directory that may contain .audition.yml
29
+ # @return [Config] empty config when the file is absent
30
+ # @raise [Audition::Error] on malformed YAML
31
+ def self.load(root)
32
+ path = File.join(root.to_s, FILE)
33
+ return new(**EMPTY) unless File.file?(path)
34
+
35
+ data = YAML.safe_load_file(path) || {}
36
+ new(
37
+ fail_on: data["fail_on"]&.to_sym,
38
+ timeout: data["timeout"],
39
+ exclude: Array(data["exclude"]).map(&:to_s),
40
+ disabled_checks:
41
+ Array(data.dig("checks", "disable")).map(&:to_s)
42
+ )
43
+ rescue Psych::Exception => e
44
+ raise Error, "#{path}: #{e.message}"
45
+ end
46
+
47
+ def initialize(fail_on:, timeout:, exclude:, disabled_checks:)
48
+ @fail_on = fail_on
49
+ @timeout = timeout
50
+ @exclude = exclude
51
+ @disabled_checks = disabled_checks
52
+ end
53
+
54
+ def excluded?(relative_path)
55
+ exclude.any? do |pattern|
56
+ next true if File.fnmatch?(pattern, relative_path)
57
+
58
+ prefix = pattern[%r{\A(.+?)/\*\*(?:/\*+)?\z}, 1]
59
+ prefix && relative_path.start_with?("#{prefix}/")
60
+ end
61
+ end
62
+
63
+ def check_disabled?(check)
64
+ disabled_checks.include?(check)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ # Same-line suppression pragmas:
5
+ #
6
+ # $flag = true # audition:disable global-variables
7
+ # legacy_call # audition:disable
8
+ #
9
+ # A bare pragma silences every check on that line; otherwise only
10
+ # the listed check names. Applied to any finding carrying a real
11
+ # path and line, including runtime findings.
12
+ class Directives
13
+ PATTERN = /#\s*audition:disable\b[ \t]*(?<list>[^#\n]*)/
14
+
15
+ def initialize
16
+ @by_path = {}
17
+ end
18
+
19
+ # @param findings [Array<Finding>]
20
+ # @return [Array<Finding>] findings not silenced by a pragma
21
+ def filter(findings)
22
+ findings.reject { |finding| disabled?(finding) }
23
+ end
24
+
25
+ def disabled?(finding)
26
+ return false unless finding.path && finding.line
27
+
28
+ checks = directives_for(finding.path)[finding.line]
29
+ return false unless checks
30
+
31
+ checks.empty? || checks.include?(finding.check)
32
+ end
33
+
34
+ private
35
+
36
+ def directives_for(path)
37
+ @by_path[path] ||= scan(path)
38
+ end
39
+
40
+ def scan(path)
41
+ return {} unless File.file?(path)
42
+
43
+ directives = {}
44
+ File.foreach(path).with_index(1) do |line, number|
45
+ match = PATTERN.match(line)
46
+ next unless match
47
+
48
+ directives[number] =
49
+ match[:list].split(/[,\s]+/).reject(&:empty?)
50
+ end
51
+ directives
52
+ rescue SystemCallError
53
+ {}
54
+ end
55
+ end
56
+ end