audition 0.2.4 → 0.3.0
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 +101 -9
- data/lib/audition/bundle_sweep.rb +11 -3
- data/lib/audition/cli.rb +110 -16
- data/lib/audition/config.rb +3 -3
- data/lib/audition/dynamic/harness.rb +113 -20
- data/lib/audition/dynamic/prober.rb +44 -3
- data/lib/audition/report/github.rb +68 -0
- data/lib/audition/report/json.rb +64 -0
- data/lib/audition/report/style.rb +74 -0
- data/lib/audition/report/text.rb +149 -0
- data/lib/audition/report.rb +8 -294
- data/lib/audition/rewriters.rb +4 -4
- data/lib/audition/static/checks/mutable_constants.rb +91 -28
- data/lib/audition/static/checks/runtime_require.rb +4 -1
- data/lib/audition/static/checks/unsafe_calls.rb +7 -6
- data/lib/audition/static/graph_audit.rb +3 -5
- data/lib/audition/static/literal_classifier.rb +78 -7
- data/lib/audition/static/native_extensions.rb +163 -0
- data/lib/audition/static/source_file.rb +70 -0
- data/lib/audition/target.rb +74 -10
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +1 -0
- metadata +6 -1
data/lib/audition/report.rb
CHANGED
|
@@ -1,79 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "pastel"
|
|
5
|
-
require "tty/link"
|
|
6
|
-
|
|
7
3
|
module Audition
|
|
8
|
-
# Aggregates static findings and dynamic results into a verdict
|
|
9
|
-
#
|
|
4
|
+
# Aggregates static findings and dynamic results into a verdict
|
|
5
|
+
# and the counts the renderers work from. Rendering lives in the
|
|
6
|
+
# Report::Text, Report::Json, and Report::Github classes.
|
|
10
7
|
class Report
|
|
11
|
-
# ANSI + OSC 8 styling with graceful degradation. Color and
|
|
12
|
-
# hyperlinks are decided once, at construction; pass color: false
|
|
13
|
-
# for pipes, NO_COLOR, or dumb terminals.
|
|
14
|
-
class Style
|
|
15
|
-
GLYPHS = Ractor.make_shareable(
|
|
16
|
-
{
|
|
17
|
-
error: ["✖", "x"], warning: ["⚠", "!"],
|
|
18
|
-
info: ["ℹ", "i"], pass: ["✔", "ok"],
|
|
19
|
-
section: ["◆", "*"], fix: ["✎", "+"]
|
|
20
|
-
}
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
PAINTS = %i[red yellow green cyan magenta dim bold].freeze
|
|
24
|
-
|
|
25
|
-
def self.detect(io: $stdout)
|
|
26
|
-
on = io.respond_to?(:tty?) && io.tty? &&
|
|
27
|
-
!ENV.key?("NO_COLOR") && ENV["TERM"] != "dumb"
|
|
28
|
-
new(color: on, hyperlinks: on && TTY::Link.link?)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def initialize(color:, hyperlinks:)
|
|
32
|
-
@pastel = Pastel.new(enabled: color)
|
|
33
|
-
@color = color
|
|
34
|
-
@hyperlinks = hyperlinks
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def color?
|
|
38
|
-
@color
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def glyph(kind)
|
|
42
|
-
GLYPHS.fetch(kind)[@color ? 0 : 1]
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
PAINTS.each do |name|
|
|
46
|
-
define_method(name) do |text| # audition:disable unsafe-calls
|
|
47
|
-
@pastel.public_send(name, text)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def severity_color(severity, text)
|
|
52
|
-
case severity
|
|
53
|
-
when :error then red(text)
|
|
54
|
-
when :warning then yellow(text)
|
|
55
|
-
else cyan(text)
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# OSC 8 hyperlink wrapping "path:line" display text in a
|
|
60
|
-
# file:// URI; supporting terminals make it clickable.
|
|
61
|
-
# tty-link emits when it detects support; when hyperlinks are
|
|
62
|
-
# forced on despite no detection (tests, --force scenarios)
|
|
63
|
-
# fall back to the raw OSC 8 template, since tty-link's
|
|
64
|
-
# fallback is "text -> url" prose.
|
|
65
|
-
def link(text, absolute_path)
|
|
66
|
-
return text unless @hyperlinks
|
|
67
|
-
|
|
68
|
-
uri = "file://#{absolute_path}"
|
|
69
|
-
if TTY::Link.link?
|
|
70
|
-
TTY::Link.link_to(text, uri)
|
|
71
|
-
else
|
|
72
|
-
"\e]8;;#{uri}\e\\#{text}\e]8;;\e\\"
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
8
|
VERDICTS = {
|
|
78
9
|
not_ready: "not ractor-ready",
|
|
79
10
|
blocked: "own code is ractor-ready; blocked by dependencies",
|
|
@@ -148,227 +79,10 @@ module Audition
|
|
|
148
79
|
end
|
|
149
80
|
end
|
|
150
81
|
end
|
|
151
|
-
|
|
152
|
-
# @param style [Style] rendering style (auto-detected default)
|
|
153
|
-
# @return [String] the human-facing terminal report
|
|
154
|
-
def to_text(style: Style.detect)
|
|
155
|
-
Text.new(self, style).render
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
GITHUB_LEVELS = {
|
|
159
|
-
error: "error", warning: "warning", info: "notice"
|
|
160
|
-
}.freeze
|
|
161
|
-
|
|
162
|
-
# GitHub Actions workflow commands: findings become inline PR
|
|
163
|
-
# annotations when this runs in CI.
|
|
164
|
-
#
|
|
165
|
-
# @return [String] one `::error`/`::warning`/`::notice` line
|
|
166
|
-
# per finding plus a verdict line
|
|
167
|
-
def to_github
|
|
168
|
-
lines = findings.map do |f|
|
|
169
|
-
level = GITHUB_LEVELS.fetch(f.severity)
|
|
170
|
-
location = f.line ? ",line=#{f.line}" : ""
|
|
171
|
-
body = workflow_escape("#{f.message}. #{f.why}")
|
|
172
|
-
file = property_escape(f.path)
|
|
173
|
-
title = property_escape("audition #{f.check}")
|
|
174
|
-
"::#{level} file=#{file}#{location}," \
|
|
175
|
-
"title=#{title}::#{body}"
|
|
176
|
-
end
|
|
177
|
-
lines << "audition verdict: #{VERDICTS.fetch(verdict)}"
|
|
178
|
-
lines.join("\n")
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def to_json(*)
|
|
182
|
-
JSON.pretty_generate(
|
|
183
|
-
"audition" => VERSION,
|
|
184
|
-
"ruby" => RUBY_VERSION,
|
|
185
|
-
"target" => {"type" => target_type.to_s,
|
|
186
|
-
"root" => target_root},
|
|
187
|
-
"verdict" => verdict.to_s,
|
|
188
|
-
"summary" => {
|
|
189
|
-
"errors" => counts[:error],
|
|
190
|
-
"dependency_errors" => counts[:dep_error],
|
|
191
|
-
"warnings" => counts[:warning],
|
|
192
|
-
"infos" => counts[:info],
|
|
193
|
-
"fixable" => counts[:fixable]
|
|
194
|
-
},
|
|
195
|
-
"findings" => findings.map do |f|
|
|
196
|
-
{
|
|
197
|
-
"check" => f.check,
|
|
198
|
-
"severity" => f.severity.to_s,
|
|
199
|
-
"message" => f.message,
|
|
200
|
-
"why" => f.why,
|
|
201
|
-
"fix" => f.fix,
|
|
202
|
-
"path" => f.path,
|
|
203
|
-
"line" => f.line,
|
|
204
|
-
"source" => f.source,
|
|
205
|
-
"fixable" => f.fixable?,
|
|
206
|
-
"dependency" => f.dependency?
|
|
207
|
-
}
|
|
208
|
-
end,
|
|
209
|
-
"dynamic" => dynamic_results.map do |r|
|
|
210
|
-
{"mode" => r.mode.to_s, "passed" => r.passed,
|
|
211
|
-
"raw" => r.raw}
|
|
212
|
-
end
|
|
213
|
-
)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
private
|
|
217
|
-
|
|
218
|
-
def workflow_escape(text)
|
|
219
|
-
text.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
220
|
-
end
|
|
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
|
-
|
|
228
|
-
public
|
|
229
|
-
|
|
230
|
-
# Text renderer, kept separate from the data so styles stay
|
|
231
|
-
# injectable.
|
|
232
|
-
class Text
|
|
233
|
-
WRAP = 74
|
|
234
|
-
|
|
235
|
-
def initialize(report, style)
|
|
236
|
-
@report = report
|
|
237
|
-
@style = style
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def render
|
|
241
|
-
[header, *file_sections, *dynamic_section, summary]
|
|
242
|
-
.join("\n")
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
private
|
|
246
|
-
|
|
247
|
-
def header
|
|
248
|
-
s = @style
|
|
249
|
-
title = s.bold("audition #{VERSION}")
|
|
250
|
-
meta = s.dim(
|
|
251
|
-
"ruby #{RUBY_VERSION} · #{@report.target_type} at " \
|
|
252
|
-
"#{@report.target_root}"
|
|
253
|
-
)
|
|
254
|
-
"#{s.glyph(:section)} #{title} #{meta}\n"
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def file_sections
|
|
258
|
-
@report.findings.group_by(&:path).map do |path, findings|
|
|
259
|
-
lines = [@style.bold(" #{path}")]
|
|
260
|
-
findings.each { |f| lines.concat(finding_lines(f)) }
|
|
261
|
-
lines.join("\n") + "\n"
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def finding_lines(finding)
|
|
266
|
-
s = @style
|
|
267
|
-
glyph = s.severity_color(finding.severity,
|
|
268
|
-
s.glyph(finding.severity))
|
|
269
|
-
loc = location_label(finding)
|
|
270
|
-
fix_mark = finding.fixable? ? " #{s.cyan(s.glyph(:fix))}" : ""
|
|
271
|
-
dep_mark =
|
|
272
|
-
finding.dependency? ? " #{s.dim("(dependency)")}" : ""
|
|
273
|
-
head = " #{glyph} #{loc}#{finding.message}" \
|
|
274
|
-
"#{fix_mark}#{dep_mark} #{s.dim(finding.check)}"
|
|
275
|
-
[head,
|
|
276
|
-
*annotation("why", finding.why),
|
|
277
|
-
*annotation("fix", finding.fix)]
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
def location_label(finding)
|
|
281
|
-
return "" unless finding.line
|
|
282
|
-
|
|
283
|
-
s = @style
|
|
284
|
-
text = "#{finding.path}:#{finding.line}"
|
|
285
|
-
absolute = File.expand_path(finding.path, @report.target_root)
|
|
286
|
-
"#{s.cyan(s.link(text, absolute))} "
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
def annotation(label, content)
|
|
290
|
-
return [] if content.nil? || content.empty?
|
|
291
|
-
|
|
292
|
-
wrapped = wrap("#{label}: #{content}", WRAP - 6)
|
|
293
|
-
wrapped.map { |line| " #{@style.dim(line)}" }
|
|
294
|
-
end
|
|
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.
|
|
299
|
-
def wrap(text, width)
|
|
300
|
-
text.scan(/\S.{0,#{width - 1}}(?=\s|\z)|\S{#{width}}/m)
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
def dynamic_section
|
|
304
|
-
return [] if @report.dynamic_results.empty?
|
|
305
|
-
|
|
306
|
-
s = @style
|
|
307
|
-
lines = [s.bold(" dynamic probes")]
|
|
308
|
-
@report.dynamic_results.each do |result|
|
|
309
|
-
lines << if result.passed
|
|
310
|
-
" #{s.green(s.glyph(:pass))} " \
|
|
311
|
-
"#{result.mode} probe passed inside a Ractor"
|
|
312
|
-
else
|
|
313
|
-
" #{s.red(s.glyph(:error))} " \
|
|
314
|
-
"#{result.mode} probe failed " \
|
|
315
|
-
"#{s.dim("(details above)")}"
|
|
316
|
-
end
|
|
317
|
-
end
|
|
318
|
-
[lines.join("\n") + "\n"]
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def pluralize(count, noun)
|
|
322
|
-
(count == 1) ? "#{count} #{noun}" : "#{count} #{noun}s"
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
def summary
|
|
326
|
-
s = @style
|
|
327
|
-
c = @report.counts
|
|
328
|
-
parts = []
|
|
329
|
-
if c[:error].positive?
|
|
330
|
-
parts << s.red(pluralize(c[:error], "error"))
|
|
331
|
-
end
|
|
332
|
-
if c[:dep_error].positive?
|
|
333
|
-
parts << s.magenta(
|
|
334
|
-
pluralize(c[:dep_error], "dependency error")
|
|
335
|
-
)
|
|
336
|
-
end
|
|
337
|
-
if c[:warning].positive?
|
|
338
|
-
parts << s.yellow(pluralize(c[:warning], "warning"))
|
|
339
|
-
end
|
|
340
|
-
parts << s.cyan("#{c[:info]} info") if c[:info].positive?
|
|
341
|
-
if c[:fixable].positive?
|
|
342
|
-
parts << s.cyan(
|
|
343
|
-
"#{c[:fixable]} fixable #{s.glyph(:fix)} " \
|
|
344
|
-
"(run with --fix)"
|
|
345
|
-
)
|
|
346
|
-
end
|
|
347
|
-
if @report.unsafe_fixes.positive?
|
|
348
|
-
parts << s.cyan(
|
|
349
|
-
pluralize(@report.unsafe_fixes, "edit") +
|
|
350
|
-
" with --fix-unsafe"
|
|
351
|
-
)
|
|
352
|
-
end
|
|
353
|
-
if @report.baselined.positive?
|
|
354
|
-
parts << s.dim("#{@report.baselined} baselined")
|
|
355
|
-
end
|
|
356
|
-
parts << s.green("no findings") if parts.empty?
|
|
357
|
-
|
|
358
|
-
verdict = @report.verdict
|
|
359
|
-
glyph, paint =
|
|
360
|
-
case verdict
|
|
361
|
-
when :not_ready then [:error, :red]
|
|
362
|
-
when :blocked then [:warning, :magenta]
|
|
363
|
-
when :risky then [:warning, :yellow]
|
|
364
|
-
else [:pass, :green]
|
|
365
|
-
end
|
|
366
|
-
badge = s.public_send(paint,
|
|
367
|
-
"#{s.glyph(glyph)} " +
|
|
368
|
-
VERDICTS.fetch(verdict))
|
|
369
|
-
" summary: #{parts.join(" · ")}\n" \
|
|
370
|
-
" verdict: #{s.bold(badge)}\n"
|
|
371
|
-
end
|
|
372
|
-
end
|
|
373
82
|
end
|
|
374
83
|
end
|
|
84
|
+
|
|
85
|
+
require_relative "report/style"
|
|
86
|
+
require_relative "report/text"
|
|
87
|
+
require_relative "report/json"
|
|
88
|
+
require_relative "report/github"
|
data/lib/audition/rewriters.rb
CHANGED
|
@@ -190,9 +190,9 @@ module Audition
|
|
|
190
190
|
# @x ||= expr
|
|
191
191
|
# return @x if defined?(@x); @x = expr
|
|
192
192
|
#
|
|
193
|
-
# Preferred strategy is freeze-on-memoize
|
|
194
|
-
#
|
|
195
|
-
#
|
|
193
|
+
# Preferred strategy is freeze-on-memoize: the memoization
|
|
194
|
+
# stays exactly as written and only the memoized value
|
|
195
|
+
# becomes shareable
|
|
196
196
|
# (`.freeze` appended; Ractor.make_shareable for containers).
|
|
197
197
|
# Non-main Ractors may then read the ivar once it has been
|
|
198
198
|
# computed; the first write must still happen on the main
|
|
@@ -325,7 +325,7 @@ module Audition
|
|
|
325
325
|
end
|
|
326
326
|
|
|
327
327
|
# Config setters (`def self.backend=(value); @backend =
|
|
328
|
-
# value; end`) get
|
|
328
|
+
# value; end`) get a try-make-shareable recipe in
|
|
329
329
|
# plain Ruby: shareable values are deeply frozen so reads
|
|
330
330
|
# from any Ractor become legal, unshareable values keep
|
|
331
331
|
# today's behavior through the rescue. Only bare local reads
|
|
@@ -30,21 +30,23 @@ module Audition
|
|
|
30
30
|
"literals only; a method call returns a fresh " \
|
|
31
31
|
"unfrozen object, so a non-main Ractor reading " \
|
|
32
32
|
"this constant raises Ractor::IsolationError. " \
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
fix: "Append `.freeze` to the call; a frozen String " \
|
|
36
|
-
"or
|
|
33
|
+
"Typical shapes: `.tr`, `Regexp.new`, and " \
|
|
34
|
+
"`Object.new` sentinels.",
|
|
35
|
+
fix: "Append `.freeze` to the call; a frozen String, " \
|
|
36
|
+
"Regexp, or bare Object is deeply shareable. " \
|
|
37
|
+
"BasicObject has no #freeze: use " \
|
|
38
|
+
"Object.new.freeze for such a sentinel."
|
|
37
39
|
|
|
38
40
|
explain :mutable_container,
|
|
39
41
|
severity: :error,
|
|
40
42
|
message: "constant %{name} holds a mutable %{type} " \
|
|
41
43
|
"literal",
|
|
42
44
|
why: CONSTANT_WHY,
|
|
43
|
-
fix: "
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
45
|
+
fix: "Append `.freeze` when every element is itself " \
|
|
46
|
+
"shareable; otherwise make it deeply shareable with " \
|
|
47
|
+
"`# shareable_constant_value: literal` or " \
|
|
48
|
+
"Ractor.make_shareable(...), since a bare " \
|
|
49
|
+
"`.freeze` is shallow."
|
|
48
50
|
|
|
49
51
|
explain :shallow_freeze,
|
|
50
52
|
severity: :error,
|
|
@@ -62,17 +64,19 @@ module Audition
|
|
|
62
64
|
explain :sync_primitive,
|
|
63
65
|
severity: :error,
|
|
64
66
|
message: "constant %{name} holds a %{klass}; sync " \
|
|
65
|
-
"primitives are
|
|
67
|
+
"primitives and concurrent collections are " \
|
|
68
|
+
"deliberately unshareable",
|
|
66
69
|
why: "Mutex/Queue/ConditionVariable coordinate " \
|
|
67
70
|
"threads inside one Ractor and can never be " \
|
|
68
71
|
"shared across Ractors; any non-main Ractor " \
|
|
69
72
|
"touching this constant raises " \
|
|
70
|
-
"Ractor::IsolationError."
|
|
73
|
+
"Ractor::IsolationError. A concurrent-ruby Map " \
|
|
74
|
+
"defines no #freeze at all, so make_shareable " \
|
|
75
|
+
"raises NoMethodError on it.",
|
|
71
76
|
fix: "Use Ractor::Port for cross-Ractor " \
|
|
72
77
|
"coordination; keep a per-Ractor primitive " \
|
|
73
78
|
"via Ractor.store_if_absent when the state it " \
|
|
74
|
-
"guards is per-Ractor too
|
|
75
|
-
"template digest mutex this way); or use " \
|
|
79
|
+
"guards is per-Ractor too; or use " \
|
|
76
80
|
"Ractor-safe structures (ractor_safe, ratomic " \
|
|
77
81
|
"gems)."
|
|
78
82
|
|
|
@@ -94,8 +98,7 @@ module Audition
|
|
|
94
98
|
"Hash, and freezing the Hash does not make " \
|
|
95
99
|
"the block shareable; a non-main Ractor " \
|
|
96
100
|
"reading this constant raises " \
|
|
97
|
-
"Ractor::IsolationError.
|
|
98
|
-
"pattern twice during its ractorization.",
|
|
101
|
+
"Ractor::IsolationError.",
|
|
99
102
|
fix: "Use a plain frozen Hash with explicit keys, " \
|
|
100
103
|
"or drop the default proc and fetch with a " \
|
|
101
104
|
"literal default: hash.fetch(key, [])."
|
|
@@ -111,8 +114,8 @@ module Audition
|
|
|
111
114
|
fix: "Build the complete value at load time and " \
|
|
112
115
|
"freeze it (each_with_object then .freeze), " \
|
|
113
116
|
"or move the registry behind a writer that " \
|
|
114
|
-
"rebuilds and refreezes on each change
|
|
115
|
-
"copy-on-write
|
|
117
|
+
"rebuilds and refreezes on each change " \
|
|
118
|
+
"(copy-on-write). A " \
|
|
116
119
|
"registry that plugins extend during boot is " \
|
|
117
120
|
"frozen in the last boot hook (after_initialize) " \
|
|
118
121
|
"rather than at definition, and writes after the " \
|
|
@@ -144,21 +147,30 @@ module Audition
|
|
|
144
147
|
|
|
145
148
|
# A constant this file itself mutates in place is a
|
|
146
149
|
# deliberate accumulator; freezing it would raise at
|
|
147
|
-
# the mutation site (sinatra's PARAMS_CONFIG).
|
|
150
|
+
# the mutation site (sinatra's PARAMS_CONFIG). One it
|
|
151
|
+
# gives singleton methods raises the same way. The
|
|
148
152
|
# finding stays, the autofix goes.
|
|
149
|
-
fix_ok = !mutated?(name)
|
|
150
|
-
|
|
153
|
+
fix_ok = !mutated?(name) && !customized?(name)
|
|
154
|
+
kind = classifier.classify(value)
|
|
155
|
+
# Build-then-freeze: a bare `NAME.freeze` later in the
|
|
156
|
+
# same body makes the literal as good as frozen, so
|
|
157
|
+
# only provably mutable elements remain to report.
|
|
158
|
+
if kind == :mutable_container && frozen_later?(name)
|
|
159
|
+
kind = classifier.frozen_kind(value)
|
|
160
|
+
fix_ok = false
|
|
161
|
+
end
|
|
162
|
+
case kind
|
|
151
163
|
when :mutable_string
|
|
152
164
|
flag(node, :mutable_string, name: name,
|
|
153
165
|
autofix: fix_ok ? append_freeze(value) : nil)
|
|
154
166
|
when :mutable_call
|
|
155
167
|
flag(node, :mutable_call, name: name,
|
|
156
168
|
type: call_type(value), method: call_display(value),
|
|
157
|
-
autofix: fix_ok ?
|
|
169
|
+
autofix: fix_ok ? freeze_call(value) : nil)
|
|
158
170
|
when :mutable_container
|
|
159
171
|
flag(node, :mutable_container, name: name,
|
|
160
172
|
type: container_type(value),
|
|
161
|
-
autofix: fix_ok ?
|
|
173
|
+
autofix: fix_ok ? freeze_container(value) : nil)
|
|
162
174
|
when :shallow_freeze
|
|
163
175
|
flag(node, :shallow_freeze, name: name,
|
|
164
176
|
autofix:
|
|
@@ -186,10 +198,18 @@ module Audition
|
|
|
186
198
|
end
|
|
187
199
|
end
|
|
188
200
|
|
|
189
|
-
def mutated?(name)
|
|
201
|
+
def mutated?(name) = named_in?(file.mutated_constants, name)
|
|
202
|
+
|
|
203
|
+
def customized?(name)
|
|
204
|
+
named_in?(file.customized_constants, name)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def frozen_later?(name) = named_in?(file.frozen_constants, name)
|
|
208
|
+
|
|
209
|
+
def named_in?(names, name)
|
|
190
210
|
bare = name.split("::").last
|
|
191
|
-
|
|
192
|
-
|
|
211
|
+
names.any? do |other|
|
|
212
|
+
other == name || other.split("::").last == bare
|
|
193
213
|
end
|
|
194
214
|
end
|
|
195
215
|
|
|
@@ -262,6 +282,8 @@ module Audition
|
|
|
262
282
|
when Prism::HashNode, Prism::KeywordHashNode then "Hash"
|
|
263
283
|
when Prism::ArrayNode then "Array"
|
|
264
284
|
when Prism::CallNode
|
|
285
|
+
return "Set" if value.name == :to_set
|
|
286
|
+
|
|
265
287
|
classifier.const_name(value.receiver) || "container"
|
|
266
288
|
else
|
|
267
289
|
"container"
|
|
@@ -277,8 +299,11 @@ module Audition
|
|
|
277
299
|
# Ternaries classify as strings when both branches are;
|
|
278
300
|
# `.freeze` binds tighter than `?:`, so they get parens.
|
|
279
301
|
def call_type(call)
|
|
280
|
-
|
|
281
|
-
|
|
302
|
+
case classifier.const_name(call.receiver)
|
|
303
|
+
when "Regexp" then "Regexp"
|
|
304
|
+
when "Object", "BasicObject" then "Object"
|
|
305
|
+
else "String"
|
|
306
|
+
end
|
|
282
307
|
end
|
|
283
308
|
|
|
284
309
|
def call_display(call)
|
|
@@ -294,8 +319,11 @@ module Audition
|
|
|
294
319
|
# the bare suffix.
|
|
295
320
|
def bare_freezable?(value)
|
|
296
321
|
case value
|
|
297
|
-
when Prism::StringNode, Prism::InterpolatedStringNode
|
|
322
|
+
when Prism::StringNode, Prism::InterpolatedStringNode,
|
|
323
|
+
Prism::HashNode
|
|
298
324
|
true
|
|
325
|
+
when Prism::ArrayNode
|
|
326
|
+
!value.opening_loc.nil?
|
|
299
327
|
when Prism::CallNode
|
|
300
328
|
!value.opening_loc.nil? ||
|
|
301
329
|
(!value.receiver.nil? && value.arguments.nil?)
|
|
@@ -305,6 +333,16 @@ module Audition
|
|
|
305
333
|
end
|
|
306
334
|
|
|
307
335
|
def append_freeze(value)
|
|
336
|
+
# `X = :a, :b` has no brackets; it gains them so the
|
|
337
|
+
# suffix freezes the whole array.
|
|
338
|
+
if value.is_a?(Prism::ArrayNode) && value.opening_loc.nil?
|
|
339
|
+
return Autofix.new(
|
|
340
|
+
start_offset: value.location.start_offset,
|
|
341
|
+
end_offset: value.location.end_offset,
|
|
342
|
+
replacement: "[#{value.location.slice}].freeze"
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
|
|
308
346
|
if bare_freezable?(value)
|
|
309
347
|
offset = value.location.end_offset
|
|
310
348
|
Autofix.new(
|
|
@@ -322,6 +360,31 @@ module Audition
|
|
|
322
360
|
end
|
|
323
361
|
end
|
|
324
362
|
|
|
363
|
+
# A frozen bare Object is shareable; BasicObject has no
|
|
364
|
+
# #freeze, so its sentinel becomes a frozen Object.
|
|
365
|
+
def freeze_call(value)
|
|
366
|
+
return append_freeze(value) unless
|
|
367
|
+
classifier.const_name(value.receiver) == "BasicObject"
|
|
368
|
+
|
|
369
|
+
Autofix.new(
|
|
370
|
+
start_offset: value.location.start_offset,
|
|
371
|
+
end_offset: value.location.end_offset,
|
|
372
|
+
replacement: "Object.new.freeze",
|
|
373
|
+
safety: :unsafe
|
|
374
|
+
)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Plain `.freeze` where every element is provably
|
|
378
|
+
# shareable, the plain-Ruby shape; the deep wrap only
|
|
379
|
+
# where a shallow freeze would not be enough.
|
|
380
|
+
def freeze_container(value)
|
|
381
|
+
if classifier.frozen_kind(value) == :shareable
|
|
382
|
+
append_freeze(value)
|
|
383
|
+
else
|
|
384
|
+
wrap_make_shareable(value)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
325
388
|
# `X = :a, :b` is an array literal without brackets; the
|
|
326
389
|
# slice must gain them or the wrap becomes a multi-arg
|
|
327
390
|
# call (mail's ATTRIBUTES).
|
|
@@ -22,7 +22,10 @@ module Audition
|
|
|
22
22
|
"load-time side effects run at an arbitrary " \
|
|
23
23
|
"point.",
|
|
24
24
|
fix: "Require eagerly at boot, before Ractors are " \
|
|
25
|
-
"spawned."
|
|
25
|
+
"spawned. For an optional dependency, the " \
|
|
26
|
+
"class-level macro that enables the feature is " \
|
|
27
|
+
"a boot-time scope; the request-time method is " \
|
|
28
|
+
"not."
|
|
26
29
|
|
|
27
30
|
explain :autoload,
|
|
28
31
|
severity: :warning,
|
|
@@ -27,10 +27,12 @@ module Audition
|
|
|
27
27
|
"variables; reads and writes alike raise " \
|
|
28
28
|
"Ractor::IsolationError from a non-main Ractor, " \
|
|
29
29
|
"whatever the value holds.",
|
|
30
|
-
fix: "
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
30
|
+
fix: "Move the state to class_attribute (a " \
|
|
31
|
+
"class-level ivar whose frozen value any Ractor " \
|
|
32
|
+
"may read) or, for plain settings, to " \
|
|
33
|
+
"singleton_class.attr_accessor plus " \
|
|
34
|
+
"delegate(..., to: TheModule) for the instance " \
|
|
35
|
+
"readers; give it a frozen default and rebuild " \
|
|
34
36
|
"and refreeze on write, at boot on the main " \
|
|
35
37
|
"Ractor."
|
|
36
38
|
|
|
@@ -110,8 +112,7 @@ module Audition
|
|
|
110
112
|
"unless the block was made shareable first.",
|
|
111
113
|
fix: "Pass a shareable lambda instead of a block: " \
|
|
112
114
|
"define_method(:x, Ractor.shareable_lambda " \
|
|
113
|
-
"{ ... })
|
|
114
|
-
"and url helpers. Captured locals must be " \
|
|
115
|
+
"{ ... }). Captured locals must be " \
|
|
115
116
|
"shareable (strings become symbols) and assigned " \
|
|
116
117
|
"before the lambda is created, and super is " \
|
|
117
118
|
"unavailable. When the captures are literals, " \
|
|
@@ -34,7 +34,7 @@ module Audition
|
|
|
34
34
|
"per-subclass values compute in the inherited hook " \
|
|
35
35
|
"(guard on subclass.name for anonymous classes). For " \
|
|
36
36
|
"collections, rebuild and refreeze on write, " \
|
|
37
|
-
"
|
|
37
|
+
"copy-on-write: self.list = " \
|
|
38
38
|
"(list + [item]).freeze; never mutate in place. As a " \
|
|
39
39
|
"last resort use Ractor.store_if_absent for " \
|
|
40
40
|
"per-Ractor state, or read the ivar first and proxy " \
|
|
@@ -43,9 +43,7 @@ module Audition
|
|
|
43
43
|
"Every write memoizes a shareable (frozen) value, so " \
|
|
44
44
|
"non-main Ractors can read it once it has been " \
|
|
45
45
|
"computed; only the first write must happen on the " \
|
|
46
|
-
"main Ractor, or it raises Ractor::IsolationError.
|
|
47
|
-
"This is the pattern Rails core uses for its own " \
|
|
48
|
-
"memoized class state."
|
|
46
|
+
"main Ractor, or it raises Ractor::IsolationError."
|
|
49
47
|
FROZEN_MEMO_FIX =
|
|
50
48
|
"Warm the cache at boot, before spawning Ractors: call " \
|
|
51
49
|
"the memoizing method from an initializer, an on_load " \
|
|
@@ -188,7 +186,7 @@ module Audition
|
|
|
188
186
|
)
|
|
189
187
|
end
|
|
190
188
|
|
|
191
|
-
# Frozen memoization
|
|
189
|
+
# Frozen memoization: every
|
|
192
190
|
# write to the ivar is a memo site (`@x ||=` or a defined?
|
|
193
191
|
# guard) whose value is provably shareable, either a frozen
|
|
194
192
|
# literal, an explicit `.freeze` or make_shareable call.
|