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.
@@ -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. Each .plan returns Autofix edits
8
- # (safety :unsafe) or, for MagicComments, a file-level edit plus
9
- # the list of checks it makes redundant.
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 only
19
- # planned when every constant assignment in the file
20
- # classifies as a literal. Otherwise, if the flagged values
21
- # are all strings, frozen_string_literal covers them.
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
- kinds = constant_values(file).map { |v| classifier.classify(v) }
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 = kinds.all? do |k|
34
- %i[shareable mutable_string mutable_container
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 !file.frozen_string_literal? &&
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.constant_values(file)
148
+ def self.constant_collector(file)
58
149
  collector = ConstantValues.new
59
150
  collector.visit(file.root)
60
- collector.values
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 ivar state to Ractor-local storage:
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): each
94
- # Ractor computes its own copy, and store_if_absent treats a
95
- # stored nil as present where ||= would recompute.
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
- edits = []
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
- key = %(:"#{namespace}/#{name}")
112
- ops.each { |op| edits << edit_for(op, key) }
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
- edits
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.edit_for(op, key)
118
- node = op[:node]
119
- case op[:kind]
120
- when :or_write
121
- value = node.value.location.slice
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: node.location.start_offset,
124
- end_offset: node.location.end_offset,
362
+ start_offset: value.location.start_offset,
363
+ end_offset: value.location.end_offset,
125
364
  replacement:
126
- "Ractor.store_if_absent(#{key}) { #{value} }",
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
- @namespace.push(node.constant_path.location.slice)
163
- super
164
- ensure
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
- @namespace.push(node.constant_path.location.slice)
170
- super
171
- ensure
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(kind)
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 record(node, kind)
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
- in_def = !@def_stack.empty?
743
+ kind = :other if @defined_depth.positive?
744
+ current_def = @def_stack.last
217
745
  singleton_method =
218
- @def_stack.last == :self ||
219
- (@def_stack.last == :plain && @sclass_depth.positive?)
220
- if in_def
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
- key = [@namespace.join("::"), node.name.to_s]
229
- @groups[key] << {node: node, kind: kind, body: body}
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
- edits = []
780
+ bundles = []
243
781
  if findings.any? { |f| f.check == "global-variables" }
244
- edits += globals(file)
782
+ bundles += globals(file)
245
783
  end
246
784
  if findings.any? { |f| f.check == "class-variables" }
247
- edits += class_variables(file)
785
+ bundles += class_variables(file)
248
786
  end
249
- edits
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
- edits = []
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
- shareable = classifier.classify(write.value) == :shareable
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. Reads of
283
- # immutable values are safe anywhere.
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
- mutated = (ops - writes).any? do |op|
286
- collector.receiver_reads.include?(op[:node].object_id)
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 mutated
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
- edits << Autofix.new(
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
- edits
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