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
data/lib/audition/rewriters.rb
CHANGED
|
@@ -4,10 +4,51 @@ require "prism"
|
|
|
4
4
|
|
|
5
5
|
module Audition
|
|
6
6
|
# Unsafe-tier, multi-site rewrites planned at fix time from a
|
|
7
|
-
# parsed file plus its findings.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# parsed file plus its findings. MagicComments.plan returns a
|
|
8
|
+
# file-level edit plus the list of checks it makes redundant.
|
|
9
|
+
# Memoization.plan and WriteOnce.plan return one bundle per
|
|
10
|
+
# converted variable group ({edits:, sites:}, safety :unsafe);
|
|
11
|
+
# Rewriters.resolve flattens them to edits after dropping groups
|
|
12
|
+
# whose edits would swallow another converted group's sites.
|
|
10
13
|
module Rewriters
|
|
14
|
+
# A planned conversion for one variable group: the group's
|
|
15
|
+
# edits plus the byte spans of every recorded site, so
|
|
16
|
+
# cross-group nesting is visible before edits are applied.
|
|
17
|
+
# Nil when the group produced no edits.
|
|
18
|
+
def self.bundle(ops, edits)
|
|
19
|
+
return nil if edits.empty?
|
|
20
|
+
|
|
21
|
+
sites = ops.map do |op|
|
|
22
|
+
location = op[:node].location
|
|
23
|
+
[location.start_offset, location.end_offset]
|
|
24
|
+
end
|
|
25
|
+
{edits: edits, sites: sites}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The fixer keeps the first of two overlapping edits, so an
|
|
29
|
+
# edit whose span contains a site of a different converted
|
|
30
|
+
# group would keep that nested site's old text while the rest
|
|
31
|
+
# of the other group moves (a stale read at runtime). Such
|
|
32
|
+
# groups are skipped here and keep their findings.
|
|
33
|
+
def self.resolve(bundles)
|
|
34
|
+
bundles.flat_map do |bundle|
|
|
35
|
+
clobbers = bundles.any? do |other|
|
|
36
|
+
!other.equal?(bundle) && swallows?(bundle, other)
|
|
37
|
+
end
|
|
38
|
+
clobbers ? [] : bundle[:edits]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.swallows?(bundle, other)
|
|
43
|
+
bundle[:edits].any? do |edit|
|
|
44
|
+
next false if edit.start_offset >= edit.end_offset
|
|
45
|
+
|
|
46
|
+
other[:sites].any? do |from, upto|
|
|
47
|
+
edit.start_offset <= from && upto <= edit.end_offset
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
11
52
|
# -- magic comments ----------------------------------------------
|
|
12
53
|
|
|
13
54
|
module MagicComments
|
|
@@ -15,10 +56,14 @@ module Audition
|
|
|
15
56
|
FSL_LINE = "# frozen_string_literal: true\n"
|
|
16
57
|
|
|
17
58
|
# `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
|
-
#
|
|
59
|
+
# non-literal constant values (verified on 4.0), so it is
|
|
60
|
+
# only planned when every constant assignment in the file is
|
|
61
|
+
# a literal all the way down: an array literal holding a
|
|
62
|
+
# local or a call (Racc-generated parser tables are the
|
|
63
|
+
# canonical case) becomes an unshareable value that the
|
|
64
|
+
# magic comment rejects at load time. Otherwise, if the
|
|
65
|
+
# flagged values are all strings, frozen_string_literal
|
|
66
|
+
# covers them.
|
|
22
67
|
def self.plan(file, findings)
|
|
23
68
|
return nil if file.shareable_constants?
|
|
24
69
|
return nil if findings.none? { |f| f.check == "mutable-constants" }
|
|
@@ -26,23 +71,69 @@ module Audition
|
|
|
26
71
|
classifier = Static::LiteralClassifier.new(
|
|
27
72
|
frozen_string_literal: file.frozen_string_literal?
|
|
28
73
|
)
|
|
29
|
-
|
|
74
|
+
collector = constant_collector(file)
|
|
75
|
+
# A constant assigned here and mutated here is a
|
|
76
|
+
# deliberate accumulator (sinatra's PARAMS_CONFIG);
|
|
77
|
+
# freezing the file's constants would raise at the
|
|
78
|
+
# mutation site.
|
|
79
|
+
return nil if mutates_own_constant?(file, collector.names)
|
|
80
|
+
|
|
81
|
+
values = collector.values
|
|
82
|
+
kinds = values.map { |v| classifier.classify(v) }
|
|
30
83
|
flagged = kinds.reject do |k|
|
|
31
84
|
%i[shareable unknown].include?(k)
|
|
32
85
|
end
|
|
33
|
-
scv_ok =
|
|
34
|
-
|
|
35
|
-
shallow_freeze].include?(k)
|
|
36
|
-
end
|
|
86
|
+
scv_ok = values.any? &&
|
|
87
|
+
values.all? { |value| deep_literal?(value, classifier) }
|
|
37
88
|
|
|
89
|
+
# Both branches demand something to fix: with no constant
|
|
90
|
+
# values (a lone constant-mutation finding) or nothing
|
|
91
|
+
# flagged, a comment would freeze unrelated code while
|
|
92
|
+
# fixing nothing. An explicit `frozen_string_literal:
|
|
93
|
+
# false` is an author opt-out and is never overridden.
|
|
38
94
|
if scv_ok
|
|
39
95
|
comment_plan(file, SCV_LINE)
|
|
40
|
-
elsif
|
|
96
|
+
elsif flagged.any? &&
|
|
97
|
+
file.magic_comment("frozen_string_literal").nil? &&
|
|
41
98
|
flagged.all? { |k| k == :mutable_string }
|
|
42
99
|
comment_plan(file, FSL_LINE)
|
|
43
100
|
end
|
|
44
101
|
end
|
|
45
102
|
|
|
103
|
+
def self.mutates_own_constant?(file, assigned)
|
|
104
|
+
mutated = file.mutated_constants
|
|
105
|
+
assigned.any? do |name|
|
|
106
|
+
bare = name.split("::").last
|
|
107
|
+
mutated.any? do |m|
|
|
108
|
+
m == name || m.split("::").last == bare
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Bare literals only: `[...].freeze` is a method call, and
|
|
114
|
+
# under the magic comment Ruby raises for it at assignment
|
|
115
|
+
# because the shallowly-frozen value is unshareable
|
|
116
|
+
# (verified on 4.0 with jwt's NAMED_CURVES).
|
|
117
|
+
def self.deep_literal?(node, classifier)
|
|
118
|
+
case node
|
|
119
|
+
when Prism::ArrayNode
|
|
120
|
+
node.elements.all? do |element|
|
|
121
|
+
deep_literal?(element, classifier)
|
|
122
|
+
end
|
|
123
|
+
when Prism::HashNode, Prism::KeywordHashNode
|
|
124
|
+
node.elements.all? do |element|
|
|
125
|
+
element.is_a?(Prism::AssocNode) &&
|
|
126
|
+
deep_literal?(element.key, classifier) &&
|
|
127
|
+
deep_literal?(element.value, classifier)
|
|
128
|
+
end
|
|
129
|
+
when Prism::CallNode
|
|
130
|
+
false
|
|
131
|
+
else
|
|
132
|
+
%i[shareable mutable_string]
|
|
133
|
+
.include?(classifier.classify(node))
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
46
137
|
def self.comment_plan(file, line)
|
|
47
138
|
offset = file.magic_insertion_offset
|
|
48
139
|
{
|
|
@@ -54,28 +145,39 @@ module Audition
|
|
|
54
145
|
}
|
|
55
146
|
end
|
|
56
147
|
|
|
57
|
-
def self.
|
|
148
|
+
def self.constant_collector(file)
|
|
58
149
|
collector = ConstantValues.new
|
|
59
150
|
collector.visit(file.root)
|
|
60
|
-
collector
|
|
151
|
+
collector
|
|
61
152
|
end
|
|
62
153
|
|
|
63
154
|
class ConstantValues < Prism::Visitor
|
|
64
|
-
attr_reader :values
|
|
155
|
+
attr_reader :values, :names
|
|
65
156
|
|
|
66
157
|
def initialize
|
|
67
158
|
@values = []
|
|
159
|
+
@names = []
|
|
68
160
|
super
|
|
69
161
|
end
|
|
70
162
|
|
|
71
163
|
%i[
|
|
72
164
|
visit_constant_write_node
|
|
73
165
|
visit_constant_or_write_node
|
|
166
|
+
].each do |method|
|
|
167
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
168
|
+
@values << node.value
|
|
169
|
+
@names << node.name.to_s
|
|
170
|
+
super(node)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
%i[
|
|
74
175
|
visit_constant_path_write_node
|
|
75
176
|
visit_constant_path_or_write_node
|
|
76
177
|
].each do |method|
|
|
77
|
-
define_method(method) do |node|
|
|
178
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
78
179
|
@values << node.value
|
|
180
|
+
@names << node.target.location.slice
|
|
79
181
|
super(node)
|
|
80
182
|
end
|
|
81
183
|
end
|
|
@@ -84,65 +186,424 @@ module Audition
|
|
|
84
186
|
|
|
85
187
|
# -- class-level memoization -------------------------------------
|
|
86
188
|
|
|
87
|
-
# Rewrites singleton-scope
|
|
189
|
+
# Rewrites singleton-scope memoization, both idioms:
|
|
190
|
+
# @x ||= expr
|
|
191
|
+
# return @x if defined?(@x); @x = expr
|
|
192
|
+
#
|
|
193
|
+
# Preferred strategy is freeze-on-memoize, the pattern Rails
|
|
194
|
+
# core applies to its own code: the memoization stays exactly
|
|
195
|
+
# as written and only the memoized value becomes shareable
|
|
196
|
+
# (`.freeze` appended; Ractor.make_shareable for containers).
|
|
197
|
+
# Non-main Ractors may then read the ivar once it has been
|
|
198
|
+
# computed; the first write must still happen on the main
|
|
199
|
+
# Ractor, which is a boot-warming concern the static check
|
|
200
|
+
# reports as an info note. Chosen when the value expression
|
|
201
|
+
# carries no block (a proxy for one-time side effects) and no
|
|
202
|
+
# writes exist outside the memo sites.
|
|
203
|
+
#
|
|
204
|
+
# Otherwise falls back to Ractor-local storage:
|
|
88
205
|
# @x ||= expr -> Ractor.store_if_absent(:"Klass/@x") { expr }
|
|
89
206
|
# @x = expr -> Ractor.current[:"Klass/@x"] = expr
|
|
90
207
|
# @x -> Ractor.current[:"Klass/@x"]
|
|
208
|
+
#
|
|
91
209
|
# Only when every reference in singleton scope is visible in
|
|
92
210
|
# this file, none sit directly in the class body, and no
|
|
93
|
-
# compound writes exist. Caveat (why this is unsafe):
|
|
94
|
-
#
|
|
95
|
-
#
|
|
211
|
+
# compound writes exist. Caveat (why this is unsafe): freezing
|
|
212
|
+
# changes value mutability, and each Ractor computes its own
|
|
213
|
+
# copy under store_if_absent.
|
|
96
214
|
module Memoization
|
|
97
215
|
def self.plan(file, findings)
|
|
98
216
|
return [] if findings.none? { |f| f.check == "class-level-state" }
|
|
99
217
|
|
|
100
218
|
collector = SingletonIvars.new
|
|
101
219
|
collector.visit(file.root)
|
|
102
|
-
|
|
220
|
+
bundles = []
|
|
103
221
|
collector.groups.each do |(namespace, name), ops|
|
|
104
222
|
next if namespace.empty?
|
|
105
223
|
|
|
106
224
|
kinds = ops.map { |op| op[:kind] }
|
|
107
|
-
next unless kinds.include?(:or_write)
|
|
108
225
|
next if kinds.include?(:other)
|
|
109
226
|
next if ops.any? { |op| op[:body] }
|
|
110
227
|
|
|
111
|
-
|
|
112
|
-
|
|
228
|
+
memos = memo_sites(ops)
|
|
229
|
+
if memos.empty?
|
|
230
|
+
bundles << Rewriters.bundle(ops, setter_edits(file, ops))
|
|
231
|
+
next
|
|
232
|
+
end
|
|
233
|
+
next if orphan_guards?(ops, memos)
|
|
234
|
+
next if guarded_with_strays?(ops, memos)
|
|
235
|
+
# `@x ||= {}` is a lazily-built accumulator, mutated
|
|
236
|
+
# through the accessor after memoization (jwt's
|
|
237
|
+
# algorithm registry). Freezing it breaks registration
|
|
238
|
+
# and Ractor-local copies would leave other Ractors an
|
|
239
|
+
# empty registry; the copy-on-write refactor is human
|
|
240
|
+
# work, so no edit is offered. Constructor memos (`new`,
|
|
241
|
+
# `Set.new`, `.dup`) are the same family: liquid's
|
|
242
|
+
# filter set and money's bank singleton both broke under
|
|
243
|
+
# freezing, and per-Ractor copies hide main-Ractor
|
|
244
|
+
# registrations.
|
|
245
|
+
next if memos.any? { |m| accumulator?(m[:op][:node].value) }
|
|
246
|
+
next if memos.any? do |m|
|
|
247
|
+
constructor?(m[:op][:node].value, classifier(file))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if freezable?(ops, memos)
|
|
251
|
+
bundles << Rewriters.bundle(ops, freeze_edits(file, memos))
|
|
252
|
+
else
|
|
253
|
+
# Ractor-local slots are keyed by lexical owner; on a
|
|
254
|
+
# class the ivar is per-subclass (faraday's
|
|
255
|
+
# DEFAULT_OPTIONS), and one shared key would merge
|
|
256
|
+
# every subclass's state. Modules cannot be
|
|
257
|
+
# subclassed, so only module-owned state converts.
|
|
258
|
+
next if ops.any? { |op| op[:class_owner] }
|
|
259
|
+
# Deleting the defined? guard sends every call through
|
|
260
|
+
# the statements after the write, so the warm path
|
|
261
|
+
# only keeps returning the memo when the guarded write
|
|
262
|
+
# ends its def body (or a bare read of the same ivar
|
|
263
|
+
# does).
|
|
264
|
+
next unless memos.all? do |m|
|
|
265
|
+
m[:guard].nil? || tail_write?(m[:op])
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
key = %(:"#{namespace}/#{name}")
|
|
269
|
+
bundles << Rewriters.bundle(
|
|
270
|
+
ops, ractor_edits(file, ops, memos, key)
|
|
271
|
+
)
|
|
272
|
+
end
|
|
113
273
|
end
|
|
114
|
-
|
|
274
|
+
bundles.compact
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def self.tail_write?(op)
|
|
278
|
+
body = op[:def_node]&.body
|
|
279
|
+
return false unless body.is_a?(Prism::StatementsNode)
|
|
280
|
+
|
|
281
|
+
statements = body.body
|
|
282
|
+
index = statements.index { |s| s.equal?(op[:node]) }
|
|
283
|
+
return false unless index
|
|
284
|
+
|
|
285
|
+
rest = statements[(index + 1)..]
|
|
286
|
+
return true if rest.empty?
|
|
287
|
+
|
|
288
|
+
rest.size == 1 &&
|
|
289
|
+
rest[0].is_a?(Prism::InstanceVariableReadNode) &&
|
|
290
|
+
rest[0].name == op[:node].name
|
|
115
291
|
end
|
|
116
292
|
|
|
117
|
-
def self.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
293
|
+
def self.classifier(file)
|
|
294
|
+
Static::LiteralClassifier.new(
|
|
295
|
+
frozen_string_literal: file.frozen_string_literal?
|
|
296
|
+
)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def self.constructor?(value, classifier)
|
|
300
|
+
value.is_a?(Prism::CallNode) &&
|
|
301
|
+
%i[new dup clone].include?(value.name) &&
|
|
302
|
+
classifier.classify(value) != :shareable
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# A memo site is an ||= write, or a plain write paired with a
|
|
306
|
+
# defined? return guard inside the same method. A guarded
|
|
307
|
+
# method with more than one write is nobody's memoization;
|
|
308
|
+
# such groups are dropped by the orphan check below.
|
|
309
|
+
def self.memo_sites(ops)
|
|
310
|
+
guards = ops.select { |op| op[:kind] == :guard }
|
|
311
|
+
ops.filter_map do |op|
|
|
312
|
+
case op[:kind]
|
|
313
|
+
when :or_write
|
|
314
|
+
{op: op, guard: nil}
|
|
315
|
+
when :write
|
|
316
|
+
guard = guards.find { |g| g[:def_id] == op[:def_id] }
|
|
317
|
+
{op: op, guard: guard} if guard
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def self.accumulator?(value)
|
|
323
|
+
(value.is_a?(Prism::ArrayNode) ||
|
|
324
|
+
value.is_a?(Prism::HashNode)) && value.elements.empty?
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Config setters (`def self.backend=(value); @backend =
|
|
328
|
+
# value; end`) get the Rails try_make_shareable recipe in
|
|
329
|
+
# plain Ruby: shareable values are deeply frozen so reads
|
|
330
|
+
# from any Ractor become legal, unshareable values keep
|
|
331
|
+
# today's behavior through the rescue. Only bare local reads
|
|
332
|
+
# are wrapped; computed values are left for a human.
|
|
333
|
+
def self.setter_edits(file, ops)
|
|
334
|
+
receivers = nil
|
|
335
|
+
ops.filter_map do |op|
|
|
336
|
+
next unless op[:kind] == :write
|
|
337
|
+
# Only genuine setters: a plain method restoring a saved
|
|
338
|
+
# local (sinatra's route conditions) and operator defs
|
|
339
|
+
# ([]=) must stay untouched.
|
|
340
|
+
setter = op[:def_name].to_s
|
|
341
|
+
next unless setter.match?(/\A\w+=\z/)
|
|
342
|
+
|
|
343
|
+
value = op[:node].value
|
|
344
|
+
next unless value.is_a?(Prism::LocalVariableReadNode)
|
|
345
|
+
|
|
346
|
+
# A value the file mutates in place through the reader
|
|
347
|
+
# accessor (`def self.set(k, v); options[k] = v; end`)
|
|
348
|
+
# or through a direct read of the ivar must never be
|
|
349
|
+
# frozen: the mutation would raise FrozenError.
|
|
350
|
+
receivers ||= mutator_receivers(file)
|
|
351
|
+
reader = setter.delete_suffix("=").to_sym
|
|
352
|
+
mutated = receivers.any? do |receiver|
|
|
353
|
+
(receiver.is_a?(Prism::CallNode) &&
|
|
354
|
+
receiver.name == reader) ||
|
|
355
|
+
(receiver.is_a?(Prism::InstanceVariableReadNode) &&
|
|
356
|
+
receiver.name == op[:node].name)
|
|
357
|
+
end
|
|
358
|
+
next if mutated
|
|
359
|
+
|
|
360
|
+
local = value.name
|
|
122
361
|
Autofix.new(
|
|
123
|
-
start_offset:
|
|
124
|
-
end_offset:
|
|
362
|
+
start_offset: value.location.start_offset,
|
|
363
|
+
end_offset: value.location.end_offset,
|
|
125
364
|
replacement:
|
|
126
|
-
"Ractor.
|
|
127
|
-
safety: :unsafe
|
|
128
|
-
)
|
|
129
|
-
when :write
|
|
130
|
-
Autofix.new(
|
|
131
|
-
start_offset: node.name_loc.start_offset,
|
|
132
|
-
end_offset: node.name_loc.end_offset,
|
|
133
|
-
replacement: "Ractor.current[#{key}]",
|
|
134
|
-
safety: :unsafe
|
|
135
|
-
)
|
|
136
|
-
when :read
|
|
137
|
-
Autofix.new(
|
|
138
|
-
start_offset: node.location.start_offset,
|
|
139
|
-
end_offset: node.location.end_offset,
|
|
140
|
-
replacement: "Ractor.current[#{key}]",
|
|
365
|
+
"(Ractor.make_shareable(#{local}) rescue #{local})",
|
|
141
366
|
safety: :unsafe
|
|
142
367
|
)
|
|
143
368
|
end
|
|
144
369
|
end
|
|
145
370
|
|
|
371
|
+
# Receivers of in-place mutator calls anywhere in the file,
|
|
372
|
+
# index writes included; mirrors SourceFile#mutated_constants.
|
|
373
|
+
def self.mutator_receivers(file)
|
|
374
|
+
receivers = []
|
|
375
|
+
queue = [file.root]
|
|
376
|
+
until queue.empty?
|
|
377
|
+
node = queue.shift
|
|
378
|
+
queue.concat(node.child_nodes.compact)
|
|
379
|
+
mutator =
|
|
380
|
+
(node.is_a?(Prism::CallNode) &&
|
|
381
|
+
Static::SourceFile::CONST_MUTATORS
|
|
382
|
+
.include?(node.name)) ||
|
|
383
|
+
Static::SourceFile::INDEX_WRITES
|
|
384
|
+
.any? { |type| node.is_a?(type) }
|
|
385
|
+
next unless mutator && node.receiver
|
|
386
|
+
|
|
387
|
+
receivers << node.receiver
|
|
388
|
+
end
|
|
389
|
+
receivers
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def self.guarded_with_strays?(ops, memos)
|
|
393
|
+
return false if memos.none? { |memo| memo[:guard] }
|
|
394
|
+
|
|
395
|
+
memo_ops = memos.map { |memo| memo[:op] }
|
|
396
|
+
ops.any? do |op|
|
|
397
|
+
op[:kind] == :write && !memo_ops.include?(op)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def self.orphan_guards?(ops, memos)
|
|
402
|
+
used = memos.filter_map { |m| m[:guard] }
|
|
403
|
+
guards = ops.select { |op| op[:kind] == :guard }
|
|
404
|
+
return true if guards.size != used.size
|
|
405
|
+
|
|
406
|
+
writes = ops.select { |op| op[:kind] == :write }
|
|
407
|
+
guards.any? do |guard|
|
|
408
|
+
writes.count { |w| w[:def_id] == guard[:def_id] } != 1
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Freeze-on-memoize applies when every write is a memo site
|
|
413
|
+
# (a stray write means cache invalidation; frozen values
|
|
414
|
+
# cannot support that) and no value needs a block to build.
|
|
415
|
+
def self.freezable?(ops, memos)
|
|
416
|
+
memo_ops = memos.map { |memo| memo[:op] }
|
|
417
|
+
writes = ops.select { |op| op[:kind] == :write }
|
|
418
|
+
return false unless (writes - memo_ops).empty?
|
|
419
|
+
|
|
420
|
+
memos.all? { |memo| blockless?(memo[:op][:node].value) }
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def self.blockless?(node)
|
|
424
|
+
queue = [node]
|
|
425
|
+
until queue.empty?
|
|
426
|
+
current = queue.shift
|
|
427
|
+
if current.is_a?(Prism::BlockNode) ||
|
|
428
|
+
current.is_a?(Prism::LambdaNode)
|
|
429
|
+
return false
|
|
430
|
+
end
|
|
431
|
+
queue.concat(current.child_nodes.compact)
|
|
432
|
+
end
|
|
433
|
+
true
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# One edit per memo site: make the memoized value shareable
|
|
437
|
+
# while leaving the memoization intact. Guards, sibling
|
|
438
|
+
# reads, and method shapes stay untouched.
|
|
439
|
+
def self.freeze_edits(file, memos)
|
|
440
|
+
kinds = classifier(file)
|
|
441
|
+
memos.filter_map do |memo|
|
|
442
|
+
value = memo[:op][:node].value
|
|
443
|
+
freeze_value(value, kinds.classify(value))
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Plain `.freeze` only where the value is provably a string;
|
|
448
|
+
# everything unproven gets Ractor.make_shareable, which is a
|
|
449
|
+
# no-op for already-shareable values. This matters for
|
|
450
|
+
# memoized classes (multi_json memoizes adapter classes):
|
|
451
|
+
# `.freeze` on a Class freezes the class object and later
|
|
452
|
+
# ivar writes on it raise FrozenError.
|
|
453
|
+
def self.freeze_value(value, kind)
|
|
454
|
+
return nil if kind == :shareable
|
|
455
|
+
# Freezing or wrapping a sync primitive raises; leave the
|
|
456
|
+
# finding in place for a human.
|
|
457
|
+
return nil if kind == :sync_primitive
|
|
458
|
+
return nil if frozen_call?(value)
|
|
459
|
+
|
|
460
|
+
slice = value.location.slice
|
|
461
|
+
replacement =
|
|
462
|
+
if kind == :mutable_string
|
|
463
|
+
parens?(value) ? "(#{slice}).freeze" : "#{slice}.freeze"
|
|
464
|
+
else
|
|
465
|
+
"Ractor.make_shareable(#{slice})"
|
|
466
|
+
end
|
|
467
|
+
Autofix.new(
|
|
468
|
+
start_offset: value.location.start_offset,
|
|
469
|
+
end_offset: value.location.end_offset,
|
|
470
|
+
replacement: replacement,
|
|
471
|
+
safety: :unsafe
|
|
472
|
+
)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def self.frozen_call?(value)
|
|
476
|
+
value.is_a?(Prism::CallNode) &&
|
|
477
|
+
value.name == :freeze &&
|
|
478
|
+
value.receiver && value.arguments.nil?
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# `.freeze` binds tighter than operators and ternaries, so
|
|
482
|
+
# compound expressions get wrapped; message sends and plain
|
|
483
|
+
# literals do not need it.
|
|
484
|
+
def self.parens?(value)
|
|
485
|
+
case value
|
|
486
|
+
when Prism::CallNode
|
|
487
|
+
!value.name.to_s.match?(/\A[a-z_]/i)
|
|
488
|
+
when Prism::StringNode, Prism::InterpolatedStringNode,
|
|
489
|
+
Prism::ArrayNode, Prism::HashNode,
|
|
490
|
+
Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
491
|
+
false
|
|
492
|
+
else
|
|
493
|
+
true
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Two Ractor-local flavors. With stray writes present (cache
|
|
498
|
+
# invalidation, `@x = nil`), memo sites become
|
|
499
|
+
# `Ractor.current[key] ||= expr`: it recomputes after a nil
|
|
500
|
+
# reset exactly like the original `||=`, where
|
|
501
|
+
# store_if_absent would treat the stored nil as present and
|
|
502
|
+
# never recompute (this broke i18n's reserved_keys_pattern).
|
|
503
|
+
# Without strays, store_if_absent keeps its atomic lazy
|
|
504
|
+
# init. Guard-idiom groups with strays are skipped entirely
|
|
505
|
+
# in plan: the defined? guard caches nil deliberately and
|
|
506
|
+
# neither flavor reproduces that alongside invalidation.
|
|
507
|
+
def self.ractor_edits(file, ops, memos, key)
|
|
508
|
+
guarded = memos.filter_map { |m| m[:op] if m[:guard] }
|
|
509
|
+
memo_ops = memos.map { |memo| memo[:op] }
|
|
510
|
+
strays = ops.any? do |op|
|
|
511
|
+
op[:kind] == :write && !memo_ops.include?(op)
|
|
512
|
+
end
|
|
513
|
+
edits = memos.filter_map do |memo|
|
|
514
|
+
deletion(file.source, memo[:guard][:node]) if memo[:guard]
|
|
515
|
+
end
|
|
516
|
+
ops.each do |op|
|
|
517
|
+
node = op[:node]
|
|
518
|
+
edits <<
|
|
519
|
+
if op[:kind] == :or_write || guarded.include?(op)
|
|
520
|
+
replacement =
|
|
521
|
+
if strays
|
|
522
|
+
value = node.value.location.slice
|
|
523
|
+
"Ractor.current[#{key}] ||= #{value}"
|
|
524
|
+
else
|
|
525
|
+
store_wrap(file.source, node, key)
|
|
526
|
+
end
|
|
527
|
+
Autofix.new(
|
|
528
|
+
start_offset: node.location.start_offset,
|
|
529
|
+
end_offset: node.location.end_offset,
|
|
530
|
+
replacement: replacement,
|
|
531
|
+
safety: :unsafe
|
|
532
|
+
)
|
|
533
|
+
elsif op[:kind] == :write
|
|
534
|
+
Autofix.new(
|
|
535
|
+
start_offset: node.name_loc.start_offset,
|
|
536
|
+
end_offset: node.name_loc.end_offset,
|
|
537
|
+
replacement: "Ractor.current[#{key}]",
|
|
538
|
+
safety: :unsafe
|
|
539
|
+
)
|
|
540
|
+
else
|
|
541
|
+
Autofix.new(
|
|
542
|
+
start_offset: node.location.start_offset,
|
|
543
|
+
end_offset: node.location.end_offset,
|
|
544
|
+
replacement: "Ractor.current[#{key}]",
|
|
545
|
+
safety: :unsafe
|
|
546
|
+
)
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
edits
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# A single-line value keeps the brace form; a multi-line
|
|
553
|
+
# value becomes a do..end block with the body shifted one
|
|
554
|
+
# level right, so the rewrite stays idiomatic. When the write
|
|
555
|
+
# shares its line with other code, layout cannot be inferred
|
|
556
|
+
# and the brace form is kept as-is.
|
|
557
|
+
def self.store_wrap(source, node, key)
|
|
558
|
+
value = node.value.location.slice
|
|
559
|
+
call = "Ractor.store_if_absent(#{key})"
|
|
560
|
+
return "#{call} { #{value} }" unless value.include?("\n")
|
|
561
|
+
|
|
562
|
+
raw = source.dup.force_encoding(Encoding::BINARY)
|
|
563
|
+
from = node.location.start_offset
|
|
564
|
+
start = from.zero? ? 0 : (raw.rindex("\n", from - 1) || -1) + 1
|
|
565
|
+
indent = raw[start...from].force_encoding(source.encoding)
|
|
566
|
+
return "#{call} { #{value} }" unless indent.match?(/\A[ \t]*\z/)
|
|
567
|
+
|
|
568
|
+
body = value.lines.map.with_index do |line, index|
|
|
569
|
+
if index.zero?
|
|
570
|
+
"#{indent} #{line}"
|
|
571
|
+
elsif line.match?(/\A\s*\z/)
|
|
572
|
+
line
|
|
573
|
+
else
|
|
574
|
+
" #{line}"
|
|
575
|
+
end
|
|
576
|
+
end.join
|
|
577
|
+
"#{call} do\n#{body}\n#{indent}end"
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# Deletes the guard statement together with its line and any
|
|
581
|
+
# blank lines that follow, so the method body does not open
|
|
582
|
+
# with a hole. Falls back to the bare node span when other
|
|
583
|
+
# code shares the line.
|
|
584
|
+
def self.deletion(source, node)
|
|
585
|
+
raw = source.dup.force_encoding(Encoding::BINARY)
|
|
586
|
+
from = node.location.start_offset
|
|
587
|
+
upto = node.location.end_offset
|
|
588
|
+
start = from.zero? ? 0 : (raw.rindex("\n", from - 1) || -1) + 1
|
|
589
|
+
if raw[start...from].match?(/\A[ \t]*\z/n)
|
|
590
|
+
newline = raw.index("\n", upto)
|
|
591
|
+
stop = newline ? newline + 1 : raw.length
|
|
592
|
+
loop do
|
|
593
|
+
newline = raw.index("\n", stop)
|
|
594
|
+
break unless newline
|
|
595
|
+
break unless raw[stop...newline].match?(/\A[ \t]*\z/n)
|
|
596
|
+
|
|
597
|
+
stop = newline + 1
|
|
598
|
+
end
|
|
599
|
+
Autofix.new(start_offset: start, end_offset: stop,
|
|
600
|
+
replacement: "", safety: :unsafe)
|
|
601
|
+
else
|
|
602
|
+
Autofix.new(start_offset: from, end_offset: upto,
|
|
603
|
+
replacement: "", safety: :unsafe)
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
146
607
|
# Collects ivar operations that touch the class object:
|
|
147
608
|
# inside `def self.x`, inside `class << self` methods, or
|
|
148
609
|
# directly in the class body (recorded with body: true, which
|
|
@@ -155,21 +616,20 @@ module Audition
|
|
|
155
616
|
@namespace = []
|
|
156
617
|
@sclass_depth = 0
|
|
157
618
|
@def_stack = []
|
|
619
|
+
@defined_depth = 0
|
|
158
620
|
super
|
|
159
621
|
end
|
|
160
622
|
|
|
161
623
|
def visit_class_node(node)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
@namespace.pop
|
|
624
|
+
scoped(node.constant_path.location.slice, :class) do
|
|
625
|
+
super(node)
|
|
626
|
+
end
|
|
166
627
|
end
|
|
167
628
|
|
|
168
629
|
def visit_module_node(node)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
@namespace.pop
|
|
630
|
+
scoped(node.constant_path.location.slice, :module) do
|
|
631
|
+
super(node)
|
|
632
|
+
end
|
|
173
633
|
end
|
|
174
634
|
|
|
175
635
|
def visit_singleton_class_node(node)
|
|
@@ -188,12 +648,41 @@ module Audition
|
|
|
188
648
|
def visit_def_node(node)
|
|
189
649
|
kind =
|
|
190
650
|
node.receiver.is_a?(Prism::SelfNode) ? :self : :plain
|
|
191
|
-
@def_stack.push(
|
|
651
|
+
@def_stack.push(
|
|
652
|
+
{kind: kind, id: node.object_id,
|
|
653
|
+
name: node.name, node: node}
|
|
654
|
+
)
|
|
192
655
|
super
|
|
193
656
|
ensure
|
|
194
657
|
@def_stack.pop
|
|
195
658
|
end
|
|
196
659
|
|
|
660
|
+
# A defined?(@x) that is not the recognized guard idiom
|
|
661
|
+
# probes ivar presence; rewriting the read to Ractor
|
|
662
|
+
# storage would make the probe unconditionally true, so
|
|
663
|
+
# every op inside disqualifies its group (recorded as
|
|
664
|
+
# :other).
|
|
665
|
+
def visit_defined_node(node)
|
|
666
|
+
@defined_depth += 1
|
|
667
|
+
super
|
|
668
|
+
ensure
|
|
669
|
+
@defined_depth -= 1
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# Matches the guard statement of the second memoization
|
|
673
|
+
# idiom: `return @x if defined?(@x)`. On a match the inner
|
|
674
|
+
# reads are not visited (they belong to the guard, not to
|
|
675
|
+
# the data flow) and the whole statement is recorded so the
|
|
676
|
+
# planner can delete it.
|
|
677
|
+
def visit_if_node(node)
|
|
678
|
+
name = guard_name(node)
|
|
679
|
+
if name
|
|
680
|
+
record(node, :guard, name)
|
|
681
|
+
else
|
|
682
|
+
super
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
197
686
|
{
|
|
198
687
|
visit_instance_variable_read_node: :read,
|
|
199
688
|
visit_instance_variable_write_node: :write,
|
|
@@ -202,22 +691,64 @@ module Audition
|
|
|
202
691
|
visit_instance_variable_and_write_node: :other,
|
|
203
692
|
visit_instance_variable_target_node: :other
|
|
204
693
|
}.each do |method, kind|
|
|
205
|
-
define_method(method) do |node|
|
|
206
|
-
record(node, kind)
|
|
694
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
695
|
+
record(node, kind, node.name)
|
|
207
696
|
super(node)
|
|
208
697
|
end
|
|
209
698
|
end
|
|
210
699
|
|
|
211
700
|
private
|
|
212
701
|
|
|
213
|
-
def
|
|
702
|
+
def guard_name(node)
|
|
703
|
+
predicate = node.predicate
|
|
704
|
+
return unless predicate.is_a?(Prism::DefinedNode)
|
|
705
|
+
|
|
706
|
+
checked = predicate.value
|
|
707
|
+
return unless checked.is_a?(Prism::InstanceVariableReadNode)
|
|
708
|
+
return if node.subsequent
|
|
709
|
+
|
|
710
|
+
body = node.statements&.body
|
|
711
|
+
return unless body && body.size == 1
|
|
712
|
+
return unless body[0].is_a?(Prism::ReturnNode)
|
|
713
|
+
|
|
714
|
+
returned = body[0].arguments&.arguments
|
|
715
|
+
return unless returned && returned.size == 1
|
|
716
|
+
return unless returned[0]
|
|
717
|
+
.is_a?(Prism::InstanceVariableReadNode)
|
|
718
|
+
return unless returned[0].name == checked.name
|
|
719
|
+
|
|
720
|
+
checked.name
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# class/module bodies open a fresh method scope: a def
|
|
724
|
+
# inside a module nested under `class << self` defines an
|
|
725
|
+
# instance method of that module, so the surrounding
|
|
726
|
+
# singleton context and def stack must not leak in.
|
|
727
|
+
def scoped(name, kind)
|
|
728
|
+
saved_depth = @sclass_depth
|
|
729
|
+
saved_stack = @def_stack
|
|
730
|
+
@namespace.push({name: name, kind: kind})
|
|
731
|
+
@sclass_depth = 0
|
|
732
|
+
@def_stack = []
|
|
733
|
+
yield
|
|
734
|
+
ensure
|
|
735
|
+
@sclass_depth = saved_depth
|
|
736
|
+
@def_stack = saved_stack
|
|
737
|
+
@namespace.pop
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def record(node, kind, name)
|
|
214
741
|
return if @namespace.empty?
|
|
215
742
|
|
|
216
|
-
|
|
743
|
+
kind = :other if @defined_depth.positive?
|
|
744
|
+
current_def = @def_stack.last
|
|
217
745
|
singleton_method =
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
746
|
+
current_def && (
|
|
747
|
+
current_def[:kind] == :self ||
|
|
748
|
+
(current_def[:kind] == :plain &&
|
|
749
|
+
@sclass_depth.positive?)
|
|
750
|
+
)
|
|
751
|
+
if current_def
|
|
221
752
|
return unless singleton_method
|
|
222
753
|
|
|
223
754
|
body = false
|
|
@@ -225,8 +756,15 @@ module Audition
|
|
|
225
756
|
body = true
|
|
226
757
|
end
|
|
227
758
|
|
|
228
|
-
|
|
229
|
-
|
|
759
|
+
names = @namespace.map { |entry| entry[:name] }
|
|
760
|
+
key = [names.join("::"), name.to_s]
|
|
761
|
+
@groups[key] << {
|
|
762
|
+
node: node, kind: kind, body: body,
|
|
763
|
+
def_id: current_def && current_def[:id],
|
|
764
|
+
def_name: current_def && current_def[:name],
|
|
765
|
+
def_node: current_def && current_def[:node],
|
|
766
|
+
class_owner: @namespace.last[:kind] == :class
|
|
767
|
+
}
|
|
230
768
|
end
|
|
231
769
|
end
|
|
232
770
|
end
|
|
@@ -239,14 +777,14 @@ module Audition
|
|
|
239
777
|
# Unsafe because cross-file readers are invisible.
|
|
240
778
|
module WriteOnce
|
|
241
779
|
def self.plan(file, findings)
|
|
242
|
-
|
|
780
|
+
bundles = []
|
|
243
781
|
if findings.any? { |f| f.check == "global-variables" }
|
|
244
|
-
|
|
782
|
+
bundles += globals(file)
|
|
245
783
|
end
|
|
246
784
|
if findings.any? { |f| f.check == "class-variables" }
|
|
247
|
-
|
|
785
|
+
bundles += class_variables(file)
|
|
248
786
|
end
|
|
249
|
-
|
|
787
|
+
bundles
|
|
250
788
|
end
|
|
251
789
|
|
|
252
790
|
def self.globals(file)
|
|
@@ -269,38 +807,70 @@ module Audition
|
|
|
269
807
|
classifier = Static::LiteralClassifier.new(
|
|
270
808
|
frozen_string_literal: file.frozen_string_literal?
|
|
271
809
|
)
|
|
272
|
-
|
|
810
|
+
# The same bare @@name under two namespaces of one file is
|
|
811
|
+
# usually same-file inheritance sharing one runtime
|
|
812
|
+
# variable; converting either copy strands the other's
|
|
813
|
+
# readers with a NameError.
|
|
814
|
+
bare = collector.groups.keys
|
|
815
|
+
.map { |key| key.split("/").last }
|
|
816
|
+
.tally
|
|
817
|
+
planned = []
|
|
818
|
+
bundles = []
|
|
273
819
|
collector.groups.each do |name, ops|
|
|
820
|
+
next if bare[name.split("/").last] > 1
|
|
821
|
+
|
|
274
822
|
writes = ops.select { |op| op[:kind] == :write }
|
|
275
823
|
next unless writes.size == 1 && writes[0][:assignable]
|
|
276
824
|
next unless (ops - writes).all? { |op| op[:kind] == :read }
|
|
277
825
|
|
|
278
826
|
write = writes[0][:node]
|
|
279
|
-
|
|
827
|
+
kind = classifier.classify(write.value)
|
|
828
|
+
next if kind == :sync_primitive
|
|
829
|
+
# A constructed object may gain singleton methods or be
|
|
830
|
+
# mutated later (sinatra's @@eats_errors); wrapping it
|
|
831
|
+
# in make_shareable freezes it and those break.
|
|
832
|
+
next if Memoization.constructor?(write.value, classifier)
|
|
833
|
+
|
|
834
|
+
shareable = kind == :shareable
|
|
280
835
|
# A mutable value that would get deep-frozen must never be
|
|
281
836
|
# the receiver of a call afterwards: `X[k] = v`, `X << v`
|
|
282
|
-
# and friends would raise FrozenError at runtime.
|
|
283
|
-
#
|
|
837
|
+
# and friends would raise FrozenError at runtime. The same
|
|
838
|
+
# goes for reads that escape into a local (`list = $x;
|
|
839
|
+
# list << h`) or a call argument, where an alias can be
|
|
840
|
+
# mutated out of sight. Reads of immutable values are
|
|
841
|
+
# safe anywhere.
|
|
284
842
|
unless shareable
|
|
285
|
-
|
|
286
|
-
|
|
843
|
+
escapes = (ops - writes).any? do |op|
|
|
844
|
+
id = op[:node].object_id
|
|
845
|
+
collector.receiver_reads.include?(id) ||
|
|
846
|
+
collector.escaping_reads.include?(id)
|
|
287
847
|
end
|
|
288
|
-
next if
|
|
848
|
+
next if escapes
|
|
289
849
|
end
|
|
290
850
|
|
|
291
851
|
constant = yield(name.split("/").last)
|
|
292
852
|
next unless constant.match?(/\A[A-Z][A-Z0-9_]*\z/)
|
|
293
853
|
next if collector.taken_constants.include?(constant)
|
|
294
854
|
|
|
295
|
-
|
|
855
|
+
# `$max` and `$MAX` both map to MAX; only the first may
|
|
856
|
+
# take the name, the second stays a variable.
|
|
857
|
+
target = [name.rpartition("/").first, constant]
|
|
858
|
+
next if planned.include?(target)
|
|
859
|
+
|
|
860
|
+
planned << target
|
|
861
|
+
edits = [Autofix.new(
|
|
296
862
|
start_offset: write.name_loc.start_offset,
|
|
297
863
|
end_offset: write.name_loc.end_offset,
|
|
298
864
|
replacement: constant,
|
|
299
865
|
safety: :unsafe
|
|
300
|
-
)
|
|
866
|
+
)]
|
|
301
867
|
unless shareable
|
|
302
868
|
value = write.value
|
|
303
869
|
source = value.location.slice
|
|
870
|
+
if value.is_a?(Prism::ArrayNode) &&
|
|
871
|
+
value.opening_loc.nil?
|
|
872
|
+
source = "[#{source}]"
|
|
873
|
+
end
|
|
304
874
|
edits << Autofix.new(
|
|
305
875
|
start_offset: value.location.start_offset,
|
|
306
876
|
end_offset: value.location.end_offset,
|
|
@@ -317,8 +887,9 @@ module Audition
|
|
|
317
887
|
safety: :unsafe
|
|
318
888
|
)
|
|
319
889
|
end
|
|
890
|
+
bundles << Rewriters.bundle(ops, edits)
|
|
320
891
|
end
|
|
321
|
-
|
|
892
|
+
bundles.compact
|
|
322
893
|
end
|
|
323
894
|
|
|
324
895
|
# Collects either global or class variable operations.
|
|
@@ -333,16 +904,19 @@ module Audition
|
|
|
333
904
|
Static::Checks::GlobalVariables::LOAD_PATH_GLOBALS
|
|
334
905
|
).freeze
|
|
335
906
|
|
|
336
|
-
attr_reader :groups, :taken_constants, :receiver_reads
|
|
907
|
+
attr_reader :groups, :taken_constants, :receiver_reads,
|
|
908
|
+
:escaping_reads
|
|
337
909
|
|
|
338
910
|
def initialize(mode)
|
|
339
911
|
@mode = mode
|
|
340
912
|
@groups = Hash.new { |h, k| h[k] = [] }
|
|
341
913
|
@taken_constants = []
|
|
342
914
|
@receiver_reads = {}
|
|
915
|
+
@escaping_reads = {}
|
|
343
916
|
@namespace = []
|
|
344
917
|
@def_depth = 0
|
|
345
918
|
@block_depth = 0
|
|
919
|
+
@conditional_depth = 0
|
|
346
920
|
super()
|
|
347
921
|
end
|
|
348
922
|
|
|
@@ -352,9 +926,67 @@ module Audition
|
|
|
352
926
|
receiver.is_a?(Prism::ClassVariableReadNode)
|
|
353
927
|
@receiver_reads[receiver.object_id] = true
|
|
354
928
|
end
|
|
929
|
+
arguments = node.arguments&.arguments || []
|
|
930
|
+
arguments.each { |argument| mark_escape(argument) }
|
|
355
931
|
super
|
|
356
932
|
end
|
|
357
933
|
|
|
934
|
+
# `list = $handlers; list << h` mutates the value through
|
|
935
|
+
# an alias the receiver check cannot see; reads that flow
|
|
936
|
+
# into a local or a call argument are recorded so mutable
|
|
937
|
+
# values never get deep-frozen under them.
|
|
938
|
+
%i[
|
|
939
|
+
visit_local_variable_write_node
|
|
940
|
+
visit_local_variable_or_write_node
|
|
941
|
+
visit_local_variable_and_write_node
|
|
942
|
+
visit_local_variable_operator_write_node
|
|
943
|
+
].each do |method|
|
|
944
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
945
|
+
mark_escape(node.value)
|
|
946
|
+
super(node)
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# A write that only happens on some paths (`$verbose =
|
|
951
|
+
# true if ENV[...]`) must stay a variable: readers get nil
|
|
952
|
+
# on the untaken path, where a constant would raise
|
|
953
|
+
# NameError.
|
|
954
|
+
%i[
|
|
955
|
+
visit_if_node
|
|
956
|
+
visit_unless_node
|
|
957
|
+
visit_case_node
|
|
958
|
+
visit_case_match_node
|
|
959
|
+
visit_while_node
|
|
960
|
+
visit_until_node
|
|
961
|
+
visit_and_node
|
|
962
|
+
visit_or_node
|
|
963
|
+
visit_rescue_modifier_node
|
|
964
|
+
].each do |method|
|
|
965
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
966
|
+
conditionally { super(node) }
|
|
967
|
+
end
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
def conditionally
|
|
971
|
+
@conditional_depth += 1
|
|
972
|
+
yield
|
|
973
|
+
ensure
|
|
974
|
+
@conditional_depth -= 1
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
def visit_begin_node(node)
|
|
978
|
+
if node.rescue_clause
|
|
979
|
+
@conditional_depth += 1
|
|
980
|
+
begin
|
|
981
|
+
super
|
|
982
|
+
ensure
|
|
983
|
+
@conditional_depth -= 1
|
|
984
|
+
end
|
|
985
|
+
else
|
|
986
|
+
super
|
|
987
|
+
end
|
|
988
|
+
end
|
|
989
|
+
|
|
358
990
|
def visit_class_node(node)
|
|
359
991
|
@namespace.push(node.constant_path.location.slice)
|
|
360
992
|
super
|
|
@@ -418,14 +1050,14 @@ module Audition
|
|
|
418
1050
|
}.freeze
|
|
419
1051
|
|
|
420
1052
|
GVAR_NODES.each do |method, kind|
|
|
421
|
-
define_method(method) do |node|
|
|
1053
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
422
1054
|
record_gvar(node, kind) if @mode == :gvar
|
|
423
1055
|
super(node)
|
|
424
1056
|
end
|
|
425
1057
|
end
|
|
426
1058
|
|
|
427
1059
|
CVAR_NODES.each do |method, kind|
|
|
428
|
-
define_method(method) do |node|
|
|
1060
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
429
1061
|
record_cvar(node, kind) if @mode == :cvar
|
|
430
1062
|
super(node)
|
|
431
1063
|
end
|
|
@@ -433,6 +1065,13 @@ module Audition
|
|
|
433
1065
|
|
|
434
1066
|
private
|
|
435
1067
|
|
|
1068
|
+
def mark_escape(node)
|
|
1069
|
+
return unless node.is_a?(Prism::GlobalVariableReadNode) ||
|
|
1070
|
+
node.is_a?(Prism::ClassVariableReadNode)
|
|
1071
|
+
|
|
1072
|
+
@escaping_reads[node.object_id] = true
|
|
1073
|
+
end
|
|
1074
|
+
|
|
436
1075
|
def record_gvar(node, kind)
|
|
437
1076
|
name = node.name.to_s
|
|
438
1077
|
return if SKIP_GLOBALS.include?(name)
|
|
@@ -440,7 +1079,7 @@ module Audition
|
|
|
440
1079
|
@groups[name] << {
|
|
441
1080
|
node: node, kind: kind,
|
|
442
1081
|
assignable: @def_depth.zero? && @block_depth.zero? &&
|
|
443
|
-
@namespace.empty?
|
|
1082
|
+
@namespace.empty? && @conditional_depth.zero?
|
|
444
1083
|
}
|
|
445
1084
|
end
|
|
446
1085
|
|
|
@@ -450,7 +1089,8 @@ module Audition
|
|
|
450
1089
|
key = "#{@namespace.join("::")}/#{node.name}"
|
|
451
1090
|
@groups[key] << {
|
|
452
1091
|
node: node, kind: kind,
|
|
453
|
-
assignable: @def_depth.zero? && @block_depth.zero?
|
|
1092
|
+
assignable: @def_depth.zero? && @block_depth.zero? &&
|
|
1093
|
+
@conditional_depth.zero?
|
|
454
1094
|
}
|
|
455
1095
|
end
|
|
456
1096
|
end
|