audition 0.1.0 → 0.2.0

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