audition 0.2.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 +4 -4
- data/README.md +23 -0
- data/lib/audition/bundle_sweep.rb +60 -13
- data/lib/audition/cli.rb +82 -29
- data/lib/audition/config.rb +28 -3
- data/lib/audition/directives.rb +26 -8
- data/lib/audition/dynamic/harness.rb +31 -5
- data/lib/audition/dynamic/prober.rb +56 -12
- data/lib/audition/fixer.rb +10 -4
- data/lib/audition/report.rb +37 -8
- data/lib/audition/rewriters.rb +373 -49
- data/lib/audition/static/analyzer.rb +7 -1
- data/lib/audition/static/checks/base.rb +19 -4
- data/lib/audition/static/checks/global_variables.rb +2 -1
- data/lib/audition/static/checks/mutable_constants.rb +84 -13
- data/lib/audition/static/checks/ractor_isolation.rb +4 -0
- data/lib/audition/static/checks/unsafe_calls.rb +1 -1
- data/lib/audition/static/graph_audit.rb +153 -9
- data/lib/audition/static/literal_classifier.rb +7 -2
- data/lib/audition/static/source_file.rb +67 -4
- data/lib/audition/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e82f1a081cebcd3209cfbced17ecb539949cf4590c785e1b6873535473090756
|
|
4
|
+
data.tar.gz: 7d6c4358bae0e8514e15452c0f0dba8bde7a53a9dd342ec9b96e9cf53a4c853f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1ddb8750389aa6734d895a4a84e32fb8a0cb0feca7d26c83e0803e8f28abfd6f16fd3255f352ba3e4d3534192636da3d6f73f651fd90dff8d18654384f779bf
|
|
7
|
+
data.tar.gz: dfe09ec14b4f3058f2df766129a78d6cfed1dfd65bd72ce512a6cfeea65e1ae42ef01bf0cb9d0c9f1d890973dcbcc052396dbca9cf445e0bf79d08676d8f3c45
|
data/README.md
CHANGED
|
@@ -113,6 +113,7 @@ proxying) and its verified semantics.
|
|
|
113
113
|
- [Usage](#usage)
|
|
114
114
|
- [Adopting incrementally](#adopting-incrementally)
|
|
115
115
|
- [What it catches](#what-it-catches)
|
|
116
|
+
- [Agent skill](#agent-skill)
|
|
116
117
|
- [Extending](#extending)
|
|
117
118
|
- [Development](#development)
|
|
118
119
|
- [License](#license)
|
|
@@ -245,6 +246,28 @@ Dynamic, on the live object graph:
|
|
|
245
246
|
- Boots Rails (`config/environment.rb`), eager-loads, and sweeps
|
|
246
247
|
the application's namespaces.
|
|
247
248
|
|
|
249
|
+
## Agent skill
|
|
250
|
+
|
|
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).
|
|
256
|
+
|
|
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
|
+
```
|
|
270
|
+
|
|
248
271
|
## Extending
|
|
249
272
|
|
|
250
273
|
Checks are written in a small declarative DSL and can be registered
|
|
@@ -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
|
-
|
|
71
|
-
|
|
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
|
|
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],
|
|
84
|
-
status: "ok"
|
|
93
|
+
warnings: counts[:warning], infos: counts[:info],
|
|
94
|
+
fixable: counts[:fixable], status: "ok"
|
|
85
95
|
)
|
|
86
|
-
rescue
|
|
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:
|
|
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(
|
|
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
|
|
177
|
-
emit_comparison(
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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 =
|
|
196
|
-
key = [f.check, f.path,
|
|
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
|
-
|
|
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
|
-
@
|
|
295
|
+
@stderr.puts(s.bold(preview[:path]))
|
|
272
296
|
preview[:hunks].each do |hunk|
|
|
273
|
-
@
|
|
297
|
+
@stderr.puts(" @ line #{hunk[:line]}")
|
|
274
298
|
hunk[:old].each_line do |line|
|
|
275
|
-
@
|
|
299
|
+
@stderr.puts(s.red(" - #{line.chomp}"))
|
|
276
300
|
end
|
|
277
301
|
hunk[:new].each_line do |line|
|
|
278
|
-
@
|
|
302
|
+
@stderr.puts(s.green(" + #{line.chomp}"))
|
|
279
303
|
end
|
|
280
304
|
end
|
|
281
305
|
end
|
|
282
|
-
@
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
462
|
+
@stdout.puts(
|
|
463
|
+
TableTennis.new(rows, **table_opts(options)).to_s
|
|
464
|
+
)
|
|
412
465
|
0
|
|
413
466
|
end
|
|
414
467
|
end
|
data/lib/audition/config.rb
CHANGED
|
@@ -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 |
|
|
56
|
-
|
|
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}/")
|
data/lib/audition/directives.rb
CHANGED
|
@@ -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>[
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -334,8 +334,13 @@ module Audition
|
|
|
334
334
|
|
|
335
335
|
# -- subprocess plumbing -------------------------------------
|
|
336
336
|
|
|
337
|
+
# Harness output can carry arbitrary target bytes; force
|
|
338
|
+
# valid UTF-8 before any string work or a binary exception
|
|
339
|
+
# message crashes the whole run.
|
|
337
340
|
def run(mode, payload = {})
|
|
338
341
|
out, err, timed_out = execute(mode, payload)
|
|
342
|
+
out = sanitize(out)
|
|
343
|
+
err = sanitize(err)
|
|
339
344
|
if timed_out
|
|
340
345
|
return {"error" => {
|
|
341
346
|
"class" => "AuditionTimeout",
|
|
@@ -346,29 +351,68 @@ module Audition
|
|
|
346
351
|
rescue JSON::ParserError
|
|
347
352
|
{"error" => {
|
|
348
353
|
"class" => "HarnessFailure",
|
|
349
|
-
"message" =>
|
|
354
|
+
"message" => err.split("\n").last(5).join("; ")
|
|
350
355
|
}}
|
|
351
356
|
end
|
|
352
357
|
|
|
358
|
+
def sanitize(text)
|
|
359
|
+
(text || "").dup.force_encoding(Encoding::UTF_8).scrub
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# The harness leads its own process group so a timeout kills
|
|
363
|
+
# every descendant, and the pipe readers are bounded: a
|
|
364
|
+
# child the target spawned inherits our pipes and would
|
|
365
|
+
# otherwise hold the read until it exits, defeating the
|
|
366
|
+
# timeout and leaving orphans behind.
|
|
353
367
|
def execute(mode, payload)
|
|
354
368
|
cmd = [@ruby, "-W0", HARNESS, mode]
|
|
355
|
-
Open3.popen3(*cmd) do |stdin, stdout, stderr, wait|
|
|
369
|
+
Open3.popen3(*cmd, pgroup: true) do |stdin, stdout, stderr, wait|
|
|
356
370
|
stdin.write(JSON.generate(payload))
|
|
357
371
|
stdin.close
|
|
358
|
-
out_reader =
|
|
359
|
-
err_reader =
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
372
|
+
out_reader = reader(stdout)
|
|
373
|
+
err_reader = reader(stderr)
|
|
374
|
+
timed_out = wait.join(@timeout).nil?
|
|
375
|
+
kill_group(wait.pid) if timed_out
|
|
376
|
+
unless drain(out_reader, err_reader)
|
|
377
|
+
kill_group(wait.pid)
|
|
378
|
+
unless drain(out_reader, err_reader)
|
|
379
|
+
close_quietly(stdout)
|
|
380
|
+
close_quietly(stderr)
|
|
367
381
|
end
|
|
368
|
-
[out_reader.value.to_s, err_reader.value.to_s, true]
|
|
369
382
|
end
|
|
383
|
+
[out_reader.value.to_s, err_reader.value.to_s, timed_out]
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Accumulates chunks so a forced close still yields what
|
|
388
|
+
# arrived before it; a plain IO#read would lose everything.
|
|
389
|
+
def reader(io)
|
|
390
|
+
Thread.new do
|
|
391
|
+
buffer = String.new(encoding: Encoding::BINARY)
|
|
392
|
+
begin
|
|
393
|
+
loop { buffer << io.readpartial(65_536) }
|
|
394
|
+
rescue IOError
|
|
395
|
+
buffer
|
|
396
|
+
end
|
|
397
|
+
buffer
|
|
370
398
|
end
|
|
371
399
|
end
|
|
400
|
+
|
|
401
|
+
def drain(*threads)
|
|
402
|
+
threads.all? { |thread| thread.join(2) }
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def kill_group(pid)
|
|
406
|
+
Process.kill("KILL", -pid)
|
|
407
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
408
|
+
nil
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def close_quietly(io)
|
|
412
|
+
io.close
|
|
413
|
+
rescue IOError
|
|
414
|
+
nil
|
|
415
|
+
end
|
|
372
416
|
end
|
|
373
417
|
end
|
|
374
418
|
end
|
data/lib/audition/fixer.rb
CHANGED
|
@@ -61,8 +61,12 @@ module Audition
|
|
|
61
61
|
edits = build_edits(path, source, group)
|
|
62
62
|
next if edits.empty?
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
# Applied bottom-up; the explicit index keeps same-offset
|
|
65
|
+
# inserts in plan order (sort_by is not stable).
|
|
66
|
+
ordered = edits.each_with_index.sort_by do |edit, index|
|
|
67
|
+
[-edit.start_offset, index]
|
|
68
|
+
end.map(&:first)
|
|
69
|
+
Plan.new(path: path, source: source, edits: ordered)
|
|
66
70
|
end
|
|
67
71
|
end
|
|
68
72
|
|
|
@@ -75,8 +79,10 @@ module Audition
|
|
|
75
79
|
file = Static::SourceFile.new(source: source, path: path)
|
|
76
80
|
if file.valid_syntax?
|
|
77
81
|
magic = Rewriters::MagicComments.plan(file, group)
|
|
78
|
-
planned
|
|
79
|
-
|
|
82
|
+
planned = Rewriters.resolve(
|
|
83
|
+
Rewriters::Memoization.plan(file, group) +
|
|
84
|
+
Rewriters::WriteOnce.plan(file, group)
|
|
85
|
+
)
|
|
80
86
|
end
|
|
81
87
|
end
|
|
82
88
|
|