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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 334e1fbfc5a78984722aad73810af312279f67f72bae4b5e60bbbf9433b0f404
4
- data.tar.gz: 26c3023a7cdeeee748b61619277c8e749b5a0204b82474b4f06220e9c0fd737d
3
+ metadata.gz: e82f1a081cebcd3209cfbced17ecb539949cf4590c785e1b6873535473090756
4
+ data.tar.gz: 7d6c4358bae0e8514e15452c0f0dba8bde7a53a9dd342ec9b96e9cf53a4c853f
5
5
  SHA512:
6
- metadata.gz: 4c91b4b8ff157e2ff268533657c9dab0c61dc5cc2c0e568790f6c45bee16dfee05f7b0ff830b90aeff43d150278292b967a52dd8ce9baff926b33b23b0bc4992
7
- data.tar.gz: 9e639f1a77f804fcc79a196d109fc967a683b07c30411a6832ac9b5315428c30338e1e31b44c7ee448c724fd8a48ce7f7fb6a42411bf0194531b25597cb8e66a
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
- 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
 
@@ -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" => (err || "").split("\n").last(5).join("; ")
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 = Thread.new { stdout.read }
359
- err_reader = Thread.new { stderr.read }
360
- if wait.join(@timeout)
361
- [out_reader.value, err_reader.value, false]
362
- else
363
- begin
364
- Process.kill("KILL", wait.pid)
365
- rescue Errno::ESRCH
366
- nil
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
@@ -61,8 +61,12 @@ module Audition
61
61
  edits = build_edits(path, source, group)
62
62
  next if edits.empty?
63
63
 
64
- Plan.new(path: path, source: source,
65
- edits: edits.sort_by { |e| -e.start_offset })
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 += Rewriters::Memoization.plan(file, group)
79
- planned += Rewriters::WriteOnce.plan(file, group)
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