audition 0.2.0 → 0.2.2
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 +23 -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 +56 -12
- data/lib/audition/fixer.rb +10 -4
- data/lib/audition/report.rb +37 -8
- data/lib/audition/rewriters.rb +373 -49
- 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 +84 -13
- data/lib/audition/static/checks/ractor_isolation.rb +42 -12
- data/lib/audition/static/checks/unsafe_calls.rb +1 -1
- data/lib/audition/static/graph_audit.rb +153 -9
- data/lib/audition/static/literal_classifier.rb +7 -2
- data/lib/audition/static/source_file.rb +67 -4
- 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
|
|
@@ -30,23 +71,45 @@ module Audition
|
|
|
30
71
|
classifier = Static::LiteralClassifier.new(
|
|
31
72
|
frozen_string_literal: file.frozen_string_literal?
|
|
32
73
|
)
|
|
33
|
-
|
|
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
|
|
34
82
|
kinds = values.map { |v| classifier.classify(v) }
|
|
35
83
|
flagged = kinds.reject do |k|
|
|
36
84
|
%i[shareable unknown].include?(k)
|
|
37
85
|
end
|
|
38
|
-
scv_ok = values.
|
|
39
|
-
deep_literal?(value, classifier)
|
|
40
|
-
|
|
41
|
-
|
|
86
|
+
scv_ok = values.any? &&
|
|
87
|
+
values.all? { |value| deep_literal?(value, classifier) }
|
|
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.
|
|
42
94
|
if scv_ok
|
|
43
95
|
comment_plan(file, SCV_LINE)
|
|
44
|
-
elsif
|
|
96
|
+
elsif flagged.any? &&
|
|
97
|
+
file.magic_comment("frozen_string_literal").nil? &&
|
|
45
98
|
flagged.all? { |k| k == :mutable_string }
|
|
46
99
|
comment_plan(file, FSL_LINE)
|
|
47
100
|
end
|
|
48
101
|
end
|
|
49
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
|
+
|
|
50
113
|
# Bare literals only: `[...].freeze` is a method call, and
|
|
51
114
|
# under the magic comment Ruby raises for it at assignment
|
|
52
115
|
# because the shallowly-frozen value is unshareable
|
|
@@ -82,28 +145,39 @@ module Audition
|
|
|
82
145
|
}
|
|
83
146
|
end
|
|
84
147
|
|
|
85
|
-
def self.
|
|
148
|
+
def self.constant_collector(file)
|
|
86
149
|
collector = ConstantValues.new
|
|
87
150
|
collector.visit(file.root)
|
|
88
|
-
collector
|
|
151
|
+
collector
|
|
89
152
|
end
|
|
90
153
|
|
|
91
154
|
class ConstantValues < Prism::Visitor
|
|
92
|
-
attr_reader :values
|
|
155
|
+
attr_reader :values, :names
|
|
93
156
|
|
|
94
157
|
def initialize
|
|
95
158
|
@values = []
|
|
159
|
+
@names = []
|
|
96
160
|
super
|
|
97
161
|
end
|
|
98
162
|
|
|
99
163
|
%i[
|
|
100
164
|
visit_constant_write_node
|
|
101
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[
|
|
102
175
|
visit_constant_path_write_node
|
|
103
176
|
visit_constant_path_or_write_node
|
|
104
177
|
].each do |method|
|
|
105
178
|
define_method(method) do |node| # audition:disable unsafe-calls
|
|
106
179
|
@values << node.value
|
|
180
|
+
@names << node.target.location.slice
|
|
107
181
|
super(node)
|
|
108
182
|
end
|
|
109
183
|
end
|
|
@@ -143,7 +217,7 @@ module Audition
|
|
|
143
217
|
|
|
144
218
|
collector = SingletonIvars.new
|
|
145
219
|
collector.visit(file.root)
|
|
146
|
-
|
|
220
|
+
bundles = []
|
|
147
221
|
collector.groups.each do |(namespace, name), ops|
|
|
148
222
|
next if namespace.empty?
|
|
149
223
|
|
|
@@ -152,7 +226,10 @@ module Audition
|
|
|
152
226
|
next if ops.any? { |op| op[:body] }
|
|
153
227
|
|
|
154
228
|
memos = memo_sites(ops)
|
|
155
|
-
|
|
229
|
+
if memos.empty?
|
|
230
|
+
bundles << Rewriters.bundle(ops, setter_edits(file, ops))
|
|
231
|
+
next
|
|
232
|
+
end
|
|
156
233
|
next if orphan_guards?(ops, memos)
|
|
157
234
|
next if guarded_with_strays?(ops, memos)
|
|
158
235
|
# `@x ||= {}` is a lazily-built accumulator, mutated
|
|
@@ -160,17 +237,69 @@ module Audition
|
|
|
160
237
|
# algorithm registry). Freezing it breaks registration
|
|
161
238
|
# and Ractor-local copies would leave other Ractors an
|
|
162
239
|
# empty registry; the copy-on-write refactor is human
|
|
163
|
-
# work, so no edit is offered.
|
|
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.
|
|
164
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
|
|
165
249
|
|
|
166
250
|
if freezable?(ops, memos)
|
|
167
|
-
|
|
251
|
+
bundles << Rewriters.bundle(ops, freeze_edits(file, memos))
|
|
168
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
|
+
|
|
169
268
|
key = %(:"#{namespace}/#{name}")
|
|
170
|
-
|
|
269
|
+
bundles << Rewriters.bundle(
|
|
270
|
+
ops, ractor_edits(file, ops, memos, key)
|
|
271
|
+
)
|
|
171
272
|
end
|
|
172
273
|
end
|
|
173
|
-
|
|
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
|
|
291
|
+
end
|
|
292
|
+
|
|
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
|
|
174
303
|
end
|
|
175
304
|
|
|
176
305
|
# A memo site is an ||= write, or a plain write paired with a
|
|
@@ -195,6 +324,71 @@ module Audition
|
|
|
195
324
|
value.is_a?(Prism::HashNode)) && value.elements.empty?
|
|
196
325
|
end
|
|
197
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
|
|
361
|
+
Autofix.new(
|
|
362
|
+
start_offset: value.location.start_offset,
|
|
363
|
+
end_offset: value.location.end_offset,
|
|
364
|
+
replacement:
|
|
365
|
+
"(Ractor.make_shareable(#{local}) rescue #{local})",
|
|
366
|
+
safety: :unsafe
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
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
|
+
|
|
198
392
|
def self.guarded_with_strays?(ops, memos)
|
|
199
393
|
return false if memos.none? { |memo| memo[:guard] }
|
|
200
394
|
|
|
@@ -243,12 +437,10 @@ module Audition
|
|
|
243
437
|
# while leaving the memoization intact. Guards, sibling
|
|
244
438
|
# reads, and method shapes stay untouched.
|
|
245
439
|
def self.freeze_edits(file, memos)
|
|
246
|
-
|
|
247
|
-
frozen_string_literal: file.frozen_string_literal?
|
|
248
|
-
)
|
|
440
|
+
kinds = classifier(file)
|
|
249
441
|
memos.filter_map do |memo|
|
|
250
442
|
value = memo[:op][:node].value
|
|
251
|
-
freeze_value(value,
|
|
443
|
+
freeze_value(value, kinds.classify(value))
|
|
252
444
|
end
|
|
253
445
|
end
|
|
254
446
|
|
|
@@ -424,21 +616,20 @@ module Audition
|
|
|
424
616
|
@namespace = []
|
|
425
617
|
@sclass_depth = 0
|
|
426
618
|
@def_stack = []
|
|
619
|
+
@defined_depth = 0
|
|
427
620
|
super
|
|
428
621
|
end
|
|
429
622
|
|
|
430
623
|
def visit_class_node(node)
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
@namespace.pop
|
|
624
|
+
scoped(node.constant_path.location.slice, :class) do
|
|
625
|
+
super(node)
|
|
626
|
+
end
|
|
435
627
|
end
|
|
436
628
|
|
|
437
629
|
def visit_module_node(node)
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
@namespace.pop
|
|
630
|
+
scoped(node.constant_path.location.slice, :module) do
|
|
631
|
+
super(node)
|
|
632
|
+
end
|
|
442
633
|
end
|
|
443
634
|
|
|
444
635
|
def visit_singleton_class_node(node)
|
|
@@ -458,13 +649,26 @@ module Audition
|
|
|
458
649
|
kind =
|
|
459
650
|
node.receiver.is_a?(Prism::SelfNode) ? :self : :plain
|
|
460
651
|
@def_stack.push(
|
|
461
|
-
{kind: kind, id: node.object_id,
|
|
652
|
+
{kind: kind, id: node.object_id,
|
|
653
|
+
name: node.name, node: node}
|
|
462
654
|
)
|
|
463
655
|
super
|
|
464
656
|
ensure
|
|
465
657
|
@def_stack.pop
|
|
466
658
|
end
|
|
467
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
|
+
|
|
468
672
|
# Matches the guard statement of the second memoization
|
|
469
673
|
# idiom: `return @x if defined?(@x)`. On a match the inner
|
|
470
674
|
# reads are not visited (they belong to the guard, not to
|
|
@@ -516,9 +720,27 @@ module Audition
|
|
|
516
720
|
checked.name
|
|
517
721
|
end
|
|
518
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
|
+
|
|
519
740
|
def record(node, kind, name)
|
|
520
741
|
return if @namespace.empty?
|
|
521
742
|
|
|
743
|
+
kind = :other if @defined_depth.positive?
|
|
522
744
|
current_def = @def_stack.last
|
|
523
745
|
singleton_method =
|
|
524
746
|
current_def && (
|
|
@@ -534,11 +756,14 @@ module Audition
|
|
|
534
756
|
body = true
|
|
535
757
|
end
|
|
536
758
|
|
|
537
|
-
|
|
759
|
+
names = @namespace.map { |entry| entry[:name] }
|
|
760
|
+
key = [names.join("::"), name.to_s]
|
|
538
761
|
@groups[key] << {
|
|
539
762
|
node: node, kind: kind, body: body,
|
|
540
763
|
def_id: current_def && current_def[:id],
|
|
541
|
-
def_name: current_def && current_def[:name]
|
|
764
|
+
def_name: current_def && current_def[:name],
|
|
765
|
+
def_node: current_def && current_def[:node],
|
|
766
|
+
class_owner: @namespace.last[:kind] == :class
|
|
542
767
|
}
|
|
543
768
|
end
|
|
544
769
|
end
|
|
@@ -552,14 +777,14 @@ module Audition
|
|
|
552
777
|
# Unsafe because cross-file readers are invisible.
|
|
553
778
|
module WriteOnce
|
|
554
779
|
def self.plan(file, findings)
|
|
555
|
-
|
|
780
|
+
bundles = []
|
|
556
781
|
if findings.any? { |f| f.check == "global-variables" }
|
|
557
|
-
|
|
782
|
+
bundles += globals(file)
|
|
558
783
|
end
|
|
559
784
|
if findings.any? { |f| f.check == "class-variables" }
|
|
560
|
-
|
|
785
|
+
bundles += class_variables(file)
|
|
561
786
|
end
|
|
562
|
-
|
|
787
|
+
bundles
|
|
563
788
|
end
|
|
564
789
|
|
|
565
790
|
def self.globals(file)
|
|
@@ -582,8 +807,18 @@ module Audition
|
|
|
582
807
|
classifier = Static::LiteralClassifier.new(
|
|
583
808
|
frozen_string_literal: file.frozen_string_literal?
|
|
584
809
|
)
|
|
585
|
-
|
|
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 = []
|
|
586
819
|
collector.groups.each do |name, ops|
|
|
820
|
+
next if bare[name.split("/").last] > 1
|
|
821
|
+
|
|
587
822
|
writes = ops.select { |op| op[:kind] == :write }
|
|
588
823
|
next unless writes.size == 1 && writes[0][:assignable]
|
|
589
824
|
next unless (ops - writes).all? { |op| op[:kind] == :read }
|
|
@@ -591,32 +826,51 @@ module Audition
|
|
|
591
826
|
write = writes[0][:node]
|
|
592
827
|
kind = classifier.classify(write.value)
|
|
593
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)
|
|
594
833
|
|
|
595
834
|
shareable = kind == :shareable
|
|
596
835
|
# A mutable value that would get deep-frozen must never be
|
|
597
836
|
# the receiver of a call afterwards: `X[k] = v`, `X << v`
|
|
598
|
-
# and friends would raise FrozenError at runtime.
|
|
599
|
-
#
|
|
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.
|
|
600
842
|
unless shareable
|
|
601
|
-
|
|
602
|
-
|
|
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)
|
|
603
847
|
end
|
|
604
|
-
next if
|
|
848
|
+
next if escapes
|
|
605
849
|
end
|
|
606
850
|
|
|
607
851
|
constant = yield(name.split("/").last)
|
|
608
852
|
next unless constant.match?(/\A[A-Z][A-Z0-9_]*\z/)
|
|
609
853
|
next if collector.taken_constants.include?(constant)
|
|
610
854
|
|
|
611
|
-
|
|
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(
|
|
612
862
|
start_offset: write.name_loc.start_offset,
|
|
613
863
|
end_offset: write.name_loc.end_offset,
|
|
614
864
|
replacement: constant,
|
|
615
865
|
safety: :unsafe
|
|
616
|
-
)
|
|
866
|
+
)]
|
|
617
867
|
unless shareable
|
|
618
868
|
value = write.value
|
|
619
869
|
source = value.location.slice
|
|
870
|
+
if value.is_a?(Prism::ArrayNode) &&
|
|
871
|
+
value.opening_loc.nil?
|
|
872
|
+
source = "[#{source}]"
|
|
873
|
+
end
|
|
620
874
|
edits << Autofix.new(
|
|
621
875
|
start_offset: value.location.start_offset,
|
|
622
876
|
end_offset: value.location.end_offset,
|
|
@@ -633,8 +887,9 @@ module Audition
|
|
|
633
887
|
safety: :unsafe
|
|
634
888
|
)
|
|
635
889
|
end
|
|
890
|
+
bundles << Rewriters.bundle(ops, edits)
|
|
636
891
|
end
|
|
637
|
-
|
|
892
|
+
bundles.compact
|
|
638
893
|
end
|
|
639
894
|
|
|
640
895
|
# Collects either global or class variable operations.
|
|
@@ -649,16 +904,19 @@ module Audition
|
|
|
649
904
|
Static::Checks::GlobalVariables::LOAD_PATH_GLOBALS
|
|
650
905
|
).freeze
|
|
651
906
|
|
|
652
|
-
attr_reader :groups, :taken_constants, :receiver_reads
|
|
907
|
+
attr_reader :groups, :taken_constants, :receiver_reads,
|
|
908
|
+
:escaping_reads
|
|
653
909
|
|
|
654
910
|
def initialize(mode)
|
|
655
911
|
@mode = mode
|
|
656
912
|
@groups = Hash.new { |h, k| h[k] = [] }
|
|
657
913
|
@taken_constants = []
|
|
658
914
|
@receiver_reads = {}
|
|
915
|
+
@escaping_reads = {}
|
|
659
916
|
@namespace = []
|
|
660
917
|
@def_depth = 0
|
|
661
918
|
@block_depth = 0
|
|
919
|
+
@conditional_depth = 0
|
|
662
920
|
super()
|
|
663
921
|
end
|
|
664
922
|
|
|
@@ -668,9 +926,67 @@ module Audition
|
|
|
668
926
|
receiver.is_a?(Prism::ClassVariableReadNode)
|
|
669
927
|
@receiver_reads[receiver.object_id] = true
|
|
670
928
|
end
|
|
929
|
+
arguments = node.arguments&.arguments || []
|
|
930
|
+
arguments.each { |argument| mark_escape(argument) }
|
|
671
931
|
super
|
|
672
932
|
end
|
|
673
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
|
+
|
|
674
990
|
def visit_class_node(node)
|
|
675
991
|
@namespace.push(node.constant_path.location.slice)
|
|
676
992
|
super
|
|
@@ -749,6 +1065,13 @@ module Audition
|
|
|
749
1065
|
|
|
750
1066
|
private
|
|
751
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
|
+
|
|
752
1075
|
def record_gvar(node, kind)
|
|
753
1076
|
name = node.name.to_s
|
|
754
1077
|
return if SKIP_GLOBALS.include?(name)
|
|
@@ -756,7 +1079,7 @@ module Audition
|
|
|
756
1079
|
@groups[name] << {
|
|
757
1080
|
node: node, kind: kind,
|
|
758
1081
|
assignable: @def_depth.zero? && @block_depth.zero? &&
|
|
759
|
-
@namespace.empty?
|
|
1082
|
+
@namespace.empty? && @conditional_depth.zero?
|
|
760
1083
|
}
|
|
761
1084
|
end
|
|
762
1085
|
|
|
@@ -766,7 +1089,8 @@ module Audition
|
|
|
766
1089
|
key = "#{@namespace.join("::")}/#{node.name}"
|
|
767
1090
|
@groups[key] << {
|
|
768
1091
|
node: node, kind: kind,
|
|
769
|
-
assignable: @def_depth.zero? && @block_depth.zero?
|
|
1092
|
+
assignable: @def_depth.zero? && @block_depth.zero? &&
|
|
1093
|
+
@conditional_depth.zero?
|
|
770
1094
|
}
|
|
771
1095
|
end
|
|
772
1096
|
end
|
|
@@ -46,7 +46,13 @@ module Audition
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
parallel_analyze(paths, workers)
|
|
49
|
-
rescue Ractor::Error
|
|
49
|
+
rescue Ractor::Error => e
|
|
50
|
+
# The silent fallback would otherwise mask a check that is
|
|
51
|
+
# itself Ractor-hostile; surface it under -w.
|
|
52
|
+
if $VERBOSE
|
|
53
|
+
warn "audition: parallel scan fell back to serial: " \
|
|
54
|
+
"#{e.class}: #{e.message}"
|
|
55
|
+
end
|
|
50
56
|
paths.flat_map { |path| analyze_path(path) }
|
|
51
57
|
end
|
|
52
58
|
|