audition 0.2.4 → 0.4.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.
@@ -81,7 +81,10 @@ module Audition
81
81
  values = collector.values
82
82
  kinds = values.map { |v| classifier.classify(v) }
83
83
  flagged = kinds.reject do |k|
84
- %i[shareable unknown].include?(k)
84
+ # A magic comment cannot fix opaque values, so they
85
+ # must not veto one either.
86
+ %i[shareable unknown instance_new opaque_call
87
+ shallow_opaque].include?(k)
85
88
  end
86
89
  scv_ok = values.any? &&
87
90
  values.all? { |value| deep_literal?(value, classifier) }
@@ -190,9 +193,9 @@ module Audition
190
193
  # @x ||= expr
191
194
  # return @x if defined?(@x); @x = expr
192
195
  #
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
+ # Preferred strategy is freeze-on-memoize: the memoization
197
+ # stays exactly as written and only the memoized value
198
+ # becomes shareable
196
199
  # (`.freeze` appended; Ractor.make_shareable for containers).
197
200
  # Non-main Ractors may then read the ivar once it has been
198
201
  # computed; the first write must still happen on the main
@@ -325,7 +328,7 @@ module Audition
325
328
  end
326
329
 
327
330
  # Config setters (`def self.backend=(value); @backend =
328
- # value; end`) get the Rails try_make_shareable recipe in
331
+ # value; end`) get a try-make-shareable recipe in
329
332
  # plain Ruby: shareable values are deeply frozen so reads
330
333
  # from any Ractor become legal, unshareable values keep
331
334
  # today's behavior through the rescue. Only bare local reads
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "etc"
3
+ require_relative "work_split"
4
4
 
5
5
  module Audition
6
6
  module Static
@@ -27,6 +27,10 @@ module Audition
27
27
 
28
28
  PARALLEL_THRESHOLD = 16
29
29
 
30
+ # Workers report in batches: a port message per file would
31
+ # cost more than the redraw it feeds.
32
+ TICK_STRIDE = 25
33
+
30
34
  # Scans files across Ractors when there are enough of them to
31
35
  # be worth the spawn cost. Checks are plain shareable classes
32
36
  # with deeply frozen catalogs, findings copy back through
@@ -34,47 +38,112 @@ module Audition
34
38
  # serial path.
35
39
  #
36
40
  # @param paths [Array<String>] files to analyze
37
- # @param workers [Integer] Ractor count (defaults to
38
- # processor count minus one)
41
+ # @param workers [Integer, nil] Ractor count (defaults to
42
+ # {#default_workers})
39
43
  # @param threshold [Integer] minimum file count before
40
44
  # Ractors are used at all
45
+ # @param progress [Progress] ticked per file
41
46
  # @return [Array<Finding>]
42
- def analyze_paths(paths, workers: default_workers,
43
- threshold: PARALLEL_THRESHOLD)
47
+ def analyze_paths(paths, workers: nil,
48
+ threshold: PARALLEL_THRESHOLD, progress: Progress::SILENT)
49
+ workers ||= default_workers
44
50
  if paths.size < threshold || workers <= 1
45
- return paths.flat_map { |path| analyze_path(path) }
51
+ return serial_analyze(paths, progress)
46
52
  end
47
53
 
48
- parallel_analyze(paths, workers)
54
+ parallel_analyze(paths, workers, progress)
49
55
  rescue Ractor::Error => e
50
56
  # The silent fallback would otherwise mask a check that is
51
57
  # itself Ractor-hostile; surface it under -w.
52
58
  if $VERBOSE
53
- warn "audition: parallel scan fell back to serial: " \
59
+ warn "Audition: parallel scan fell back to serial: " \
54
60
  "#{e.class}: #{e.message}"
55
61
  end
56
- paths.flat_map { |path| analyze_path(path) }
62
+ progress.ractors = nil
63
+ serial_analyze(paths, progress)
57
64
  end
58
65
 
59
66
  private
60
67
 
61
- def parallel_analyze(paths, workers)
68
+ def serial_analyze(paths, progress)
69
+ paths.flat_map do |path|
70
+ findings = analyze_path(path)
71
+ progress.tick
72
+ findings
73
+ end
74
+ end
75
+
76
+ # A port carries counts out of the workers while they run:
77
+ # the alternative is a status line frozen for the whole
78
+ # parallel phase, which is most of a large scan.
79
+ def parallel_analyze(paths, workers, progress)
62
80
  experimental = Warning[:experimental]
63
81
  Warning[:experimental] = false
64
- slice = (paths.size / workers.to_f).ceil
65
82
  checks = @checks
66
- paths.each_slice(slice).map do |chunk|
67
- Ractor.new(chunk, checks) do |files, active_checks|
68
- analyzer = Analyzer.new(checks: active_checks)
69
- files.flat_map { |file| analyzer.analyze_path(file) }
83
+ port = progress.enabled? ? Ractor::Port.new : nil
84
+ chunks = balanced_chunks(paths, workers)
85
+ progress.ractors = chunks.size
86
+ ractors = chunks.map do |chunk|
87
+ # The sentinel goes out through `ensure` so a worker that
88
+ # raises still releases the drain loop; the exception
89
+ # itself still surfaces from `Ractor#value`.
90
+ Ractor.new(chunk, checks, port) do |files, active, tap|
91
+ analyzer = Analyzer.new(checks: active)
92
+ pending = 0
93
+ begin
94
+ files.flat_map do |file|
95
+ findings = analyzer.analyze_path(file)
96
+ pending += 1
97
+ if tap && pending >= TICK_STRIDE
98
+ tap.send(pending)
99
+ pending = 0
100
+ end
101
+ findings
102
+ end
103
+ ensure
104
+ tap&.send(pending)
105
+ tap&.send(:done)
106
+ end
70
107
  end
71
- end.flat_map(&:value)
108
+ end
109
+ drain(port, ractors.size, progress) if port
110
+ ractors.flat_map(&:value)
72
111
  ensure
73
112
  Warning[:experimental] = experimental
74
113
  end
75
114
 
115
+ def drain(port, workers, progress)
116
+ done = 0
117
+ while done < workers
118
+ message = port.receive
119
+ if message == :done
120
+ done += 1
121
+ else
122
+ progress.tick(message)
123
+ end
124
+ end
125
+ rescue
126
+ # Narration is cosmetic; a closed port ends it quietly and
127
+ # `Ractor#value` still reports what went wrong.
128
+ nil
129
+ end
130
+
76
131
  def default_workers
77
- [Etc.nprocessors - 1, 1].max
132
+ WorkSplit.workers
133
+ end
134
+
135
+ # Byte size stands in for parse cost, and the stat it costs
136
+ # is nothing beside the parse it schedules.
137
+ def balanced_chunks(paths, workers)
138
+ WorkSplit.chunks(
139
+ paths.map { |path| [path, file_size(path)] }, workers
140
+ )
141
+ end
142
+
143
+ def file_size(path)
144
+ File.size(path)
145
+ rescue SystemCallError
146
+ 0
78
147
  end
79
148
 
80
149
  def analyze_file(file)
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ module Static
5
+ module Checks
6
+ # Class-level state owned by a dependency. A gem's own source
7
+ # is outside the scanned tree, so the graph audit never sees
8
+ # the ivar and the call site is the only place left to flag.
9
+ # Generated type stubs in the target's tree record it: an
10
+ # attribute reader on a singleton class is a class-level ivar
11
+ # read by definition, and the generator marks those methods
12
+ # apart from ones that compute their answer.
13
+ class DependencyClassState < Base
14
+ explain :read,
15
+ severity: :warning,
16
+ message: "%{owner}.%{name} reads class-level state " \
17
+ "owned by a dependency",
18
+ why: "The stub for %{owner} declares %{name} as an " \
19
+ "attribute on its singleton class, so the call " \
20
+ "returns a class-level instance variable. A " \
21
+ "non-main Ractor raises Ractor::IsolationError " \
22
+ "(\"can not get unshareable values from instance " \
23
+ "variables of classes/modules\") unless whatever " \
24
+ "the value happens to be is shareable.",
25
+ fix: "Read it once on the main Ractor and pass the " \
26
+ "value in, or make the dependency's assignment " \
27
+ "deeply shareable before any Ractor starts."
28
+
29
+ explain :write,
30
+ severity: :error,
31
+ message: "%{owner}.%{name}= writes class-level state " \
32
+ "owned by a dependency",
33
+ why: "Assigning an attribute on %{owner}'s singleton " \
34
+ "class writes a class-level instance variable, " \
35
+ "which a non-main Ractor cannot do at all: it " \
36
+ "raises Ractor::IsolationError regardless of what " \
37
+ "the value is.",
38
+ fix: "Configure the dependency at boot on the main " \
39
+ "Ractor and leave it alone afterwards."
40
+
41
+ # The generator documents a reader it generated from an
42
+ # attribute; a hand-written method of the same shape gets
43
+ # its own prose and stays quiet.
44
+ STUB_READER = /Returns the value of attribute (\w+)/
45
+ STUB_SINGLETON = /\A\s*class\s+<<\s+self\b/
46
+ STUB_SCOPE = /\A\s*(?:class|module)\s+([A-Za-z0-9_:]+)/
47
+ STUB_DEF = /\A\s*def ([a-z_][A-Za-z0-9_]*)[(;]/
48
+ STUB_COMMENT = /\A\s*#/
49
+
50
+ class << self
51
+ attr_reader :attributes
52
+
53
+ # Owner name => set of attribute names, kept shareable
54
+ # for parallel scanning.
55
+ def attributes=(pairs)
56
+ grouped = pairs.group_by(&:first)
57
+ .transform_values { |v| v.map(&:last).uniq }
58
+ @attributes = Ractor.make_shareable( # audition:disable
59
+ grouped
60
+ )
61
+ end
62
+
63
+ # @param paths [Array<String>] scanned files; only
64
+ # generated type stubs carry this evidence
65
+ # @return [void]
66
+ def learn(paths)
67
+ pairs = []
68
+ paths.each do |path|
69
+ next unless path.end_with?(".rbi")
70
+
71
+ read_stub(path, pairs)
72
+ end
73
+ self.attributes = pairs
74
+ end
75
+
76
+ private
77
+
78
+ # Stub indentation tracks nesting exactly, so the scope
79
+ # stack follows it rather than parsing the file.
80
+ def read_stub(path, pairs)
81
+ scope = []
82
+ singleton = []
83
+ indents = []
84
+ documented = nil
85
+ File.foreach(path) do |raw|
86
+ line = raw.rstrip
87
+ next if line.empty?
88
+
89
+ indent = line[/\A */].size
90
+ while indents.any? && indent <= indents.last
91
+ indents.pop
92
+ scope.pop
93
+ singleton.pop
94
+ end
95
+ documented =
96
+ step_stub(line, scope, singleton, indents,
97
+ documented, pairs)
98
+ end
99
+ rescue SystemCallError
100
+ nil
101
+ end
102
+
103
+ def step_stub(line, scope, singleton, indents,
104
+ documented, pairs)
105
+ case line
106
+ when STUB_COMMENT
107
+ return STUB_READER.match(line)&.[](1) || documented
108
+ when STUB_SINGLETON
109
+ scope.push(scope.last)
110
+ singleton.push(true)
111
+ indents.push(line[/\A */].size)
112
+ when STUB_SCOPE
113
+ name = Regexp.last_match(1)
114
+ scope.push(scope.empty? ? name : "#{scope.last}::#{name}")
115
+ singleton.push(false)
116
+ indents.push(line[/\A */].size)
117
+ when STUB_DEF
118
+ name = Regexp.last_match(1)
119
+ if singleton.last && scope.last && documented == name
120
+ pairs << [scope.last, name]
121
+ end
122
+ end
123
+ nil
124
+ end
125
+ end
126
+
127
+ self.attributes = []
128
+
129
+ on :call_node do |node|
130
+ examine(node)
131
+ end
132
+
133
+ private
134
+
135
+ def examine(node)
136
+ owner = constant_name(node.receiver)
137
+ return unless owner
138
+
139
+ name = node.name.to_s
140
+ writer = name.end_with?("=")
141
+ attribute = writer ? name.chomp("=") : name
142
+ names = self.class.attributes[owner]
143
+ return unless names&.include?(attribute)
144
+
145
+ flag(node, writer ? :write : :read,
146
+ owner: owner, name: attribute)
147
+ end
148
+
149
+ def constant_name(node)
150
+ case node
151
+ when Prism::ConstantReadNode
152
+ node.name.to_s
153
+ when Prism::ConstantPathNode
154
+ node.location.slice.delete_prefix("::")
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -30,21 +30,23 @@ module Audition
30
30
  "literals only; a method call returns a fresh " \
31
31
  "unfrozen object, so a non-main Ractor reading " \
32
32
  "this constant raises Ractor::IsolationError. " \
33
- "Rails hit this with `.tr` and `Regexp.new` " \
34
- "during its ractorization.",
35
- fix: "Append `.freeze` to the call; a frozen String " \
36
- "or Regexp is deeply shareable."
33
+ "Typical shapes: `.tr`, `Regexp.new`, and " \
34
+ "`Object.new` sentinels.",
35
+ fix: "Append `.freeze` to the call; a frozen String, " \
36
+ "Regexp, or bare Object is deeply shareable. " \
37
+ "BasicObject has no #freeze: use " \
38
+ "Object.new.freeze for such a sentinel."
37
39
 
38
40
  explain :mutable_container,
39
41
  severity: :error,
40
42
  message: "constant %{name} holds a mutable %{type} " \
41
43
  "literal",
42
44
  why: CONSTANT_WHY,
43
- fix: "Make it deeply shareable: " \
44
- "`# shareable_constant_value: literal`, or " \
45
- "wrap in Ractor.make_shareable(...). A bare " \
46
- "`.freeze` is not enough when elements are " \
47
- "themselves mutable."
45
+ fix: "Append `.freeze` when every element is itself " \
46
+ "shareable; otherwise make it deeply shareable with " \
47
+ "`# shareable_constant_value: literal` or " \
48
+ "Ractor.make_shareable(...), since a bare " \
49
+ "`.freeze` is shallow."
48
50
 
49
51
  explain :shallow_freeze,
50
52
  severity: :error,
@@ -62,17 +64,19 @@ module Audition
62
64
  explain :sync_primitive,
63
65
  severity: :error,
64
66
  message: "constant %{name} holds a %{klass}; sync " \
65
- "primitives are deliberately unshareable",
67
+ "primitives and concurrent collections are " \
68
+ "deliberately unshareable",
66
69
  why: "Mutex/Queue/ConditionVariable coordinate " \
67
70
  "threads inside one Ractor and can never be " \
68
71
  "shared across Ractors; any non-main Ractor " \
69
72
  "touching this constant raises " \
70
- "Ractor::IsolationError.",
73
+ "Ractor::IsolationError. A concurrent-ruby Map " \
74
+ "defines no #freeze at all, so make_shareable " \
75
+ "raises NoMethodError on it.",
71
76
  fix: "Use Ractor::Port for cross-Ractor " \
72
77
  "coordination; keep a per-Ractor primitive " \
73
78
  "via Ractor.store_if_absent when the state it " \
74
- "guards is per-Ractor too (Rails moved its " \
75
- "template digest mutex this way); or use " \
79
+ "guards is per-Ractor too; or use " \
76
80
  "Ractor-safe structures (ractor_safe, ratomic " \
77
81
  "gems)."
78
82
 
@@ -94,12 +98,49 @@ module Audition
94
98
  "Hash, and freezing the Hash does not make " \
95
99
  "the block shareable; a non-main Ractor " \
96
100
  "reading this constant raises " \
97
- "Ractor::IsolationError. Rails removed this " \
98
- "pattern twice during its ractorization.",
101
+ "Ractor::IsolationError.",
99
102
  fix: "Use a plain frozen Hash with explicit keys, " \
100
103
  "or drop the default proc and fetch with a " \
101
104
  "literal default: hash.fetch(key, [])."
102
105
 
106
+ explain :unshareable_instance,
107
+ severity: :warning,
108
+ message: "constant %{name} holds an unfrozen %{klass} " \
109
+ "instance",
110
+ why: "A fresh instance is unfrozen, so non-main " \
111
+ "Ractors raise Ractor::IsolationError reading " \
112
+ "it. A warning, not an error: .new can be " \
113
+ "overridden to return a shareable value.",
114
+ fix: "Freeze it when deeply immutable, wrap in " \
115
+ "Ractor.make_shareable, or keep a per-Ractor " \
116
+ "copy via Ractor.store_if_absent."
117
+
118
+ explain :shallow_opaque,
119
+ severity: :warning,
120
+ message: "constant %{name} is frozen at the top level " \
121
+ "only; what it holds is unproven",
122
+ why: "Freezing is shallow: elements and instance " \
123
+ "variables stay as their calls returned them, and " \
124
+ "unless those are deeply frozen a non-main Ractor " \
125
+ "reading the constant raises " \
126
+ "Ractor::IsolationError. The dynamic probe settles " \
127
+ "it when the target boots.",
128
+ fix: "Build the value with Ractor.make_shareable for a " \
129
+ "deep freeze, or silence a known-shareable value " \
130
+ "with a disable comment."
131
+
132
+ explain :opaque_constant,
133
+ severity: :warning,
134
+ message: "constant %{name} holds the result of " \
135
+ "%{method}; shareability unproven",
136
+ why: "Unless the call returns a deeply frozen value, " \
137
+ "non-main Ractors raise Ractor::IsolationError " \
138
+ "reading the constant. The dynamic probe settles " \
139
+ "it when the target boots.",
140
+ fix: "Freeze the result at definition time, or " \
141
+ "silence a known-shareable value with a disable " \
142
+ "comment."
143
+
103
144
  explain :constant_mutation,
104
145
  severity: :warning,
105
146
  message: "in-place %{method} on constant %{name}",
@@ -111,8 +152,8 @@ module Audition
111
152
  fix: "Build the complete value at load time and " \
112
153
  "freeze it (each_with_object then .freeze), " \
113
154
  "or move the registry behind a writer that " \
114
- "rebuilds and refreezes on each change, the " \
115
- "copy-on-write style Rails registries use. A " \
155
+ "rebuilds and refreezes on each change " \
156
+ "(copy-on-write). A " \
116
157
  "registry that plugins extend during boot is " \
117
158
  "frozen in the last boot hook (after_initialize) " \
118
159
  "rather than at definition, and writes after the " \
@@ -144,21 +185,34 @@ module Audition
144
185
 
145
186
  # A constant this file itself mutates in place is a
146
187
  # deliberate accumulator; freezing it would raise at
147
- # the mutation site (sinatra's PARAMS_CONFIG). The
188
+ # the mutation site (sinatra's PARAMS_CONFIG). One it
189
+ # gives singleton methods raises the same way. The
148
190
  # finding stays, the autofix goes.
149
- fix_ok = !mutated?(name)
150
- case classifier.classify(value)
191
+ fix_ok = !mutated?(name) && !customized?(name)
192
+ kind = classifier.classify(value)
193
+ # A Sorbet cast returns its argument and a begin
194
+ # block its last statement: fixes, type names and
195
+ # depth checks target the value inside.
196
+ value = classifier.unwrap(value)
197
+ # Build-then-freeze: a bare `NAME.freeze` later in the
198
+ # same body makes the literal as good as frozen, so
199
+ # only provably mutable elements remain to report.
200
+ if kind == :mutable_container && frozen_later?(name)
201
+ kind = classifier.frozen_kind(value)
202
+ fix_ok = false
203
+ end
204
+ case kind
151
205
  when :mutable_string
152
206
  flag(node, :mutable_string, name: name,
153
207
  autofix: fix_ok ? append_freeze(value) : nil)
154
208
  when :mutable_call
155
209
  flag(node, :mutable_call, name: name,
156
210
  type: call_type(value), method: call_display(value),
157
- autofix: fix_ok ? append_freeze(value) : nil)
211
+ autofix: fix_ok ? freeze_call(value) : nil)
158
212
  when :mutable_container
159
213
  flag(node, :mutable_container, name: name,
160
214
  type: container_type(value),
161
- autofix: fix_ok ? wrap_make_shareable(value) : nil)
215
+ autofix: fix_ok ? freeze_container(value) : nil)
162
216
  when :shallow_freeze
163
217
  flag(node, :shallow_freeze, name: name,
164
218
  autofix:
@@ -183,13 +237,35 @@ module Audition
183
237
  autofix: wrappable ? wrap_make_shareable(value) : nil)
184
238
  when :default_proc
185
239
  flag(node, :hash_default_proc, name: name)
240
+ when :shallow_opaque
241
+ flag(node, :shallow_opaque, name: name)
242
+ # Build-then-freeze leaves depth unknown: stay silent.
243
+ when :instance_new
244
+ unless frozen_later?(name)
245
+ flag(node, :unshareable_instance, name: name,
246
+ klass:
247
+ classifier.const_name(value.receiver) || "new")
248
+ end
249
+ when :opaque_call
250
+ unless frozen_later?(name)
251
+ flag(node, :opaque_constant, name: name,
252
+ method: opaque_display(value))
253
+ end
186
254
  end
187
255
  end
188
256
 
189
- def mutated?(name)
257
+ def mutated?(name) = named_in?(file.mutated_constants, name)
258
+
259
+ def customized?(name)
260
+ named_in?(file.customized_constants, name)
261
+ end
262
+
263
+ def frozen_later?(name) = named_in?(file.frozen_constants, name)
264
+
265
+ def named_in?(names, name)
190
266
  bare = name.split("::").last
191
- file.mutated_constants.any? do |mutated|
192
- mutated == name || mutated.split("::").last == bare
267
+ names.any? do |other|
268
+ other == name || other.split("::").last == bare
193
269
  end
194
270
  end
195
271
 
@@ -262,6 +338,8 @@ module Audition
262
338
  when Prism::HashNode, Prism::KeywordHashNode then "Hash"
263
339
  when Prism::ArrayNode then "Array"
264
340
  when Prism::CallNode
341
+ return "Set" if value.name == :to_set
342
+
265
343
  classifier.const_name(value.receiver) || "container"
266
344
  else
267
345
  "container"
@@ -277,8 +355,11 @@ module Audition
277
355
  # Ternaries classify as strings when both branches are;
278
356
  # `.freeze` binds tighter than `?:`, so they get parens.
279
357
  def call_type(call)
280
- owner = classifier.const_name(call.receiver)
281
- (owner == "Regexp") ? "Regexp" : "String"
358
+ case classifier.const_name(call.receiver)
359
+ when "Regexp" then "Regexp"
360
+ when "Object", "BasicObject" then "Object"
361
+ else "String"
362
+ end
282
363
  end
283
364
 
284
365
  def call_display(call)
@@ -288,14 +369,32 @@ module Audition
288
369
  owner ? "#{owner}.#{call.name}" : "String##{call.name}"
289
370
  end
290
371
 
372
+ # The receiver is arbitrary, often a chain, so the
373
+ # fallback names only the method.
374
+ def opaque_display(call)
375
+ owner = classifier.const_name(call.receiver)
376
+ method =
377
+ if owner
378
+ "#{owner}.#{call.name}"
379
+ elsif call.receiver
380
+ ".#{call.name}"
381
+ else
382
+ call.name.to_s
383
+ end
384
+ "a #{method} call"
385
+ end
386
+
291
387
  # `.freeze` binds tighter than an operator: `"a" + "b".freeze`
292
388
  # freezes only "b", so operator calls get parentheses while
293
389
  # literals and parenthesized or argument-free calls take
294
390
  # the bare suffix.
295
391
  def bare_freezable?(value)
296
392
  case value
297
- when Prism::StringNode, Prism::InterpolatedStringNode
393
+ when Prism::StringNode, Prism::InterpolatedStringNode,
394
+ Prism::HashNode
298
395
  true
396
+ when Prism::ArrayNode
397
+ !value.opening_loc.nil?
299
398
  when Prism::CallNode
300
399
  !value.opening_loc.nil? ||
301
400
  (!value.receiver.nil? && value.arguments.nil?)
@@ -305,6 +404,16 @@ module Audition
305
404
  end
306
405
 
307
406
  def append_freeze(value)
407
+ # `X = :a, :b` has no brackets; it gains them so the
408
+ # suffix freezes the whole array.
409
+ if value.is_a?(Prism::ArrayNode) && value.opening_loc.nil?
410
+ return Autofix.new(
411
+ start_offset: value.location.start_offset,
412
+ end_offset: value.location.end_offset,
413
+ replacement: "[#{value.location.slice}].freeze"
414
+ )
415
+ end
416
+
308
417
  if bare_freezable?(value)
309
418
  offset = value.location.end_offset
310
419
  Autofix.new(
@@ -322,6 +431,31 @@ module Audition
322
431
  end
323
432
  end
324
433
 
434
+ # A frozen bare Object is shareable; BasicObject has no
435
+ # #freeze, so its sentinel becomes a frozen Object.
436
+ def freeze_call(value)
437
+ return append_freeze(value) unless
438
+ classifier.const_name(value.receiver) == "BasicObject"
439
+
440
+ Autofix.new(
441
+ start_offset: value.location.start_offset,
442
+ end_offset: value.location.end_offset,
443
+ replacement: "Object.new.freeze",
444
+ safety: :unsafe
445
+ )
446
+ end
447
+
448
+ # Plain `.freeze` where every element is provably
449
+ # shareable, the plain-Ruby shape; the deep wrap only
450
+ # where a shallow freeze would not be enough.
451
+ def freeze_container(value)
452
+ if classifier.frozen_kind(value) == :shareable
453
+ append_freeze(value)
454
+ else
455
+ wrap_make_shareable(value)
456
+ end
457
+ end
458
+
325
459
  # `X = :a, :b` is an array literal without brackets; the
326
460
  # slice must gain them or the wrap becomes a multi-arg
327
461
  # call (mail's ATTRIBUTES).
@@ -22,7 +22,10 @@ module Audition
22
22
  "load-time side effects run at an arbitrary " \
23
23
  "point.",
24
24
  fix: "Require eagerly at boot, before Ractors are " \
25
- "spawned."
25
+ "spawned. For an optional dependency, the " \
26
+ "class-level macro that enables the feature is " \
27
+ "a boot-time scope; the request-time method is " \
28
+ "not."
26
29
 
27
30
  explain :autoload,
28
31
  severity: :warning,