audition 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1605ecba3ed080146001f514aabd1d5b1754ac94623205f9e47500011b3a0894
4
- data.tar.gz: acd2a8e10e2c487bfc56b53960b23051795bd2c0274f065146e5d497bc4e11be
3
+ metadata.gz: e82f1a081cebcd3209cfbced17ecb539949cf4590c785e1b6873535473090756
4
+ data.tar.gz: 7d6c4358bae0e8514e15452c0f0dba8bde7a53a9dd342ec9b96e9cf53a4c853f
5
5
  SHA512:
6
- metadata.gz: 9135eecfc64dbdac1dc3aef23a449f707384d41adb018d634a2940ef1a3eb93cebf0836e333f3d6a6bcb47d4eff9f1efdf219406379202e5a10bd0624785d39f
7
- data.tar.gz: 2111276ebb2d9006c1fdcd8bafcac5086cb00bcd3fbcb6c57f5a9f30c4e388181f523be0b5839ca35d4d64bb2ebeb60a1332d41286befc13c84e7166f20a1468
6
+ metadata.gz: e1ddb8750389aa6734d895a4a84e32fb8a0cb0feca7d26c83e0803e8f28abfd6f16fd3255f352ba3e4d3534192636da3d6f73f651fd90dff8d18654384f779bf
7
+ data.tar.gz: dfe09ec14b4f3058f2df766129a78d6cfed1dfd65bd72ce512a6cfeea65e1ae42ef01bf0cb9d0c9f1d890973dcbcc052396dbca9cf445e0bf79d08676d8f3c45
data/README.md CHANGED
@@ -3,18 +3,23 @@
3
3
  Point it at a Ruby script, a gem, a Rack app, or a Rails root and it
4
4
  tells you whether that code can run inside Ractors, why it cannot,
5
5
  and how to fix it. Unlike a linter, audition does not stop at
6
- pattern-matching your source: it also loads the target in a
7
- sandboxed subprocess and observes real `Ractor::IsolationError`s on
8
- the live object graph.
6
+ pattern-matching your source: whole-program analysis is powered by
7
+ [rubydex](https://github.com/Shopify/rubydex), Shopify's Ruby
8
+ indexer, and the target is also loaded in a sandboxed subprocess
9
+ to observe real `Ractor::IsolationError`s on the live object
10
+ graph. Some of the checks and fixes were trained on how Rails
11
+ core itself is being ractorized; see the
12
+ [pattern study](docs/rails_core_best_practices.md).
9
13
 
10
14
  [![GitHub Release](https://img.shields.io/github/v/release/yaroslav/audition)](https://github.com/yaroslav/audition/releases)
11
15
  [![Docs](https://img.shields.io/badge/yard-docs-blue.svg)](https://rubydoc.info/gems/audition)
12
16
 
13
17
  - **Three probes, one verdict.** Per-file Prism AST checks,
14
- whole-program semantic analysis on the
15
- [rubydex](https://github.com/Shopify/rubydex) graph (class-level
16
- state is resolved across files and reopenings), and dynamic
17
- in-Ractor execution of the actual target.
18
+ whole-program semantic analysis powered by
19
+ [rubydex](https://github.com/Shopify/rubydex) (Shopify's code
20
+ graph; class-level state is resolved across files and
21
+ reopenings), and dynamic in-Ractor execution of the actual
22
+ target.
18
23
  - **Explains, not just flags.** Every finding carries a `why`
19
24
  (which rule of the Ractor model it violates) and a `fix`
20
25
  (what to write instead).
@@ -22,14 +27,28 @@ the live object graph.
22
27
  `.freeze` on string constants, `Ractor.make_shareable(...)` for
23
28
  mutable and shallow-frozen containers and Proc constants, and
24
29
  boot-time hoisting of method-body requires. `--fix-unsafe` adds
25
- semantics-affecting rewrites: magic-comment insertion, class
26
- memoization to `Ractor.store_if_absent`, `autoload` to
27
- `require`, and write-once globals/class variables to frozen
28
- constants. `--dry-run` previews everything as a diff.
30
+ semantics-affecting rewrites: magic-comment insertion,
31
+ freeze-on-memoize for class-level memoization (both `@x ||=`
32
+ and `return @x if defined?(@x)` idioms keep their caching, the
33
+ memoized value becomes shareable, Rails-core style;
34
+ `Ractor.store_if_absent` only for block initializers and
35
+ invalidated caches), `autoload` to `require`, and write-once
36
+ globals/class variables to frozen constants. `--dry-run`
37
+ previews everything as a diff.
29
38
  - **Dependency-aware.** Runtime findings are attributed to their
30
39
  source via `const_source_location`; when your own code is clean
31
40
  but a dependency is not, the verdict is a distinct `blocked`
32
41
  state, so `globalid` is not blamed for ActiveSupport's state.
42
+ - **Dogfooding.** The scanner for Ractor compatibility is built
43
+ using Ractors: static analysis fans out across CPU cores on
44
+ Ractor workers, and audition passes its own audit.
45
+ - **Trained on Rails core.** Several checks and fix suggestions
46
+ come straight from studying the Rails ractorization effort
47
+ (about 75 substantive commits): `Hash.new` default procs,
48
+ in-place mutation of registry constants, closure-carrying
49
+ `define_method`, copy-on-write rewrites. The findings are
50
+ documented in
51
+ [docs/rails_core_best_practices.md](docs/rails_core_best_practices.md).
33
52
  - **Terminal-native output.** Colors, glyphs, and OSC 8 hyperlinks;
34
53
  `path:line` is clickable in supporting terminals. JSON output for
35
54
  CI.
@@ -94,7 +113,7 @@ proxying) and its verified semantics.
94
113
  - [Usage](#usage)
95
114
  - [Adopting incrementally](#adopting-incrementally)
96
115
  - [What it catches](#what-it-catches)
97
- - [Field notes](#field-notes)
116
+ - [Agent skill](#agent-skill)
98
117
  - [Extending](#extending)
99
118
  - [Development](#development)
100
119
  - [License](#license)
@@ -191,14 +210,19 @@ Static, with file:line precision:
191
210
  - **Class variables**, resolved on the rubydex graph.
192
211
  - **Class-level instance variables**, unified across the class
193
212
  body, `def self.`, and `class << self`, across files; the classic
194
- `@cache ||= {}` memoization.
213
+ `@cache ||= {}` and `return @x if defined?(@x)` memoizations.
195
214
  - **Constants that are not deeply shareable**: bare mutable
196
215
  literals, interpolated strings, and the subtle shallow freeze
197
216
  (`[[1], [2]].freeze` still raises; audition explains why).
198
217
  Honors `# frozen_string_literal:` and
199
218
  `# shareable_constant_value:` magic comments.
200
219
  - **Sync primitives and Procs in constants** (Mutex, Queue,
201
- lambdas).
220
+ lambdas), including `Hash.new { }` default procs, which stay
221
+ unshareable even after `.freeze`.
222
+ - **Registry-style constant mutation** (`RENDERERS << key`,
223
+ `LOOKUP[k] = v`) and **`define_method` with a literal block**
224
+ (the method carries an unshareable Proc); both patterns and
225
+ their fixes come from the Rails core ractorization study.
202
226
  - **Runtime require and autoload** (serializes all Ractors through
203
227
  the main-Ractor proxy).
204
228
  - **`Ractor.new` blocks capturing outer locals** (the ArgumentError
@@ -222,20 +246,27 @@ Dynamic, on the live object graph:
222
246
  - Boots Rails (`config/environment.rb`), eager-loads, and sweeps
223
247
  the application's namespaces.
224
248
 
225
- ## Field notes
249
+ ## Agent skill
226
250
 
227
- Findings from running audition on popular gems (July 2026,
228
- Ruby 4.0.6):
251
+ This repository ships a `ractor-readiness` skill that teaches
252
+ coding agents (Claude Code and friends) the full audition
253
+ workflow: audit, fix tiers, suite-parity verification, and
254
+ incremental adoption. It lives in
255
+ [skills/ractor-readiness/SKILL.md](skills/ractor-readiness/SKILL.md).
229
256
 
230
- - **rack 3.2**: `Rack::Builder.parse_file` cannot run inside a
231
- Ractor at all; `Rack::BUILDER_TOPLEVEL_BINDING` holds an
232
- unshareable Binding. audition's own rack probe rebuilds the app
233
- with `Rack::Builder.new` + `instance_eval` to get around it.
234
- - **mail 2.9**: 28 hard findings, including `@@maximum_amount`,
235
- `@@autoloads`, and unfrozen table constants like `FIELDS_MAP`.
236
- - **globalid 1.3**: only 6 findings of its own; the rest of its
237
- report is ActiveSupport state, attributed as dependency errors
238
- in the summary.
257
+ Install into Claude Code as a plugin:
258
+
259
+ ```
260
+ /plugin marketplace add yaroslav/audition
261
+ /plugin install audition@audition
262
+ ```
263
+
264
+ Or install the skill with the
265
+ [skills CLI](https://github.com/vercel-labs/skills):
266
+
267
+ ```console
268
+ $ npx skills add yaroslav/audition
269
+ ```
239
270
 
240
271
  ## Extending
241
272
 
data/exe/audition CHANGED
@@ -1,6 +1,14 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # When run from a source checkout, prefer the sibling lib over any
5
+ # installed audition gem; installed binstubs never hit this branch
6
+ # because their lib is where they load from anyway.
7
+ lib = File.expand_path("../lib", __dir__)
8
+ if File.file?(File.join(lib, "audition.rb"))
9
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
10
+ end
11
+
4
12
  require "audition"
5
13
 
6
14
  exit Audition::CLI.run(ARGV)
@@ -11,7 +11,7 @@ module Audition
11
11
  CONCURRENCY = 4
12
12
 
13
13
  Row = Data.define(:name, :version, :verdict, :errors,
14
- :dep_errors, :warnings, :fixable, :status)
14
+ :dep_errors, :warnings, :infos, :fixable, :status)
15
15
 
16
16
  VERDICT_ORDER = {
17
17
  :not_ready => 0, :blocked => 1, :risky => 2, :ready => 3, nil => 4
@@ -66,38 +66,85 @@ module Audition
66
66
  parser.specs.map { |s| [s.name, s.version.to_s] }.uniq
67
67
  end
68
68
 
69
+ # Mirrors CLI#audit: the locked version's own spec is audited,
70
+ # and findings pass through the gem's inline pragmas and its
71
+ # .audition.yml (exclude plus checks.disable) so a sweep row
72
+ # agrees with a direct audit of the same gem.
69
73
  def audit_gem(name, version)
70
- target = Target.detect(name)
71
- findings = static_findings(target)
74
+ spec = Gem::Specification.find_by_name(name, version)
75
+ target = gem_target(spec)
76
+ config = Config.load(target.root)
77
+ directives = Directives.new
78
+ findings = filter(static_findings(target, config),
79
+ directives, config)
72
80
  results = dynamic_results(target)
81
+ findings += filter(results.flat_map(&:findings),
82
+ directives, config)
73
83
  report = Report.new(
74
84
  target_type: :gem,
75
85
  target_root: target.root,
76
- findings: findings + results.flat_map(&:findings),
86
+ findings: findings,
77
87
  dynamic_results: results
78
88
  )
79
89
  counts = report.counts
80
90
  Row.new(
81
91
  name: name, version: version, verdict: report.verdict,
82
92
  errors: counts[:error], dep_errors: counts[:dep_error],
83
- warnings: counts[:warning], fixable: counts[:fixable],
84
- status: "ok"
93
+ warnings: counts[:warning], infos: counts[:info],
94
+ fixable: counts[:fixable], status: "ok"
85
95
  )
86
- rescue Error
96
+ rescue Gem::MissingSpecError
97
+ failed_row(name, version, "not installed")
98
+ rescue => e
99
+ failed_row(name, version, "failed: #{e.class}")
100
+ end
101
+
102
+ def failed_row(name, version, status)
87
103
  Row.new(
88
104
  name: name, version: version, verdict: nil, errors: 0,
89
- dep_errors: 0, warnings: 0, fixable: 0,
90
- status: "not installed"
105
+ dep_errors: 0, warnings: 0, infos: 0, fixable: 0,
106
+ status: status
107
+ )
108
+ end
109
+
110
+ def gem_target(spec)
111
+ root = spec.gem_dir
112
+ Target.new(
113
+ type: :gem,
114
+ root: root,
115
+ ruby_files: spec.require_paths.flat_map do |rp|
116
+ ruby_files_under(File.join(root, rp))
117
+ end,
118
+ entry: {mode: :require, feature: spec.name, root: root}
91
119
  )
92
120
  end
93
121
 
122
+ # Same glob discipline as Target: skip vendored trees and
123
+ # dotdirs.
124
+ def ruby_files_under(dir)
125
+ Dir[File.join(dir, "**", "*.rb")].reject do |path|
126
+ path.delete_prefix("#{dir}/").split("/").any? do |part|
127
+ Target::EXCLUDED_DIRS.include?(part) ||
128
+ part.start_with?(".")
129
+ end
130
+ end.sort
131
+ end
132
+
133
+ def filter(findings, directives, config)
134
+ directives.filter(findings).reject do |finding|
135
+ config.check_disabled?(finding.check)
136
+ end
137
+ end
138
+
94
139
  # Worker threads already parallelize across gems; per-gem
95
140
  # scanning stays serial to avoid a Ractor storm.
96
- def static_findings(target)
141
+ def static_findings(target, config)
142
+ files = target.ruby_files.reject do |file|
143
+ config.excluded?(file.delete_prefix("#{target.root}/"))
144
+ end
97
145
  per_file = Static::Analyzer.new
98
- .analyze_paths(target.ruby_files, workers: 1)
99
- per_file + Static::GraphAudit.new
100
- .analyze_paths(target.ruby_files)
146
+ .analyze_paths(files, workers: 1)
147
+ per_file + Static::GraphAudit.new.analyze_paths(files)
101
148
  end
102
149
 
103
150
  def dynamic_results(target)
data/lib/audition/cli.rb CHANGED
@@ -135,6 +135,9 @@ module Audition
135
135
  end
136
136
 
137
137
  def audit(target, options)
138
+ # Read the comparison report up front: a missing or broken
139
+ # file should be a fast usage error, not a post-audit crash.
140
+ compare = load_compare(options[:compare])
138
141
  config = Config.load(target.root)
139
142
  options = apply_config(options, config)
140
143
  directives = Directives.new
@@ -162,6 +165,7 @@ module Audition
162
165
  return 0
163
166
  end
164
167
 
168
+ visible = all
165
169
  all, baselined = apply_baseline(all, target, options)
166
170
 
167
171
  report = Report.new(
@@ -173,34 +177,43 @@ module Audition
173
177
  baselined: baselined
174
178
  )
175
179
  emit(report, options)
176
- if options[:compare] && options[:format] != :json
177
- emit_comparison(report, options)
180
+ if compare && options[:format] != :json
181
+ emit_comparison(compare, visible, target, options)
178
182
  end
179
183
  exit_code(report, options)
180
184
  end
181
185
 
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
186
+ def load_compare(path)
187
+ return nil unless path
188
+
189
+ JSON.parse(File.read(path))
190
+ rescue JSON::ParserError, SystemCallError => e
191
+ raise Error, "cannot read #{path}: #{e.message}"
192
+ end
188
193
 
194
+ # Fingerprints are root-relative on both sides, so an absolute
195
+ # old run compares cleanly against a relative new run. The
196
+ # comparison sees pre-baseline visible findings: a baselined
197
+ # finding still exists and must not count as fixed.
198
+ def emit_comparison(old, findings, target, options)
199
+ old_root = old.dig("target", "root").to_s
189
200
  budget = Hash.new(0)
190
201
  old.fetch("findings", []).each do |f|
191
- budget[[f["check"], f["path"], f["message"]]] += 1
202
+ key = [f["check"], relativize(f["path"].to_s, old_root),
203
+ f["message"]]
204
+ budget[key] += 1
192
205
  end
193
206
  total_old = budget.values.sum
194
207
 
195
- introduced = report.findings.reject do |f|
196
- key = [f.check, f.path, f.message]
208
+ introduced = findings.reject do |f|
209
+ key = [f.check, relativize(f.path.to_s, target.root),
210
+ f.message]
197
211
  next false unless budget[key].positive?
198
212
 
199
213
  budget[key] -= 1
200
214
  true
201
215
  end
202
- fixed = total_old -
203
- (report.findings.size - introduced.size)
216
+ fixed = total_old - (findings.size - introduced.size)
204
217
 
205
218
  s = style(options)
206
219
  @stdout.puts(
@@ -213,6 +226,12 @@ module Audition
213
226
  end
214
227
  end
215
228
 
229
+ def relativize(path, root)
230
+ return path if root.empty?
231
+
232
+ path.delete_prefix("#{root}/")
233
+ end
234
+
216
235
  def apply_config(options, config)
217
236
  merged = options.dup
218
237
  if config.fail_on && !options[:explicit].include?(:fail_on)
@@ -247,6 +266,8 @@ module Audition
247
266
  per_file + Static::GraphAudit.new.analyze_paths(files)
248
267
  end
249
268
 
269
+ # Fix chatter goes to stderr: stdout carries the report, which
270
+ # must stay parseable under --format json.
250
271
  def run_fix(target, findings, options)
251
272
  fixer = Fixer.new(unsafe: options[:unsafe])
252
273
  if options[:dry_run]
@@ -256,30 +277,33 @@ module Audition
256
277
 
257
278
  applied = fixer.apply(findings)
258
279
  total = applied.values.sum
259
- @stdout.puts(
280
+ if total.zero?
281
+ @stderr.puts("nothing to fix")
282
+ return findings
283
+ end
284
+
285
+ @stderr.puts(
260
286
  "fixed #{total} finding(s) in #{applied.size} file(s)"
261
287
  )
262
- return findings unless total.positive?
263
-
264
288
  filter(static_findings(target, Config.load(target.root)),
265
289
  Directives.new, Config.load(target.root))
266
290
  end
267
291
 
268
292
  def render_preview(previews, options)
269
- s = style(options)
293
+ s = style(options, io: @stderr)
270
294
  previews.each do |preview|
271
- @stdout.puts(s.bold(preview[:path]))
295
+ @stderr.puts(s.bold(preview[:path]))
272
296
  preview[:hunks].each do |hunk|
273
- @stdout.puts(" @ line #{hunk[:line]}")
297
+ @stderr.puts(" @ line #{hunk[:line]}")
274
298
  hunk[:old].each_line do |line|
275
- @stdout.puts(s.red(" - #{line.chomp}"))
299
+ @stderr.puts(s.red(" - #{line.chomp}"))
276
300
  end
277
301
  hunk[:new].each_line do |line|
278
- @stdout.puts(s.green(" + #{line.chomp}"))
302
+ @stderr.puts(s.green(" + #{line.chomp}"))
279
303
  end
280
304
  end
281
305
  end
282
- @stdout.puts("dry run: no files were changed")
306
+ @stderr.puts("dry run: no files were changed")
283
307
  end
284
308
 
285
309
  def prober(options)
@@ -294,11 +318,11 @@ module Audition
294
318
  end
295
319
  end
296
320
 
297
- def style(options)
321
+ def style(options, io: @stdout)
298
322
  if options[:plain]
299
323
  Report::Style.new(color: false, hyperlinks: false)
300
324
  else
301
- Report::Style.detect(io: @stdout)
325
+ Report::Style.detect(io: io)
302
326
  end
303
327
  end
304
328
 
@@ -338,9 +362,21 @@ module Audition
338
362
  if options[:format] == :json
339
363
  emit_sweep_json(rows)
340
364
  else
341
- emit_sweep_table(rows)
365
+ emit_sweep_table(rows, options)
342
366
  end
343
- (rows.any? { |r| r.verdict == :not_ready }) ? 1 : 0
367
+ threshold = SEVERITIES.fetch(options[:fail_on])
368
+ (rows.any? { |r| row_failed?(r, threshold) }) ? 1 : 0
369
+ end
370
+
371
+ # Same contract as a direct audit: a row fails when it carries
372
+ # findings at or above --fail-on; not-ready rows always fail.
373
+ def row_failed?(row, threshold)
374
+ return true if row.verdict == :not_ready
375
+
376
+ hits = row.errors + row.dep_errors
377
+ hits += row.warnings if threshold <= SEVERITIES[:warning]
378
+ hits += row.infos if threshold <= SEVERITIES[:info]
379
+ hits.positive?
344
380
  end
345
381
 
346
382
  def sweep_progress
@@ -351,7 +387,19 @@ module Audition
351
387
  end
352
388
  end
353
389
 
354
- def emit_sweep_table(rows)
390
+ # Cells arrive preformatted: coercion would render a version
391
+ # like "3.2" as 3.200. Color follows the CLI's own detection
392
+ # because the table gem reads the global $stdout and cannot
393
+ # see --plain. Autolayout only on ttys; pipes keep full width.
394
+ def table_opts(options)
395
+ tty = @stdout.respond_to?(:tty?) && @stdout.tty?
396
+ {
397
+ color: style(options).color?, coerce: false,
398
+ layout: tty, placeholder: "-"
399
+ }
400
+ end
401
+
402
+ def emit_sweep_table(rows, options)
355
403
  ready = rows.count { |r| r.verdict == :ready }
356
404
  table = rows.map do |r|
357
405
  {
@@ -365,7 +413,9 @@ module Audition
365
413
  "status" => r.status
366
414
  }
367
415
  end
368
- @stdout.puts(TableTennis.new(table, layout: false).to_s)
416
+ @stdout.puts(TableTennis.new(
417
+ table, zebra: true, **table_opts(options)
418
+ ).to_s)
369
419
  @stdout.puts(
370
420
  "#{ready} of #{rows.size} gems ractor-ready"
371
421
  )
@@ -383,6 +433,7 @@ module Audition
383
433
  "errors" => r.errors,
384
434
  "dependency_errors" => r.dep_errors,
385
435
  "warnings" => r.warnings,
436
+ "infos" => r.infos,
386
437
  "fixable" => r.fixable,
387
438
  "status" => r.status
388
439
  }
@@ -408,7 +459,9 @@ module Audition
408
459
  }
409
460
  end
410
461
  @stdout.puts("ruby #{RUBY_VERSION} at #{RbConfig.ruby}")
411
- @stdout.puts(TableTennis.new(rows, layout: false).to_s)
462
+ @stdout.puts(
463
+ TableTennis.new(rows, **table_opts(options)).to_s
464
+ )
412
465
  0
413
466
  end
414
467
  end
@@ -23,16 +23,20 @@ module Audition
23
23
  disabled_checks: []}
24
24
  )
25
25
 
26
+ FAIL_ON_LEVELS = %w[error warning info].freeze
27
+
26
28
  attr_reader :fail_on, :timeout, :exclude, :disabled_checks
27
29
 
28
30
  # @param root [String] directory that may contain .audition.yml
29
31
  # @return [Config] empty config when the file is absent
30
- # @raise [Audition::Error] on malformed YAML
32
+ # @raise [Audition::Error] on malformed YAML, a non-mapping
33
+ # document, or an unknown fail_on level
31
34
  def self.load(root)
32
35
  path = File.join(root.to_s, FILE)
33
36
  return new(**EMPTY) unless File.file?(path)
34
37
 
35
38
  data = YAML.safe_load_file(path) || {}
39
+ validate!(path, data)
36
40
  new(
37
41
  fail_on: data["fail_on"]&.to_sym,
38
42
  timeout: data["timeout"],
@@ -44,6 +48,22 @@ module Audition
44
48
  raise Error, "#{path}: #{e.message}"
45
49
  end
46
50
 
51
+ def self.validate!(path, data)
52
+ unless data.is_a?(Hash)
53
+ raise Error,
54
+ "#{path}: expected a YAML mapping, got #{data.class}"
55
+ end
56
+
57
+ fail_on = data["fail_on"]
58
+ return if fail_on.nil? ||
59
+ FAIL_ON_LEVELS.include?(fail_on.to_s)
60
+
61
+ raise Error,
62
+ "#{path}: fail_on must be one of error, warning, or " \
63
+ "info (got #{fail_on.inspect})"
64
+ end
65
+ private_class_method :validate!
66
+
47
67
  def initialize(fail_on:, timeout:, exclude:, disabled_checks:)
48
68
  @fail_on = fail_on
49
69
  @timeout = timeout
@@ -51,9 +71,14 @@ module Audition
51
71
  @disabled_checks = disabled_checks
52
72
  end
53
73
 
74
+ # Globs follow .gitignore-style expectations: `*` stays within
75
+ # one directory level, `dir/**` covers the whole subtree, and a
76
+ # leading `./` is tolerated.
54
77
  def excluded?(relative_path)
55
- exclude.any? do |pattern|
56
- next true if File.fnmatch?(pattern, relative_path)
78
+ exclude.any? do |raw|
79
+ pattern = raw.delete_prefix("./")
80
+ next true if File.fnmatch?(pattern, relative_path,
81
+ File::FNM_PATHNAME | File::FNM_EXTGLOB)
57
82
 
58
83
  prefix = pattern[%r{\A(.+?)/\*\*(?:/\*+)?\z}, 1]
59
84
  prefix && relative_path.start_with?("#{prefix}/")
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  module Audition
4
6
  # Same-line suppression pragmas:
5
7
  #
@@ -8,9 +10,11 @@ module Audition
8
10
  #
9
11
  # A bare pragma silences every check on that line; otherwise only
10
12
  # the listed check names. Applied to any finding carrying a real
11
- # path and line, including runtime findings.
13
+ # path and line, including runtime findings. Pragmas are read
14
+ # from Prism's comment list, never from raw lines: pragma-shaped
15
+ # text inside a string literal is data, not a directive.
12
16
  class Directives
13
- PATTERN = /#\s*audition:disable\b[ \t]*(?<list>[^#\n]*)/
17
+ PATTERN = /#\s*audition:disable\b[ \t]*(?<list>[\w \t,-]*)/
14
18
 
15
19
  def initialize
16
20
  @by_path = {}
@@ -37,16 +41,30 @@ module Audition
37
41
  @by_path[path] ||= scan(path)
38
42
  end
39
43
 
44
+ # Every pragma comment on a line contributes; an explicit
45
+ # disable is never shadowed by an earlier pragma, and a bare
46
+ # pragma (empty list) wins over any list.
40
47
  def scan(path)
41
48
  return {} unless File.file?(path)
42
49
 
43
50
  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?)
51
+ Prism.parse_file(path).comments.each do |comment|
52
+ comment.slice.scan(PATTERN) do
53
+ line = comment.location.start_line
54
+ checks = Regexp.last_match[:list]
55
+ .split(/[,\s]+/).reject(&:empty?)
56
+ if directives.key?(line)
57
+ existing = directives[line]
58
+ directives[line] =
59
+ if existing.empty? || checks.empty?
60
+ []
61
+ else
62
+ existing | checks
63
+ end
64
+ else
65
+ directives[line] = checks
66
+ end
67
+ end
50
68
  end
51
69
  directives
52
70
  rescue SystemCallError
@@ -55,13 +55,27 @@ module AuditionHarness
55
55
  end
56
56
  out.puts(JSON.generate(result))
57
57
  rescue Exception => e
58
- out.puts(JSON.generate("error" => describe_error(e)))
58
+ begin
59
+ out.puts(JSON.generate("error" => describe_error(e)))
60
+ rescue Exception
61
+ out.puts('{"error":{"class":"HarnessFailure",' \
62
+ '"message":"unreportable error"}}')
63
+ end
59
64
  end
60
65
 
66
+ # Exception messages can carry arbitrary bytes (C extensions,
67
+ # binary filenames); unscrubbed they blow up JSON.generate
68
+ # inside the rescue and the harness dies without output.
61
69
  def describe_error(error)
62
70
  root = unwrap(error)
63
- {"class" => root.class.name,
64
- "message" => root.message.to_s[0, 500]}
71
+ {"class" => scrub(root.class.name.to_s),
72
+ "message" => scrub(root.message.to_s)[0, 500]}
73
+ end
74
+
75
+ def scrub(text)
76
+ text.dup.force_encoding(Encoding::UTF_8).scrub
77
+ rescue Exception
78
+ "(unprintable)"
65
79
  end
66
80
 
67
81
  def unwrap(error)
@@ -111,7 +125,17 @@ module AuditionHarness
111
125
  $LOAD_PATH.unshift(lp) # audition:disable global-variables
112
126
  end
113
127
  before = Object.constants
114
- require payload.fetch("feature") # audition:disable runtime-require
128
+ feature = payload.fetch("feature")
129
+ begin
130
+ require feature # audition:disable runtime-require
131
+ rescue LoadError
132
+ # Dashed gem names conventionally ship slashed entry files
133
+ # (rspec-mocks provides rspec/mocks).
134
+ slashed = feature.tr("-", "/")
135
+ raise if slashed == feature
136
+
137
+ require slashed # audition:disable runtime-require
138
+ end
115
139
  scan(Object.constants - before, root: payload["root"])
116
140
  end
117
141
 
@@ -192,7 +216,9 @@ module AuditionHarness
192
216
  rescue Exception
193
217
  nil
194
218
  end
195
- own = root.nil? || path.nil? || path.start_with?(root)
219
+ # The separator matters: /x/app must not claim /x/app-helpers.
220
+ own = root.nil? || path.nil? || path == root ||
221
+ path.start_with?(root + File::SEPARATOR)
196
222
  {"path" => path, "line" => line, "own" => own}
197
223
  end
198
224