audition 0.1.0 → 0.2.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 +37 -29
- data/exe/audition +8 -0
- data/lib/audition/dynamic/prober.rb +16 -4
- data/lib/audition/fixer.rb +49 -21
- data/lib/audition/report.rb +4 -3
- data/lib/audition/rewriters.rb +371 -55
- data/lib/audition/static/checks/mutable_constants.rb +91 -9
- data/lib/audition/static/checks/ractor_isolation.rb +1 -1
- data/lib/audition/static/checks/runtime_require.rb +98 -15
- data/lib/audition/static/checks/unsafe_calls.rb +27 -0
- data/lib/audition/static/graph_audit.rb +97 -13
- data/lib/audition/static/literal_classifier.rb +69 -10
- data/lib/audition/static/source_file.rb +26 -8
- data/lib/audition/version.rb +1 -1
- metadata +1 -1
data/lib/audition/rewriters.rb
CHANGED
|
@@ -15,10 +15,14 @@ module Audition
|
|
|
15
15
|
FSL_LINE = "# frozen_string_literal: true\n"
|
|
16
16
|
|
|
17
17
|
# `shareable_constant_value: literal` raises at load for
|
|
18
|
-
# non-literal constant values (verified on 4.0), so it is
|
|
19
|
-
# planned when every constant assignment in the file
|
|
20
|
-
#
|
|
21
|
-
#
|
|
18
|
+
# non-literal constant values (verified on 4.0), so it is
|
|
19
|
+
# only planned when every constant assignment in the file is
|
|
20
|
+
# a literal all the way down: an array literal holding a
|
|
21
|
+
# local or a call (Racc-generated parser tables are the
|
|
22
|
+
# canonical case) becomes an unshareable value that the
|
|
23
|
+
# magic comment rejects at load time. Otherwise, if the
|
|
24
|
+
# flagged values are all strings, frozen_string_literal
|
|
25
|
+
# covers them.
|
|
22
26
|
def self.plan(file, findings)
|
|
23
27
|
return nil if file.shareable_constants?
|
|
24
28
|
return nil if findings.none? { |f| f.check == "mutable-constants" }
|
|
@@ -26,13 +30,13 @@ module Audition
|
|
|
26
30
|
classifier = Static::LiteralClassifier.new(
|
|
27
31
|
frozen_string_literal: file.frozen_string_literal?
|
|
28
32
|
)
|
|
29
|
-
|
|
33
|
+
values = constant_values(file)
|
|
34
|
+
kinds = values.map { |v| classifier.classify(v) }
|
|
30
35
|
flagged = kinds.reject do |k|
|
|
31
36
|
%i[shareable unknown].include?(k)
|
|
32
37
|
end
|
|
33
|
-
scv_ok =
|
|
34
|
-
|
|
35
|
-
shallow_freeze].include?(k)
|
|
38
|
+
scv_ok = values.all? do |value|
|
|
39
|
+
deep_literal?(value, classifier)
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
if scv_ok
|
|
@@ -43,6 +47,30 @@ module Audition
|
|
|
43
47
|
end
|
|
44
48
|
end
|
|
45
49
|
|
|
50
|
+
# Bare literals only: `[...].freeze` is a method call, and
|
|
51
|
+
# under the magic comment Ruby raises for it at assignment
|
|
52
|
+
# because the shallowly-frozen value is unshareable
|
|
53
|
+
# (verified on 4.0 with jwt's NAMED_CURVES).
|
|
54
|
+
def self.deep_literal?(node, classifier)
|
|
55
|
+
case node
|
|
56
|
+
when Prism::ArrayNode
|
|
57
|
+
node.elements.all? do |element|
|
|
58
|
+
deep_literal?(element, classifier)
|
|
59
|
+
end
|
|
60
|
+
when Prism::HashNode, Prism::KeywordHashNode
|
|
61
|
+
node.elements.all? do |element|
|
|
62
|
+
element.is_a?(Prism::AssocNode) &&
|
|
63
|
+
deep_literal?(element.key, classifier) &&
|
|
64
|
+
deep_literal?(element.value, classifier)
|
|
65
|
+
end
|
|
66
|
+
when Prism::CallNode
|
|
67
|
+
false
|
|
68
|
+
else
|
|
69
|
+
%i[shareable mutable_string]
|
|
70
|
+
.include?(classifier.classify(node))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
46
74
|
def self.comment_plan(file, line)
|
|
47
75
|
offset = file.magic_insertion_offset
|
|
48
76
|
{
|
|
@@ -74,7 +102,7 @@ module Audition
|
|
|
74
102
|
visit_constant_path_write_node
|
|
75
103
|
visit_constant_path_or_write_node
|
|
76
104
|
].each do |method|
|
|
77
|
-
define_method(method) do |node|
|
|
105
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
78
106
|
@values << node.value
|
|
79
107
|
super(node)
|
|
80
108
|
end
|
|
@@ -84,15 +112,31 @@ module Audition
|
|
|
84
112
|
|
|
85
113
|
# -- class-level memoization -------------------------------------
|
|
86
114
|
|
|
87
|
-
# Rewrites singleton-scope
|
|
115
|
+
# Rewrites singleton-scope memoization, both idioms:
|
|
116
|
+
# @x ||= expr
|
|
117
|
+
# return @x if defined?(@x); @x = expr
|
|
118
|
+
#
|
|
119
|
+
# Preferred strategy is freeze-on-memoize, the pattern Rails
|
|
120
|
+
# core applies to its own code: the memoization stays exactly
|
|
121
|
+
# as written and only the memoized value becomes shareable
|
|
122
|
+
# (`.freeze` appended; Ractor.make_shareable for containers).
|
|
123
|
+
# Non-main Ractors may then read the ivar once it has been
|
|
124
|
+
# computed; the first write must still happen on the main
|
|
125
|
+
# Ractor, which is a boot-warming concern the static check
|
|
126
|
+
# reports as an info note. Chosen when the value expression
|
|
127
|
+
# carries no block (a proxy for one-time side effects) and no
|
|
128
|
+
# writes exist outside the memo sites.
|
|
129
|
+
#
|
|
130
|
+
# Otherwise falls back to Ractor-local storage:
|
|
88
131
|
# @x ||= expr -> Ractor.store_if_absent(:"Klass/@x") { expr }
|
|
89
132
|
# @x = expr -> Ractor.current[:"Klass/@x"] = expr
|
|
90
133
|
# @x -> Ractor.current[:"Klass/@x"]
|
|
134
|
+
#
|
|
91
135
|
# Only when every reference in singleton scope is visible in
|
|
92
136
|
# this file, none sit directly in the class body, and no
|
|
93
|
-
# compound writes exist. Caveat (why this is unsafe):
|
|
94
|
-
#
|
|
95
|
-
#
|
|
137
|
+
# compound writes exist. Caveat (why this is unsafe): freezing
|
|
138
|
+
# changes value mutability, and each Ractor computes its own
|
|
139
|
+
# copy under store_if_absent.
|
|
96
140
|
module Memoization
|
|
97
141
|
def self.plan(file, findings)
|
|
98
142
|
return [] if findings.none? { |f| f.check == "class-level-state" }
|
|
@@ -104,42 +148,267 @@ module Audition
|
|
|
104
148
|
next if namespace.empty?
|
|
105
149
|
|
|
106
150
|
kinds = ops.map { |op| op[:kind] }
|
|
107
|
-
next unless kinds.include?(:or_write)
|
|
108
151
|
next if kinds.include?(:other)
|
|
109
152
|
next if ops.any? { |op| op[:body] }
|
|
110
153
|
|
|
111
|
-
|
|
112
|
-
|
|
154
|
+
memos = memo_sites(ops)
|
|
155
|
+
next if memos.empty?
|
|
156
|
+
next if orphan_guards?(ops, memos)
|
|
157
|
+
next if guarded_with_strays?(ops, memos)
|
|
158
|
+
# `@x ||= {}` is a lazily-built accumulator, mutated
|
|
159
|
+
# through the accessor after memoization (jwt's
|
|
160
|
+
# algorithm registry). Freezing it breaks registration
|
|
161
|
+
# and Ractor-local copies would leave other Ractors an
|
|
162
|
+
# empty registry; the copy-on-write refactor is human
|
|
163
|
+
# work, so no edit is offered.
|
|
164
|
+
next if memos.any? { |m| accumulator?(m[:op][:node].value) }
|
|
165
|
+
|
|
166
|
+
if freezable?(ops, memos)
|
|
167
|
+
edits.concat(freeze_edits(file, memos))
|
|
168
|
+
else
|
|
169
|
+
key = %(:"#{namespace}/#{name}")
|
|
170
|
+
edits.concat(ractor_edits(file, ops, memos, key))
|
|
171
|
+
end
|
|
113
172
|
end
|
|
114
173
|
edits
|
|
115
174
|
end
|
|
116
175
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
176
|
+
# A memo site is an ||= write, or a plain write paired with a
|
|
177
|
+
# defined? return guard inside the same method. A guarded
|
|
178
|
+
# method with more than one write is nobody's memoization;
|
|
179
|
+
# such groups are dropped by the orphan check below.
|
|
180
|
+
def self.memo_sites(ops)
|
|
181
|
+
guards = ops.select { |op| op[:kind] == :guard }
|
|
182
|
+
ops.filter_map do |op|
|
|
183
|
+
case op[:kind]
|
|
184
|
+
when :or_write
|
|
185
|
+
{op: op, guard: nil}
|
|
186
|
+
when :write
|
|
187
|
+
guard = guards.find { |g| g[:def_id] == op[:def_id] }
|
|
188
|
+
{op: op, guard: guard} if guard
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.accumulator?(value)
|
|
194
|
+
(value.is_a?(Prism::ArrayNode) ||
|
|
195
|
+
value.is_a?(Prism::HashNode)) && value.elements.empty?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def self.guarded_with_strays?(ops, memos)
|
|
199
|
+
return false if memos.none? { |memo| memo[:guard] }
|
|
200
|
+
|
|
201
|
+
memo_ops = memos.map { |memo| memo[:op] }
|
|
202
|
+
ops.any? do |op|
|
|
203
|
+
op[:kind] == :write && !memo_ops.include?(op)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.orphan_guards?(ops, memos)
|
|
208
|
+
used = memos.filter_map { |m| m[:guard] }
|
|
209
|
+
guards = ops.select { |op| op[:kind] == :guard }
|
|
210
|
+
return true if guards.size != used.size
|
|
211
|
+
|
|
212
|
+
writes = ops.select { |op| op[:kind] == :write }
|
|
213
|
+
guards.any? do |guard|
|
|
214
|
+
writes.count { |w| w[:def_id] == guard[:def_id] } != 1
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Freeze-on-memoize applies when every write is a memo site
|
|
219
|
+
# (a stray write means cache invalidation; frozen values
|
|
220
|
+
# cannot support that) and no value needs a block to build.
|
|
221
|
+
def self.freezable?(ops, memos)
|
|
222
|
+
memo_ops = memos.map { |memo| memo[:op] }
|
|
223
|
+
writes = ops.select { |op| op[:kind] == :write }
|
|
224
|
+
return false unless (writes - memo_ops).empty?
|
|
225
|
+
|
|
226
|
+
memos.all? { |memo| blockless?(memo[:op][:node].value) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def self.blockless?(node)
|
|
230
|
+
queue = [node]
|
|
231
|
+
until queue.empty?
|
|
232
|
+
current = queue.shift
|
|
233
|
+
if current.is_a?(Prism::BlockNode) ||
|
|
234
|
+
current.is_a?(Prism::LambdaNode)
|
|
235
|
+
return false
|
|
236
|
+
end
|
|
237
|
+
queue.concat(current.child_nodes.compact)
|
|
238
|
+
end
|
|
239
|
+
true
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# One edit per memo site: make the memoized value shareable
|
|
243
|
+
# while leaving the memoization intact. Guards, sibling
|
|
244
|
+
# reads, and method shapes stay untouched.
|
|
245
|
+
def self.freeze_edits(file, memos)
|
|
246
|
+
classifier = Static::LiteralClassifier.new(
|
|
247
|
+
frozen_string_literal: file.frozen_string_literal?
|
|
248
|
+
)
|
|
249
|
+
memos.filter_map do |memo|
|
|
250
|
+
value = memo[:op][:node].value
|
|
251
|
+
freeze_value(value, classifier.classify(value))
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Plain `.freeze` only where the value is provably a string;
|
|
256
|
+
# everything unproven gets Ractor.make_shareable, which is a
|
|
257
|
+
# no-op for already-shareable values. This matters for
|
|
258
|
+
# memoized classes (multi_json memoizes adapter classes):
|
|
259
|
+
# `.freeze` on a Class freezes the class object and later
|
|
260
|
+
# ivar writes on it raise FrozenError.
|
|
261
|
+
def self.freeze_value(value, kind)
|
|
262
|
+
return nil if kind == :shareable
|
|
263
|
+
# Freezing or wrapping a sync primitive raises; leave the
|
|
264
|
+
# finding in place for a human.
|
|
265
|
+
return nil if kind == :sync_primitive
|
|
266
|
+
return nil if frozen_call?(value)
|
|
267
|
+
|
|
268
|
+
slice = value.location.slice
|
|
269
|
+
replacement =
|
|
270
|
+
if kind == :mutable_string
|
|
271
|
+
parens?(value) ? "(#{slice}).freeze" : "#{slice}.freeze"
|
|
272
|
+
else
|
|
273
|
+
"Ractor.make_shareable(#{slice})"
|
|
274
|
+
end
|
|
275
|
+
Autofix.new(
|
|
276
|
+
start_offset: value.location.start_offset,
|
|
277
|
+
end_offset: value.location.end_offset,
|
|
278
|
+
replacement: replacement,
|
|
279
|
+
safety: :unsafe
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def self.frozen_call?(value)
|
|
284
|
+
value.is_a?(Prism::CallNode) &&
|
|
285
|
+
value.name == :freeze &&
|
|
286
|
+
value.receiver && value.arguments.nil?
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# `.freeze` binds tighter than operators and ternaries, so
|
|
290
|
+
# compound expressions get wrapped; message sends and plain
|
|
291
|
+
# literals do not need it.
|
|
292
|
+
def self.parens?(value)
|
|
293
|
+
case value
|
|
294
|
+
when Prism::CallNode
|
|
295
|
+
!value.name.to_s.match?(/\A[a-z_]/i)
|
|
296
|
+
when Prism::StringNode, Prism::InterpolatedStringNode,
|
|
297
|
+
Prism::ArrayNode, Prism::HashNode,
|
|
298
|
+
Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
299
|
+
false
|
|
300
|
+
else
|
|
301
|
+
true
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Two Ractor-local flavors. With stray writes present (cache
|
|
306
|
+
# invalidation, `@x = nil`), memo sites become
|
|
307
|
+
# `Ractor.current[key] ||= expr`: it recomputes after a nil
|
|
308
|
+
# reset exactly like the original `||=`, where
|
|
309
|
+
# store_if_absent would treat the stored nil as present and
|
|
310
|
+
# never recompute (this broke i18n's reserved_keys_pattern).
|
|
311
|
+
# Without strays, store_if_absent keeps its atomic lazy
|
|
312
|
+
# init. Guard-idiom groups with strays are skipped entirely
|
|
313
|
+
# in plan: the defined? guard caches nil deliberately and
|
|
314
|
+
# neither flavor reproduces that alongside invalidation.
|
|
315
|
+
def self.ractor_edits(file, ops, memos, key)
|
|
316
|
+
guarded = memos.filter_map { |m| m[:op] if m[:guard] }
|
|
317
|
+
memo_ops = memos.map { |memo| memo[:op] }
|
|
318
|
+
strays = ops.any? do |op|
|
|
319
|
+
op[:kind] == :write && !memo_ops.include?(op)
|
|
320
|
+
end
|
|
321
|
+
edits = memos.filter_map do |memo|
|
|
322
|
+
deletion(file.source, memo[:guard][:node]) if memo[:guard]
|
|
323
|
+
end
|
|
324
|
+
ops.each do |op|
|
|
325
|
+
node = op[:node]
|
|
326
|
+
edits <<
|
|
327
|
+
if op[:kind] == :or_write || guarded.include?(op)
|
|
328
|
+
replacement =
|
|
329
|
+
if strays
|
|
330
|
+
value = node.value.location.slice
|
|
331
|
+
"Ractor.current[#{key}] ||= #{value}"
|
|
332
|
+
else
|
|
333
|
+
store_wrap(file.source, node, key)
|
|
334
|
+
end
|
|
335
|
+
Autofix.new(
|
|
336
|
+
start_offset: node.location.start_offset,
|
|
337
|
+
end_offset: node.location.end_offset,
|
|
338
|
+
replacement: replacement,
|
|
339
|
+
safety: :unsafe
|
|
340
|
+
)
|
|
341
|
+
elsif op[:kind] == :write
|
|
342
|
+
Autofix.new(
|
|
343
|
+
start_offset: node.name_loc.start_offset,
|
|
344
|
+
end_offset: node.name_loc.end_offset,
|
|
345
|
+
replacement: "Ractor.current[#{key}]",
|
|
346
|
+
safety: :unsafe
|
|
347
|
+
)
|
|
348
|
+
else
|
|
349
|
+
Autofix.new(
|
|
350
|
+
start_offset: node.location.start_offset,
|
|
351
|
+
end_offset: node.location.end_offset,
|
|
352
|
+
replacement: "Ractor.current[#{key}]",
|
|
353
|
+
safety: :unsafe
|
|
354
|
+
)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
edits
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# A single-line value keeps the brace form; a multi-line
|
|
361
|
+
# value becomes a do..end block with the body shifted one
|
|
362
|
+
# level right, so the rewrite stays idiomatic. When the write
|
|
363
|
+
# shares its line with other code, layout cannot be inferred
|
|
364
|
+
# and the brace form is kept as-is.
|
|
365
|
+
def self.store_wrap(source, node, key)
|
|
366
|
+
value = node.value.location.slice
|
|
367
|
+
call = "Ractor.store_if_absent(#{key})"
|
|
368
|
+
return "#{call} { #{value} }" unless value.include?("\n")
|
|
369
|
+
|
|
370
|
+
raw = source.dup.force_encoding(Encoding::BINARY)
|
|
371
|
+
from = node.location.start_offset
|
|
372
|
+
start = from.zero? ? 0 : (raw.rindex("\n", from - 1) || -1) + 1
|
|
373
|
+
indent = raw[start...from].force_encoding(source.encoding)
|
|
374
|
+
return "#{call} { #{value} }" unless indent.match?(/\A[ \t]*\z/)
|
|
375
|
+
|
|
376
|
+
body = value.lines.map.with_index do |line, index|
|
|
377
|
+
if index.zero?
|
|
378
|
+
"#{indent} #{line}"
|
|
379
|
+
elsif line.match?(/\A\s*\z/)
|
|
380
|
+
line
|
|
381
|
+
else
|
|
382
|
+
" #{line}"
|
|
383
|
+
end
|
|
384
|
+
end.join
|
|
385
|
+
"#{call} do\n#{body}\n#{indent}end"
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Deletes the guard statement together with its line and any
|
|
389
|
+
# blank lines that follow, so the method body does not open
|
|
390
|
+
# with a hole. Falls back to the bare node span when other
|
|
391
|
+
# code shares the line.
|
|
392
|
+
def self.deletion(source, node)
|
|
393
|
+
raw = source.dup.force_encoding(Encoding::BINARY)
|
|
394
|
+
from = node.location.start_offset
|
|
395
|
+
upto = node.location.end_offset
|
|
396
|
+
start = from.zero? ? 0 : (raw.rindex("\n", from - 1) || -1) + 1
|
|
397
|
+
if raw[start...from].match?(/\A[ \t]*\z/n)
|
|
398
|
+
newline = raw.index("\n", upto)
|
|
399
|
+
stop = newline ? newline + 1 : raw.length
|
|
400
|
+
loop do
|
|
401
|
+
newline = raw.index("\n", stop)
|
|
402
|
+
break unless newline
|
|
403
|
+
break unless raw[stop...newline].match?(/\A[ \t]*\z/n)
|
|
404
|
+
|
|
405
|
+
stop = newline + 1
|
|
406
|
+
end
|
|
407
|
+
Autofix.new(start_offset: start, end_offset: stop,
|
|
408
|
+
replacement: "", safety: :unsafe)
|
|
409
|
+
else
|
|
410
|
+
Autofix.new(start_offset: from, end_offset: upto,
|
|
411
|
+
replacement: "", safety: :unsafe)
|
|
143
412
|
end
|
|
144
413
|
end
|
|
145
414
|
|
|
@@ -188,12 +457,28 @@ module Audition
|
|
|
188
457
|
def visit_def_node(node)
|
|
189
458
|
kind =
|
|
190
459
|
node.receiver.is_a?(Prism::SelfNode) ? :self : :plain
|
|
191
|
-
@def_stack.push(
|
|
460
|
+
@def_stack.push(
|
|
461
|
+
{kind: kind, id: node.object_id, name: node.name}
|
|
462
|
+
)
|
|
192
463
|
super
|
|
193
464
|
ensure
|
|
194
465
|
@def_stack.pop
|
|
195
466
|
end
|
|
196
467
|
|
|
468
|
+
# Matches the guard statement of the second memoization
|
|
469
|
+
# idiom: `return @x if defined?(@x)`. On a match the inner
|
|
470
|
+
# reads are not visited (they belong to the guard, not to
|
|
471
|
+
# the data flow) and the whole statement is recorded so the
|
|
472
|
+
# planner can delete it.
|
|
473
|
+
def visit_if_node(node)
|
|
474
|
+
name = guard_name(node)
|
|
475
|
+
if name
|
|
476
|
+
record(node, :guard, name)
|
|
477
|
+
else
|
|
478
|
+
super
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
197
482
|
{
|
|
198
483
|
visit_instance_variable_read_node: :read,
|
|
199
484
|
visit_instance_variable_write_node: :write,
|
|
@@ -202,22 +487,46 @@ module Audition
|
|
|
202
487
|
visit_instance_variable_and_write_node: :other,
|
|
203
488
|
visit_instance_variable_target_node: :other
|
|
204
489
|
}.each do |method, kind|
|
|
205
|
-
define_method(method) do |node|
|
|
206
|
-
record(node, kind)
|
|
490
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
491
|
+
record(node, kind, node.name)
|
|
207
492
|
super(node)
|
|
208
493
|
end
|
|
209
494
|
end
|
|
210
495
|
|
|
211
496
|
private
|
|
212
497
|
|
|
213
|
-
def
|
|
498
|
+
def guard_name(node)
|
|
499
|
+
predicate = node.predicate
|
|
500
|
+
return unless predicate.is_a?(Prism::DefinedNode)
|
|
501
|
+
|
|
502
|
+
checked = predicate.value
|
|
503
|
+
return unless checked.is_a?(Prism::InstanceVariableReadNode)
|
|
504
|
+
return if node.subsequent
|
|
505
|
+
|
|
506
|
+
body = node.statements&.body
|
|
507
|
+
return unless body && body.size == 1
|
|
508
|
+
return unless body[0].is_a?(Prism::ReturnNode)
|
|
509
|
+
|
|
510
|
+
returned = body[0].arguments&.arguments
|
|
511
|
+
return unless returned && returned.size == 1
|
|
512
|
+
return unless returned[0]
|
|
513
|
+
.is_a?(Prism::InstanceVariableReadNode)
|
|
514
|
+
return unless returned[0].name == checked.name
|
|
515
|
+
|
|
516
|
+
checked.name
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def record(node, kind, name)
|
|
214
520
|
return if @namespace.empty?
|
|
215
521
|
|
|
216
|
-
|
|
522
|
+
current_def = @def_stack.last
|
|
217
523
|
singleton_method =
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
524
|
+
current_def && (
|
|
525
|
+
current_def[:kind] == :self ||
|
|
526
|
+
(current_def[:kind] == :plain &&
|
|
527
|
+
@sclass_depth.positive?)
|
|
528
|
+
)
|
|
529
|
+
if current_def
|
|
221
530
|
return unless singleton_method
|
|
222
531
|
|
|
223
532
|
body = false
|
|
@@ -225,8 +534,12 @@ module Audition
|
|
|
225
534
|
body = true
|
|
226
535
|
end
|
|
227
536
|
|
|
228
|
-
key = [@namespace.join("::"),
|
|
229
|
-
@groups[key] << {
|
|
537
|
+
key = [@namespace.join("::"), name.to_s]
|
|
538
|
+
@groups[key] << {
|
|
539
|
+
node: node, kind: kind, body: body,
|
|
540
|
+
def_id: current_def && current_def[:id],
|
|
541
|
+
def_name: current_def && current_def[:name]
|
|
542
|
+
}
|
|
230
543
|
end
|
|
231
544
|
end
|
|
232
545
|
end
|
|
@@ -276,7 +589,10 @@ module Audition
|
|
|
276
589
|
next unless (ops - writes).all? { |op| op[:kind] == :read }
|
|
277
590
|
|
|
278
591
|
write = writes[0][:node]
|
|
279
|
-
|
|
592
|
+
kind = classifier.classify(write.value)
|
|
593
|
+
next if kind == :sync_primitive
|
|
594
|
+
|
|
595
|
+
shareable = kind == :shareable
|
|
280
596
|
# A mutable value that would get deep-frozen must never be
|
|
281
597
|
# the receiver of a call afterwards: `X[k] = v`, `X << v`
|
|
282
598
|
# and friends would raise FrozenError at runtime. Reads of
|
|
@@ -418,14 +734,14 @@ module Audition
|
|
|
418
734
|
}.freeze
|
|
419
735
|
|
|
420
736
|
GVAR_NODES.each do |method, kind|
|
|
421
|
-
define_method(method) do |node|
|
|
737
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
422
738
|
record_gvar(node, kind) if @mode == :gvar
|
|
423
739
|
super(node)
|
|
424
740
|
end
|
|
425
741
|
end
|
|
426
742
|
|
|
427
743
|
CVAR_NODES.each do |method, kind|
|
|
428
|
-
define_method(method) do |node|
|
|
744
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
429
745
|
record_cvar(node, kind) if @mode == :cvar
|
|
430
746
|
super(node)
|
|
431
747
|
end
|
|
@@ -71,10 +71,42 @@ module Audition
|
|
|
71
71
|
"it isolates self-contained lambdas; or " \
|
|
72
72
|
"promote the logic to a method."
|
|
73
73
|
|
|
74
|
+
explain :hash_default_proc,
|
|
75
|
+
severity: :error,
|
|
76
|
+
message: "constant %{name} holds a Hash with a " \
|
|
77
|
+
"default proc",
|
|
78
|
+
why: "The default block is a Proc baked into the " \
|
|
79
|
+
"Hash, and freezing the Hash does not make " \
|
|
80
|
+
"the block shareable; a non-main Ractor " \
|
|
81
|
+
"reading this constant raises " \
|
|
82
|
+
"Ractor::IsolationError. Rails removed this " \
|
|
83
|
+
"pattern twice during its ractorization.",
|
|
84
|
+
fix: "Use a plain frozen Hash with explicit keys, " \
|
|
85
|
+
"or drop the default proc and fetch with a " \
|
|
86
|
+
"literal default: hash.fetch(key, [])."
|
|
87
|
+
|
|
88
|
+
explain :constant_mutation,
|
|
89
|
+
severity: :warning,
|
|
90
|
+
message: "in-place %{method} on constant %{name}",
|
|
91
|
+
why: "A constant mutated after load is shared " \
|
|
92
|
+
"mutable state: non-main Ractors raise " \
|
|
93
|
+
"Ractor::IsolationError reading it while " \
|
|
94
|
+
"unfrozen, and freezing it turns this call " \
|
|
95
|
+
"into a FrozenError.",
|
|
96
|
+
fix: "Build the complete value at load time and " \
|
|
97
|
+
"freeze it (each_with_object then .freeze), " \
|
|
98
|
+
"or move the registry behind a writer that " \
|
|
99
|
+
"rebuilds and refreezes on each change, the " \
|
|
100
|
+
"copy-on-write style Rails registries use."
|
|
101
|
+
|
|
74
102
|
on :constant_write_node, :constant_or_write_node do |node|
|
|
75
103
|
examine(node.name.to_s, node, node.value)
|
|
76
104
|
end
|
|
77
105
|
|
|
106
|
+
on :call_node do |node|
|
|
107
|
+
flag_constant_mutation(node)
|
|
108
|
+
end
|
|
109
|
+
|
|
78
110
|
on :constant_path_write_node,
|
|
79
111
|
:constant_path_or_write_node do |node|
|
|
80
112
|
examine(node.target.location.slice, node, node.value)
|
|
@@ -90,18 +122,55 @@ module Audition
|
|
|
90
122
|
flag(node, :mutable_string, name: name,
|
|
91
123
|
autofix: append_freeze(value))
|
|
92
124
|
when :mutable_container
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
flag(node, :mutable_container, name: name,
|
|
126
|
+
type: container_type(value),
|
|
95
127
|
autofix: wrap_make_shareable(value))
|
|
96
128
|
when :shallow_freeze
|
|
97
129
|
flag(node, :shallow_freeze, name: name,
|
|
98
130
|
autofix: replace_with_make_shareable(value))
|
|
99
131
|
when :sync_primitive
|
|
132
|
+
klass = value.is_a?(Prism::CallNode) &&
|
|
133
|
+
classifier.const_name(value.receiver)
|
|
100
134
|
flag(node, :sync_primitive, name: name,
|
|
101
|
-
klass:
|
|
135
|
+
klass: klass || "sync primitive")
|
|
102
136
|
when :proc
|
|
103
137
|
flag(node, :proc_constant, name: name,
|
|
104
138
|
autofix: wrap_make_shareable(value))
|
|
139
|
+
when :default_proc
|
|
140
|
+
flag(node, :hash_default_proc, name: name)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Mutators that read as data mutation, not as class-level
|
|
145
|
+
# method names (delete/update/store would collide with
|
|
146
|
+
# Active Record class methods). Screaming-case receivers
|
|
147
|
+
# only: `RENDERERS << x` is a registry mutation, while
|
|
148
|
+
# `User << x` is a class method call.
|
|
149
|
+
MUTATORS = Ractor.make_shareable(
|
|
150
|
+
%i[[]= << push unshift concat merge! replace]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def flag_constant_mutation(node)
|
|
154
|
+
return unless MUTATORS.include?(node.name)
|
|
155
|
+
|
|
156
|
+
name = classifier.const_name(node.receiver)
|
|
157
|
+
return unless name
|
|
158
|
+
return if name == "ENV"
|
|
159
|
+
return unless name.split("::").last
|
|
160
|
+
.match?(/\A[A-Z][A-Z0-9_]*\z/)
|
|
161
|
+
|
|
162
|
+
flag(node, :constant_mutation, name: name,
|
|
163
|
+
method: node.name.to_s)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def container_type(value)
|
|
167
|
+
case value
|
|
168
|
+
when Prism::HashNode, Prism::KeywordHashNode then "Hash"
|
|
169
|
+
when Prism::ArrayNode then "Array"
|
|
170
|
+
when Prism::CallNode
|
|
171
|
+
classifier.const_name(value.receiver) || "container"
|
|
172
|
+
else
|
|
173
|
+
"container"
|
|
105
174
|
end
|
|
106
175
|
end
|
|
107
176
|
|
|
@@ -111,13 +180,26 @@ module Audition
|
|
|
111
180
|
)
|
|
112
181
|
end
|
|
113
182
|
|
|
183
|
+
# Ternaries classify as strings when both branches are;
|
|
184
|
+
# `.freeze` binds tighter than `?:`, so they get parens.
|
|
114
185
|
def append_freeze(value)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
end_offset
|
|
119
|
-
|
|
120
|
-
|
|
186
|
+
string = value.is_a?(Prism::StringNode) ||
|
|
187
|
+
value.is_a?(Prism::InterpolatedStringNode)
|
|
188
|
+
if string
|
|
189
|
+
offset = value.location.end_offset
|
|
190
|
+
Autofix.new(
|
|
191
|
+
start_offset: offset,
|
|
192
|
+
end_offset: offset,
|
|
193
|
+
replacement: ".freeze"
|
|
194
|
+
)
|
|
195
|
+
else
|
|
196
|
+
source = value.location.slice
|
|
197
|
+
Autofix.new(
|
|
198
|
+
start_offset: value.location.start_offset,
|
|
199
|
+
end_offset: value.location.end_offset,
|
|
200
|
+
replacement: "(#{source}).freeze"
|
|
201
|
+
)
|
|
202
|
+
end
|
|
121
203
|
end
|
|
122
204
|
|
|
123
205
|
def wrap_make_shareable(value)
|
|
@@ -93,7 +93,7 @@ module Audition
|
|
|
93
93
|
visit_local_variable_and_write_node
|
|
94
94
|
visit_local_variable_target_node
|
|
95
95
|
].each do |method|
|
|
96
|
-
define_method(method) do |node|
|
|
96
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
97
97
|
@names << node.name.to_s if node.depth > @level
|
|
98
98
|
super(node)
|
|
99
99
|
end
|