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 +4 -4
- data/README.md +57 -26
- data/exe/audition +8 -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 +72 -16
- data/lib/audition/fixer.rb +59 -25
- data/lib/audition/report.rb +41 -11
- data/lib/audition/rewriters.rb +725 -85
- 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 +168 -15
- data/lib/audition/static/checks/ractor_isolation.rb +5 -1
- data/lib/audition/static/checks/runtime_require.rb +98 -15
- data/lib/audition/static/checks/unsafe_calls.rb +28 -1
- data/lib/audition/static/graph_audit.rb +242 -14
- data/lib/audition/static/literal_classifier.rb +76 -12
- data/lib/audition/static/source_file.rb +92 -11
- data/lib/audition/version.rb +1 -1
- metadata +3 -3
|
@@ -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 : :
|
|
226
|
+
severity: hot ? :error : :info,
|
|
213
227
|
message: "class-level state " \
|
|
214
228
|
"#{entry["ivars"].join(", ")} on " \
|
|
215
229
|
"#{entry["const"]}#{detail}",
|
|
216
|
-
why:
|
|
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" =>
|
|
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 =
|
|
347
|
-
err_reader =
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
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
|
|
|
@@ -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
|
-
|
|
119
|
+
encoding = plan.source.encoding
|
|
120
|
+
bytes = plan.source.dup.force_encoding(Encoding::BINARY)
|
|
110
121
|
plan.edits.each do |edit|
|
|
111
|
-
|
|
112
|
-
edit.replacement
|
|
122
|
+
bytes[edit.start_offset...edit.end_offset] =
|
|
123
|
+
edit.replacement.dup.force_encoding(Encoding::BINARY)
|
|
113
124
|
end
|
|
114
|
-
|
|
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.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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:
|
|
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
|
data/lib/audition/report.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
349
|
+
pluralize(@report.unsafe_fixes, "edit") +
|
|
350
|
+
" with --fix-unsafe"
|
|
321
351
|
)
|
|
322
352
|
end
|
|
323
353
|
if @report.baselined.positive?
|