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.
@@ -201,21 +201,33 @@ module Audition
201
201
  findings
202
202
  end
203
203
 
204
+ # Class-level state holding only shareable values is the
205
+ # warmed frozen-memoization shape: reads are legal from any
206
+ # Ractor, so it rates an info note; unshareable values are
207
+ # hard errors.
204
208
  def class_state_finding(entry, label)
205
209
  unshareable = entry.fetch("unshareable", [])
206
210
  hot = unshareable.any?
207
211
  detail =
208
212
  hot ? " (unshareable: #{unshareable.join(", ")})" : ""
213
+ why =
214
+ if hot
215
+ "Writes raise Ractor::IsolationError from non-main " \
216
+ "Ractors; reads raise too while the value is " \
217
+ "unshareable. #{RUNTIME_WHY}"
218
+ else
219
+ "Every value observed here is shareable, so reads " \
220
+ "from non-main Ractors are legal; only late writes " \
221
+ "would raise Ractor::IsolationError. #{RUNTIME_WHY}"
222
+ end
209
223
  runtime_finding(
210
224
  entry, label,
211
225
  check: "runtime-class-state",
212
- severity: hot ? :error : :warning,
226
+ severity: hot ? :error : :info,
213
227
  message: "class-level state " \
214
228
  "#{entry["ivars"].join(", ")} on " \
215
229
  "#{entry["const"]}#{detail}",
216
- why: "Writes raise Ractor::IsolationError from non-main " \
217
- "Ractors; reads raise too while the value is " \
218
- "unshareable. #{RUNTIME_WHY}",
230
+ why: why,
219
231
  fix: "Precompute and freeze at load, use " \
220
232
  "Ractor.store_if_absent, or keep per-Ractor state."
221
233
  )
@@ -322,8 +334,13 @@ module Audition
322
334
 
323
335
  # -- subprocess plumbing -------------------------------------
324
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.
325
340
  def run(mode, payload = {})
326
341
  out, err, timed_out = execute(mode, payload)
342
+ out = sanitize(out)
343
+ err = sanitize(err)
327
344
  if timed_out
328
345
  return {"error" => {
329
346
  "class" => "AuditionTimeout",
@@ -334,29 +351,68 @@ module Audition
334
351
  rescue JSON::ParserError
335
352
  {"error" => {
336
353
  "class" => "HarnessFailure",
337
- "message" => (err || "").split("\n").last(5).join("; ")
354
+ "message" => err.split("\n").last(5).join("; ")
338
355
  }}
339
356
  end
340
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.
341
367
  def execute(mode, payload)
342
368
  cmd = [@ruby, "-W0", HARNESS, mode]
343
- Open3.popen3(*cmd) do |stdin, stdout, stderr, wait|
369
+ Open3.popen3(*cmd, pgroup: true) do |stdin, stdout, stderr, wait|
344
370
  stdin.write(JSON.generate(payload))
345
371
  stdin.close
346
- out_reader = Thread.new { stdout.read }
347
- err_reader = Thread.new { stderr.read }
348
- if wait.join(@timeout)
349
- [out_reader.value, err_reader.value, false]
350
- else
351
- begin
352
- Process.kill("KILL", wait.pid)
353
- rescue Errno::ESRCH
354
- 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)
355
381
  end
356
- [out_reader.value.to_s, err_reader.value.to_s, true]
357
382
  end
383
+ [out_reader.value.to_s, err_reader.value.to_s, timed_out]
358
384
  end
359
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
398
+ end
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
360
416
  end
361
417
  end
362
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
 
@@ -105,37 +111,65 @@ module Audition
105
111
  accepted
106
112
  end
107
113
 
114
+ # Prism offsets are byte offsets; splicing must happen on a
115
+ # binary copy or every edit after the first multibyte
116
+ # character lands short (addressable's unicode tables were
117
+ # the crash test).
108
118
  def patched(plan)
109
- source = plan.source.dup
119
+ encoding = plan.source.encoding
120
+ bytes = plan.source.dup.force_encoding(Encoding::BINARY)
110
121
  plan.edits.each do |edit|
111
- source[edit.start_offset...edit.end_offset] =
112
- edit.replacement
122
+ bytes[edit.start_offset...edit.end_offset] =
123
+ edit.replacement.dup.force_encoding(Encoding::BINARY)
113
124
  end
114
- source
125
+ bytes.force_encoding(encoding)
115
126
  end
116
127
 
128
+ # Edits whose line windows overlap (a guard deletion runs into
129
+ # the write it pairs with) render as one hunk, so the preview
130
+ # never repeats a line in two half-applied states. All window
131
+ # math runs on a binary copy: the offsets are bytes.
117
132
  def hunks(plan)
118
- plan.edits.sort_by(&:start_offset).map do |edit|
119
- line_start =
120
- if edit.start_offset.zero?
121
- 0
122
- else
123
- before = plan.source.rindex("\n", edit.start_offset - 1)
124
- before ? before + 1 : 0
125
- end
126
- line_end = plan.source.index("\n", edit.end_offset) ||
127
- plan.source.length
128
- old = plan.source[line_start...line_end]
133
+ encoding = plan.source.encoding
134
+ raw = plan.source.dup.force_encoding(Encoding::BINARY)
135
+ groups = []
136
+ plan.edits.sort_by(&:start_offset).each do |edit|
137
+ from, upto = line_window(raw, edit)
138
+ if groups.any? && from <= groups.last[:upto]
139
+ last = groups.last
140
+ last[:upto] = [last[:upto], upto].max
141
+ last[:edits] << edit
142
+ else
143
+ groups << {from: from, upto: upto, edits: [edit]}
144
+ end
145
+ end
146
+ groups.map do |group|
147
+ old = raw[group[:from]...group[:upto]]
129
148
  updated = old.dup
130
- span = ((edit.start_offset - line_start)...
131
- (edit.end_offset - line_start))
132
- updated[span] = edit.replacement
149
+ group[:edits].reverse_each do |edit|
150
+ span = ((edit.start_offset - group[:from])...
151
+ (edit.end_offset - group[:from]))
152
+ updated[span] =
153
+ edit.replacement.dup.force_encoding(Encoding::BINARY)
154
+ end
133
155
  {
134
- line: plan.source[0, edit.start_offset].count("\n") + 1,
135
- old: old,
136
- new: updated.chomp
156
+ line: raw[0, group[:from]].count("\n") + 1,
157
+ old: old.force_encoding(encoding),
158
+ new: updated.chomp.force_encoding(encoding)
137
159
  }
138
160
  end
139
161
  end
162
+
163
+ def line_window(raw, edit)
164
+ from =
165
+ if edit.start_offset.zero?
166
+ 0
167
+ else
168
+ before = raw.rindex("\n", edit.start_offset - 1)
169
+ before ? before + 1 : 0
170
+ end
171
+ upto = raw.index("\n", edit.end_offset) || raw.length
172
+ [from, upto]
173
+ end
140
174
  end
141
175
  end
@@ -34,12 +34,16 @@ module Audition
34
34
  @hyperlinks = hyperlinks
35
35
  end
36
36
 
37
+ def color?
38
+ @color
39
+ end
40
+
37
41
  def glyph(kind)
38
42
  GLYPHS.fetch(kind)[@color ? 0 : 1]
39
43
  end
40
44
 
41
45
  PAINTS.each do |name|
42
- define_method(name) do |text|
46
+ define_method(name) do |text| # audition:disable unsafe-calls
43
47
  @pastel.public_send(name, text)
44
48
  end
45
49
  end
@@ -110,9 +114,10 @@ module Audition
110
114
  return :not_ready if own_errors?
111
115
  return :blocked if dependency_errors? ||
112
116
  dynamic_results.any? { |r| !r.passed }
113
- return :risky if counts[:warning].positive? ||
114
- counts[:info].positive?
117
+ return :risky if counts[:warning].positive?
115
118
 
119
+ # Info notes describe things that work on Ruby 4.0 and are
120
+ # only worth knowing; they do not taint the verdict.
116
121
  :ready
117
122
  end
118
123
 
@@ -134,7 +139,12 @@ module Audition
134
139
  else
135
140
  acc[f.severity] += 1
136
141
  end
137
- acc[:fixable] += 1 if f.fixable?
142
+ # Only safe autofixes count: `--fix` alone would not
143
+ # touch an unsafe-only finding, so advertising it as
144
+ # fixable would send users in circles.
145
+ if f.autofix && !f.autofix.unsafe?
146
+ acc[:fixable] += 1
147
+ end
138
148
  end
139
149
  end
140
150
  end
@@ -159,8 +169,10 @@ module Audition
159
169
  level = GITHUB_LEVELS.fetch(f.severity)
160
170
  location = f.line ? ",line=#{f.line}" : ""
161
171
  body = workflow_escape("#{f.message}. #{f.why}")
162
- "::#{level} file=#{f.path}#{location}," \
163
- "title=audition #{f.check}::#{body}"
172
+ file = property_escape(f.path)
173
+ title = property_escape("audition #{f.check}")
174
+ "::#{level} file=#{file}#{location}," \
175
+ "title=#{title}::#{body}"
164
176
  end
165
177
  lines << "audition verdict: #{VERDICTS.fetch(verdict)}"
166
178
  lines.join("\n")
@@ -207,6 +219,12 @@ module Audition
207
219
  text.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
208
220
  end
209
221
 
222
+ # Workflow command properties additionally reserve `:` and `,`;
223
+ # an unescaped comma in a path would end the property early.
224
+ def property_escape(text)
225
+ workflow_escape(text).gsub(":", "%3A").gsub(",", "%2C")
226
+ end
227
+
210
228
  public
211
229
 
212
230
  # Text renderer, kept separate from the data so styles stay
@@ -275,8 +293,11 @@ module Audition
275
293
  wrapped.map { |line| " #{@style.dim(line)}" }
276
294
  end
277
295
 
296
+ # Tokens longer than the width (long URLs) cannot end before
297
+ # whitespace, so the first alternative would drop their head;
298
+ # the second hard-slices them instead.
278
299
  def wrap(text, width)
279
- text.scan(/\S.{0,#{width - 1}}(?=\s|\z)/m)
300
+ text.scan(/\S.{0,#{width - 1}}(?=\s|\z)|\S{#{width}}/m)
280
301
  end
281
302
 
282
303
  def dynamic_section
@@ -297,16 +318,24 @@ module Audition
297
318
  [lines.join("\n") + "\n"]
298
319
  end
299
320
 
321
+ def pluralize(count, noun)
322
+ (count == 1) ? "#{count} #{noun}" : "#{count} #{noun}s"
323
+ end
324
+
300
325
  def summary
301
326
  s = @style
302
327
  c = @report.counts
303
328
  parts = []
304
- parts << s.red("#{c[:error]} errors") if c[:error].positive?
329
+ if c[:error].positive?
330
+ parts << s.red(pluralize(c[:error], "error"))
331
+ end
305
332
  if c[:dep_error].positive?
306
- parts << s.magenta("#{c[:dep_error]} dependency errors")
333
+ parts << s.magenta(
334
+ pluralize(c[:dep_error], "dependency error")
335
+ )
307
336
  end
308
337
  if c[:warning].positive?
309
- parts << s.yellow("#{c[:warning]} warnings")
338
+ parts << s.yellow(pluralize(c[:warning], "warning"))
310
339
  end
311
340
  parts << s.cyan("#{c[:info]} info") if c[:info].positive?
312
341
  if c[:fixable].positive?
@@ -317,7 +346,8 @@ module Audition
317
346
  end
318
347
  if @report.unsafe_fixes.positive?
319
348
  parts << s.cyan(
320
- "#{@report.unsafe_fixes} more with --fix-unsafe"
349
+ pluralize(@report.unsafe_fixes, "edit") +
350
+ " with --fix-unsafe"
321
351
  )
322
352
  end
323
353
  if @report.baselined.positive?