audition 0.2.4 → 0.3.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.
@@ -1,79 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "pastel"
5
- require "tty/link"
6
-
7
3
  module Audition
8
- # Aggregates static findings and dynamic results into a verdict and
9
- # renders them as npm-CLI-style text or JSON.
4
+ # Aggregates static findings and dynamic results into a verdict
5
+ # and the counts the renderers work from. Rendering lives in the
6
+ # Report::Text, Report::Json, and Report::Github classes.
10
7
  class Report
11
- # ANSI + OSC 8 styling with graceful degradation. Color and
12
- # hyperlinks are decided once, at construction; pass color: false
13
- # for pipes, NO_COLOR, or dumb terminals.
14
- class Style
15
- GLYPHS = Ractor.make_shareable(
16
- {
17
- error: ["✖", "x"], warning: ["⚠", "!"],
18
- info: ["ℹ", "i"], pass: ["✔", "ok"],
19
- section: ["◆", "*"], fix: ["✎", "+"]
20
- }
21
- )
22
-
23
- PAINTS = %i[red yellow green cyan magenta dim bold].freeze
24
-
25
- def self.detect(io: $stdout)
26
- on = io.respond_to?(:tty?) && io.tty? &&
27
- !ENV.key?("NO_COLOR") && ENV["TERM"] != "dumb"
28
- new(color: on, hyperlinks: on && TTY::Link.link?)
29
- end
30
-
31
- def initialize(color:, hyperlinks:)
32
- @pastel = Pastel.new(enabled: color)
33
- @color = color
34
- @hyperlinks = hyperlinks
35
- end
36
-
37
- def color?
38
- @color
39
- end
40
-
41
- def glyph(kind)
42
- GLYPHS.fetch(kind)[@color ? 0 : 1]
43
- end
44
-
45
- PAINTS.each do |name|
46
- define_method(name) do |text| # audition:disable unsafe-calls
47
- @pastel.public_send(name, text)
48
- end
49
- end
50
-
51
- def severity_color(severity, text)
52
- case severity
53
- when :error then red(text)
54
- when :warning then yellow(text)
55
- else cyan(text)
56
- end
57
- end
58
-
59
- # OSC 8 hyperlink wrapping "path:line" display text in a
60
- # file:// URI; supporting terminals make it clickable.
61
- # tty-link emits when it detects support; when hyperlinks are
62
- # forced on despite no detection (tests, --force scenarios)
63
- # fall back to the raw OSC 8 template, since tty-link's
64
- # fallback is "text -> url" prose.
65
- def link(text, absolute_path)
66
- return text unless @hyperlinks
67
-
68
- uri = "file://#{absolute_path}"
69
- if TTY::Link.link?
70
- TTY::Link.link_to(text, uri)
71
- else
72
- "\e]8;;#{uri}\e\\#{text}\e]8;;\e\\"
73
- end
74
- end
75
- end
76
-
77
8
  VERDICTS = {
78
9
  not_ready: "not ractor-ready",
79
10
  blocked: "own code is ractor-ready; blocked by dependencies",
@@ -148,227 +79,10 @@ module Audition
148
79
  end
149
80
  end
150
81
  end
151
-
152
- # @param style [Style] rendering style (auto-detected default)
153
- # @return [String] the human-facing terminal report
154
- def to_text(style: Style.detect)
155
- Text.new(self, style).render
156
- end
157
-
158
- GITHUB_LEVELS = {
159
- error: "error", warning: "warning", info: "notice"
160
- }.freeze
161
-
162
- # GitHub Actions workflow commands: findings become inline PR
163
- # annotations when this runs in CI.
164
- #
165
- # @return [String] one `::error`/`::warning`/`::notice` line
166
- # per finding plus a verdict line
167
- def to_github
168
- lines = findings.map do |f|
169
- level = GITHUB_LEVELS.fetch(f.severity)
170
- location = f.line ? ",line=#{f.line}" : ""
171
- body = workflow_escape("#{f.message}. #{f.why}")
172
- file = property_escape(f.path)
173
- title = property_escape("audition #{f.check}")
174
- "::#{level} file=#{file}#{location}," \
175
- "title=#{title}::#{body}"
176
- end
177
- lines << "audition verdict: #{VERDICTS.fetch(verdict)}"
178
- lines.join("\n")
179
- end
180
-
181
- def to_json(*)
182
- JSON.pretty_generate(
183
- "audition" => VERSION,
184
- "ruby" => RUBY_VERSION,
185
- "target" => {"type" => target_type.to_s,
186
- "root" => target_root},
187
- "verdict" => verdict.to_s,
188
- "summary" => {
189
- "errors" => counts[:error],
190
- "dependency_errors" => counts[:dep_error],
191
- "warnings" => counts[:warning],
192
- "infos" => counts[:info],
193
- "fixable" => counts[:fixable]
194
- },
195
- "findings" => findings.map do |f|
196
- {
197
- "check" => f.check,
198
- "severity" => f.severity.to_s,
199
- "message" => f.message,
200
- "why" => f.why,
201
- "fix" => f.fix,
202
- "path" => f.path,
203
- "line" => f.line,
204
- "source" => f.source,
205
- "fixable" => f.fixable?,
206
- "dependency" => f.dependency?
207
- }
208
- end,
209
- "dynamic" => dynamic_results.map do |r|
210
- {"mode" => r.mode.to_s, "passed" => r.passed,
211
- "raw" => r.raw}
212
- end
213
- )
214
- end
215
-
216
- private
217
-
218
- def workflow_escape(text)
219
- text.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
220
- end
221
-
222
- # Workflow command properties additionally reserve `:` and `,`;
223
- # an unescaped comma in a path would end the property early.
224
- def property_escape(text)
225
- workflow_escape(text).gsub(":", "%3A").gsub(",", "%2C")
226
- end
227
-
228
- public
229
-
230
- # Text renderer, kept separate from the data so styles stay
231
- # injectable.
232
- class Text
233
- WRAP = 74
234
-
235
- def initialize(report, style)
236
- @report = report
237
- @style = style
238
- end
239
-
240
- def render
241
- [header, *file_sections, *dynamic_section, summary]
242
- .join("\n")
243
- end
244
-
245
- private
246
-
247
- def header
248
- s = @style
249
- title = s.bold("audition #{VERSION}")
250
- meta = s.dim(
251
- "ruby #{RUBY_VERSION} · #{@report.target_type} at " \
252
- "#{@report.target_root}"
253
- )
254
- "#{s.glyph(:section)} #{title} #{meta}\n"
255
- end
256
-
257
- def file_sections
258
- @report.findings.group_by(&:path).map do |path, findings|
259
- lines = [@style.bold(" #{path}")]
260
- findings.each { |f| lines.concat(finding_lines(f)) }
261
- lines.join("\n") + "\n"
262
- end
263
- end
264
-
265
- def finding_lines(finding)
266
- s = @style
267
- glyph = s.severity_color(finding.severity,
268
- s.glyph(finding.severity))
269
- loc = location_label(finding)
270
- fix_mark = finding.fixable? ? " #{s.cyan(s.glyph(:fix))}" : ""
271
- dep_mark =
272
- finding.dependency? ? " #{s.dim("(dependency)")}" : ""
273
- head = " #{glyph} #{loc}#{finding.message}" \
274
- "#{fix_mark}#{dep_mark} #{s.dim(finding.check)}"
275
- [head,
276
- *annotation("why", finding.why),
277
- *annotation("fix", finding.fix)]
278
- end
279
-
280
- def location_label(finding)
281
- return "" unless finding.line
282
-
283
- s = @style
284
- text = "#{finding.path}:#{finding.line}"
285
- absolute = File.expand_path(finding.path, @report.target_root)
286
- "#{s.cyan(s.link(text, absolute))} "
287
- end
288
-
289
- def annotation(label, content)
290
- return [] if content.nil? || content.empty?
291
-
292
- wrapped = wrap("#{label}: #{content}", WRAP - 6)
293
- wrapped.map { |line| " #{@style.dim(line)}" }
294
- end
295
-
296
- # Tokens longer than the width (long URLs) cannot end before
297
- # whitespace, so the first alternative would drop their head;
298
- # the second hard-slices them instead.
299
- def wrap(text, width)
300
- text.scan(/\S.{0,#{width - 1}}(?=\s|\z)|\S{#{width}}/m)
301
- end
302
-
303
- def dynamic_section
304
- return [] if @report.dynamic_results.empty?
305
-
306
- s = @style
307
- lines = [s.bold(" dynamic probes")]
308
- @report.dynamic_results.each do |result|
309
- lines << if result.passed
310
- " #{s.green(s.glyph(:pass))} " \
311
- "#{result.mode} probe passed inside a Ractor"
312
- else
313
- " #{s.red(s.glyph(:error))} " \
314
- "#{result.mode} probe failed " \
315
- "#{s.dim("(details above)")}"
316
- end
317
- end
318
- [lines.join("\n") + "\n"]
319
- end
320
-
321
- def pluralize(count, noun)
322
- (count == 1) ? "#{count} #{noun}" : "#{count} #{noun}s"
323
- end
324
-
325
- def summary
326
- s = @style
327
- c = @report.counts
328
- parts = []
329
- if c[:error].positive?
330
- parts << s.red(pluralize(c[:error], "error"))
331
- end
332
- if c[:dep_error].positive?
333
- parts << s.magenta(
334
- pluralize(c[:dep_error], "dependency error")
335
- )
336
- end
337
- if c[:warning].positive?
338
- parts << s.yellow(pluralize(c[:warning], "warning"))
339
- end
340
- parts << s.cyan("#{c[:info]} info") if c[:info].positive?
341
- if c[:fixable].positive?
342
- parts << s.cyan(
343
- "#{c[:fixable]} fixable #{s.glyph(:fix)} " \
344
- "(run with --fix)"
345
- )
346
- end
347
- if @report.unsafe_fixes.positive?
348
- parts << s.cyan(
349
- pluralize(@report.unsafe_fixes, "edit") +
350
- " with --fix-unsafe"
351
- )
352
- end
353
- if @report.baselined.positive?
354
- parts << s.dim("#{@report.baselined} baselined")
355
- end
356
- parts << s.green("no findings") if parts.empty?
357
-
358
- verdict = @report.verdict
359
- glyph, paint =
360
- case verdict
361
- when :not_ready then [:error, :red]
362
- when :blocked then [:warning, :magenta]
363
- when :risky then [:warning, :yellow]
364
- else [:pass, :green]
365
- end
366
- badge = s.public_send(paint,
367
- "#{s.glyph(glyph)} " +
368
- VERDICTS.fetch(verdict))
369
- " summary: #{parts.join(" · ")}\n" \
370
- " verdict: #{s.bold(badge)}\n"
371
- end
372
- end
373
82
  end
374
83
  end
84
+
85
+ require_relative "report/style"
86
+ require_relative "report/text"
87
+ require_relative "report/json"
88
+ require_relative "report/github"
@@ -190,9 +190,9 @@ module Audition
190
190
  # @x ||= expr
191
191
  # return @x if defined?(@x); @x = expr
192
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
193
+ # Preferred strategy is freeze-on-memoize: the memoization
194
+ # stays exactly as written and only the memoized value
195
+ # becomes shareable
196
196
  # (`.freeze` appended; Ractor.make_shareable for containers).
197
197
  # Non-main Ractors may then read the ivar once it has been
198
198
  # computed; the first write must still happen on the main
@@ -325,7 +325,7 @@ module Audition
325
325
  end
326
326
 
327
327
  # Config setters (`def self.backend=(value); @backend =
328
- # value; end`) get the Rails try_make_shareable recipe in
328
+ # value; end`) get a try-make-shareable recipe in
329
329
  # plain Ruby: shareable values are deeply frozen so reads
330
330
  # from any Ractor become legal, unshareable values keep
331
331
  # today's behavior through the rescue. Only bare local reads
@@ -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,8 +98,7 @@ 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, [])."
@@ -111,8 +114,8 @@ module Audition
111
114
  fix: "Build the complete value at load time and " \
112
115
  "freeze it (each_with_object then .freeze), " \
113
116
  "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 " \
117
+ "rebuilds and refreezes on each change " \
118
+ "(copy-on-write). A " \
116
119
  "registry that plugins extend during boot is " \
117
120
  "frozen in the last boot hook (after_initialize) " \
118
121
  "rather than at definition, and writes after the " \
@@ -144,21 +147,30 @@ module Audition
144
147
 
145
148
  # A constant this file itself mutates in place is a
146
149
  # deliberate accumulator; freezing it would raise at
147
- # the mutation site (sinatra's PARAMS_CONFIG). The
150
+ # the mutation site (sinatra's PARAMS_CONFIG). One it
151
+ # gives singleton methods raises the same way. The
148
152
  # finding stays, the autofix goes.
149
- fix_ok = !mutated?(name)
150
- case classifier.classify(value)
153
+ fix_ok = !mutated?(name) && !customized?(name)
154
+ kind = classifier.classify(value)
155
+ # Build-then-freeze: a bare `NAME.freeze` later in the
156
+ # same body makes the literal as good as frozen, so
157
+ # only provably mutable elements remain to report.
158
+ if kind == :mutable_container && frozen_later?(name)
159
+ kind = classifier.frozen_kind(value)
160
+ fix_ok = false
161
+ end
162
+ case kind
151
163
  when :mutable_string
152
164
  flag(node, :mutable_string, name: name,
153
165
  autofix: fix_ok ? append_freeze(value) : nil)
154
166
  when :mutable_call
155
167
  flag(node, :mutable_call, name: name,
156
168
  type: call_type(value), method: call_display(value),
157
- autofix: fix_ok ? append_freeze(value) : nil)
169
+ autofix: fix_ok ? freeze_call(value) : nil)
158
170
  when :mutable_container
159
171
  flag(node, :mutable_container, name: name,
160
172
  type: container_type(value),
161
- autofix: fix_ok ? wrap_make_shareable(value) : nil)
173
+ autofix: fix_ok ? freeze_container(value) : nil)
162
174
  when :shallow_freeze
163
175
  flag(node, :shallow_freeze, name: name,
164
176
  autofix:
@@ -186,10 +198,18 @@ module Audition
186
198
  end
187
199
  end
188
200
 
189
- def mutated?(name)
201
+ def mutated?(name) = named_in?(file.mutated_constants, name)
202
+
203
+ def customized?(name)
204
+ named_in?(file.customized_constants, name)
205
+ end
206
+
207
+ def frozen_later?(name) = named_in?(file.frozen_constants, name)
208
+
209
+ def named_in?(names, name)
190
210
  bare = name.split("::").last
191
- file.mutated_constants.any? do |mutated|
192
- mutated == name || mutated.split("::").last == bare
211
+ names.any? do |other|
212
+ other == name || other.split("::").last == bare
193
213
  end
194
214
  end
195
215
 
@@ -262,6 +282,8 @@ module Audition
262
282
  when Prism::HashNode, Prism::KeywordHashNode then "Hash"
263
283
  when Prism::ArrayNode then "Array"
264
284
  when Prism::CallNode
285
+ return "Set" if value.name == :to_set
286
+
265
287
  classifier.const_name(value.receiver) || "container"
266
288
  else
267
289
  "container"
@@ -277,8 +299,11 @@ module Audition
277
299
  # Ternaries classify as strings when both branches are;
278
300
  # `.freeze` binds tighter than `?:`, so they get parens.
279
301
  def call_type(call)
280
- owner = classifier.const_name(call.receiver)
281
- (owner == "Regexp") ? "Regexp" : "String"
302
+ case classifier.const_name(call.receiver)
303
+ when "Regexp" then "Regexp"
304
+ when "Object", "BasicObject" then "Object"
305
+ else "String"
306
+ end
282
307
  end
283
308
 
284
309
  def call_display(call)
@@ -294,8 +319,11 @@ module Audition
294
319
  # the bare suffix.
295
320
  def bare_freezable?(value)
296
321
  case value
297
- when Prism::StringNode, Prism::InterpolatedStringNode
322
+ when Prism::StringNode, Prism::InterpolatedStringNode,
323
+ Prism::HashNode
298
324
  true
325
+ when Prism::ArrayNode
326
+ !value.opening_loc.nil?
299
327
  when Prism::CallNode
300
328
  !value.opening_loc.nil? ||
301
329
  (!value.receiver.nil? && value.arguments.nil?)
@@ -305,6 +333,16 @@ module Audition
305
333
  end
306
334
 
307
335
  def append_freeze(value)
336
+ # `X = :a, :b` has no brackets; it gains them so the
337
+ # suffix freezes the whole array.
338
+ if value.is_a?(Prism::ArrayNode) && value.opening_loc.nil?
339
+ return Autofix.new(
340
+ start_offset: value.location.start_offset,
341
+ end_offset: value.location.end_offset,
342
+ replacement: "[#{value.location.slice}].freeze"
343
+ )
344
+ end
345
+
308
346
  if bare_freezable?(value)
309
347
  offset = value.location.end_offset
310
348
  Autofix.new(
@@ -322,6 +360,31 @@ module Audition
322
360
  end
323
361
  end
324
362
 
363
+ # A frozen bare Object is shareable; BasicObject has no
364
+ # #freeze, so its sentinel becomes a frozen Object.
365
+ def freeze_call(value)
366
+ return append_freeze(value) unless
367
+ classifier.const_name(value.receiver) == "BasicObject"
368
+
369
+ Autofix.new(
370
+ start_offset: value.location.start_offset,
371
+ end_offset: value.location.end_offset,
372
+ replacement: "Object.new.freeze",
373
+ safety: :unsafe
374
+ )
375
+ end
376
+
377
+ # Plain `.freeze` where every element is provably
378
+ # shareable, the plain-Ruby shape; the deep wrap only
379
+ # where a shallow freeze would not be enough.
380
+ def freeze_container(value)
381
+ if classifier.frozen_kind(value) == :shareable
382
+ append_freeze(value)
383
+ else
384
+ wrap_make_shareable(value)
385
+ end
386
+ end
387
+
325
388
  # `X = :a, :b` is an array literal without brackets; the
326
389
  # slice must gain them or the wrap becomes a multi-arg
327
390
  # 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,
@@ -27,10 +27,12 @@ module Audition
27
27
  "variables; reads and writes alike raise " \
28
28
  "Ractor::IsolationError from a non-main Ractor, " \
29
29
  "whatever the value holds.",
30
- fix: "Rails itself migrated these to class_attribute " \
31
- "(a class-level ivar whose frozen value any " \
32
- "Ractor may read) or to a module ivar behind a " \
33
- "reader; give it a frozen default and rebuild " \
30
+ fix: "Move the state to class_attribute (a " \
31
+ "class-level ivar whose frozen value any Ractor " \
32
+ "may read) or, for plain settings, to " \
33
+ "singleton_class.attr_accessor plus " \
34
+ "delegate(..., to: TheModule) for the instance " \
35
+ "readers; give it a frozen default and rebuild " \
34
36
  "and refreeze on write, at boot on the main " \
35
37
  "Ractor."
36
38
 
@@ -110,8 +112,7 @@ module Audition
110
112
  "unless the block was made shareable first.",
111
113
  fix: "Pass a shareable lambda instead of a block: " \
112
114
  "define_method(:x, Ractor.shareable_lambda " \
113
- "{ ... }), as Rails did for its date selectors " \
114
- "and url helpers. Captured locals must be " \
115
+ "{ ... }). Captured locals must be " \
115
116
  "shareable (strings become symbols) and assigned " \
116
117
  "before the lambda is created, and super is " \
117
118
  "unavailable. When the captures are literals, " \
@@ -34,7 +34,7 @@ module Audition
34
34
  "per-subclass values compute in the inherited hook " \
35
35
  "(guard on subclass.name for anonymous classes). For " \
36
36
  "collections, rebuild and refreeze on write, " \
37
- "Rails-style copy-on-write: self.list = " \
37
+ "copy-on-write: self.list = " \
38
38
  "(list + [item]).freeze; never mutate in place. As a " \
39
39
  "last resort use Ractor.store_if_absent for " \
40
40
  "per-Ractor state, or read the ivar first and proxy " \
@@ -43,9 +43,7 @@ module Audition
43
43
  "Every write memoizes a shareable (frozen) value, so " \
44
44
  "non-main Ractors can read it once it has been " \
45
45
  "computed; only the first write must happen on the " \
46
- "main Ractor, or it raises Ractor::IsolationError. " \
47
- "This is the pattern Rails core uses for its own " \
48
- "memoized class state."
46
+ "main Ractor, or it raises Ractor::IsolationError."
49
47
  FROZEN_MEMO_FIX =
50
48
  "Warm the cache at boot, before spawning Ractors: call " \
51
49
  "the memoizing method from an initializer, an on_load " \
@@ -188,7 +186,7 @@ module Audition
188
186
  )
189
187
  end
190
188
 
191
- # Frozen memoization, the shape Rails core ships: every
189
+ # Frozen memoization: every
192
190
  # write to the ivar is a memo site (`@x ||=` or a defined?
193
191
  # guard) whose value is provably shareable, either a frozen
194
192
  # literal, an explicit `.freeze` or make_shareable call.