audition 0.3.0 → 0.4.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.
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ module Static
5
+ module Checks
6
+ # Lazy memoization on an instance is harmless until the
7
+ # instance is frozen: Ractor.make_shareable freezes every
8
+ # object it reaches, and the next `@x ||=` raises FrozenError.
9
+ # Two shapes make the freeze provable from the class alone: an
10
+ # initialize that ends by freezing self (a value object), and a
11
+ # `freeze` override (the class expects to be frozen). In the
12
+ # first the memo can never run; in the second it must be
13
+ # warmed inside the override, before super, which is the
14
+ # compute-on-freeze pattern. Class-level memos belong to the
15
+ # graph audit.
16
+ class InstanceMemoization < Base
17
+ explain :memo_after_self_freeze,
18
+ severity: :error,
19
+ message: "instance memoization %{ivar} in #%{method} on " \
20
+ "a class that freezes itself in initialize",
21
+ why: "The instance is frozen before any other method " \
22
+ "runs, so the first call writes an instance " \
23
+ "variable on a frozen object and raises " \
24
+ "FrozenError.",
25
+ fix: "Compute the value in initialize, before the " \
26
+ "freeze, and expose it with attr_reader; or drop " \
27
+ "the memo and recompute on each call."
28
+
29
+ explain :memo_not_warmed,
30
+ severity: :warning,
31
+ message: "freeze override leaves %{ivar} cold; " \
32
+ "#%{method} memoizes it lazily",
33
+ why: "Ractor.make_shareable calls freeze, so an " \
34
+ "instance frozen through this override raises " \
35
+ "FrozenError the first time #%{method} runs " \
36
+ "afterwards.",
37
+ fix: "Warm it in the override: call #%{method} (or " \
38
+ "assign %{ivar}) before super, the " \
39
+ "compute-on-freeze pattern; or compute it in " \
40
+ "initialize."
41
+
42
+ def initialize(file)
43
+ super
44
+ @contexts = []
45
+ @sclass_depth = 0
46
+ end
47
+
48
+ def visit_class_node(node) = scoped { super }
49
+
50
+ def visit_module_node(node) = scoped { super }
51
+
52
+ def visit_singleton_class_node(node)
53
+ @sclass_depth += 1
54
+ super
55
+ ensure
56
+ @sclass_depth -= 1
57
+ end
58
+
59
+ # Only plain instance methods count; a def is not entered,
60
+ # so nothing inside a method body opens a context.
61
+ def visit_def_node(node)
62
+ context = @contexts.last
63
+ return unless context && node.receiver.nil? &&
64
+ @sclass_depth.zero?
65
+
66
+ record_method(context, node)
67
+ end
68
+
69
+ private
70
+
71
+ def scoped
72
+ @contexts.push(
73
+ {memos: {}, methods: {}, self_freeze: false, warm: nil}
74
+ )
75
+ saved = @sclass_depth
76
+ @sclass_depth = 0
77
+ yield
78
+ ensure
79
+ @sclass_depth = saved
80
+ report(@contexts.pop)
81
+ end
82
+
83
+ def record_method(context, node)
84
+ case node.name
85
+ when :initialize
86
+ context[:self_freeze] = self_freezing?(node)
87
+ when :freeze
88
+ context[:warm] = touched_by(node)
89
+ else
90
+ context[:methods][node.name] ||= touched_by(node)
91
+ memo_sites(node).each do |ivar, write|
92
+ context[:memos][ivar] ||= {method: node.name, node: write}
93
+ end
94
+ end
95
+ end
96
+
97
+ def report(context)
98
+ memos = context[:memos]
99
+ return if memos.empty?
100
+
101
+ if context[:self_freeze]
102
+ memos.each do |ivar, memo|
103
+ flag(memo[:node], :memo_after_self_freeze,
104
+ ivar: ivar, method: memo[:method])
105
+ end
106
+ elsif context[:warm]
107
+ reached, warmed = warmed_closure(context)
108
+ memos.each do |ivar, memo|
109
+ next if reached.include?(memo[:method]) ||
110
+ warmed.include?(ivar)
111
+
112
+ flag(memo[:node], :memo_not_warmed,
113
+ ivar: ivar, method: memo[:method])
114
+ end
115
+ end
116
+ end
117
+
118
+ # Everything the override reaches through the class's own
119
+ # instance methods: a memo is warm when its method runs on
120
+ # the way, or when any method on the way assigns its ivar.
121
+ def warmed_closure(context)
122
+ methods = context[:methods]
123
+ reached = []
124
+ warmed = context[:warm][:ivars].dup
125
+ queue = context[:warm][:calls].dup
126
+ until queue.empty?
127
+ name = queue.shift
128
+ next if reached.include?(name)
129
+
130
+ reached << name
131
+ touched = methods[name] or next
132
+
133
+ warmed.concat(touched[:ivars])
134
+ queue.concat(touched[:calls])
135
+ end
136
+ [reached, warmed]
137
+ end
138
+
139
+ # initialize ends with `freeze`, `self.freeze`, or
140
+ # `Ractor.make_shareable(self)`.
141
+ def self_freezing?(node)
142
+ last = statements_of(node.body)&.last
143
+ return false unless last.is_a?(Prism::CallNode)
144
+
145
+ receiver = last.receiver
146
+ if last.name == :freeze
147
+ (receiver.nil? || receiver.is_a?(Prism::SelfNode)) &&
148
+ last.arguments.nil?
149
+ elsif last.name == :make_shareable
150
+ last.arguments&.arguments&.first.is_a?(Prism::SelfNode)
151
+ else
152
+ false
153
+ end
154
+ end
155
+
156
+ def statements_of(body)
157
+ case body
158
+ when Prism::StatementsNode then body.body
159
+ when Prism::BeginNode then body.statements&.body
160
+ end
161
+ end
162
+
163
+ # The methods a body calls on self and the ivars it assigns.
164
+ def touched_by(node)
165
+ calls = []
166
+ ivars = []
167
+ each_descendant(node.body) do |child|
168
+ case child
169
+ when Prism::CallNode
170
+ receiver = child.receiver
171
+ if receiver.nil? || receiver.is_a?(Prism::SelfNode)
172
+ calls << child.name
173
+ end
174
+ when Prism::InstanceVariableWriteNode,
175
+ Prism::InstanceVariableOrWriteNode
176
+ ivars << child.name.to_s
177
+ end
178
+ end
179
+ {calls: calls, ivars: ivars}
180
+ end
181
+
182
+ # `@x ||= v`, and `@x = v` guarded by `defined?(@x)` in the
183
+ # same method.
184
+ def memo_sites(node)
185
+ or_writes = []
186
+ writes = {}
187
+ guarded = []
188
+ each_descendant(node.body) do |child|
189
+ case child
190
+ when Prism::InstanceVariableOrWriteNode
191
+ or_writes << [child.name.to_s, child]
192
+ when Prism::InstanceVariableWriteNode
193
+ writes[child.name.to_s] ||= child
194
+ when Prism::DefinedNode
195
+ value = child.value
196
+ if value.is_a?(Prism::InstanceVariableReadNode)
197
+ guarded << value.name.to_s
198
+ end
199
+ end
200
+ end
201
+ guarded.each do |ivar|
202
+ or_writes << [ivar, writes[ivar]] if writes[ivar]
203
+ end
204
+ or_writes
205
+ end
206
+
207
+ # Blocks and lambdas inside a method still write self's
208
+ # ivars; a nested def or class does not.
209
+ def each_descendant(node)
210
+ queue = [node].compact
211
+ until queue.empty?
212
+ current = queue.shift
213
+ yield current
214
+ current.compact_child_nodes.each do |child|
215
+ next if child.is_a?(Prism::DefNode) ||
216
+ child.is_a?(Prism::ClassNode) ||
217
+ child.is_a?(Prism::ModuleNode)
218
+
219
+ queue << child
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -37,6 +37,33 @@ module Audition
37
37
  "BasicObject has no #freeze: use " \
38
38
  "Object.new.freeze for such a sentinel."
39
39
 
40
+ explain :fresh_container,
41
+ severity: :error,
42
+ message: "constant %{name} holds an unfrozen %{type} " \
43
+ "returned by %{method}",
44
+ why: "The call allocates a new %{type} on every core " \
45
+ "receiver that defines it, and " \
46
+ "`# frozen_string_literal: true` covers literals " \
47
+ "only, so the constant holds an unfrozen object " \
48
+ "and a non-main Ractor reading it raises " \
49
+ "Ractor::IsolationError.",
50
+ fix: "Make the value deeply shareable at definition " \
51
+ "time with Ractor.make_shareable(...), or append " \
52
+ "`.freeze` when every element is itself shareable, " \
53
+ "since a bare `.freeze` is shallow."
54
+
55
+ explain :unshareable_object,
56
+ severity: :error,
57
+ message: "constant %{name} holds a Method object from " \
58
+ "%{method}, which is never shareable",
59
+ why: "Method and UnboundMethod objects are not " \
60
+ "Ractor-shareable and freezing one does not help " \
61
+ "(verified on Ruby 4.0); a non-main Ractor reading " \
62
+ "this constant raises Ractor::IsolationError.",
63
+ fix: "Store the method name as a Symbol and look the " \
64
+ "method up where it is called, or keep the Method " \
65
+ "object per-Ractor with Ractor.store_if_absent."
66
+
40
67
  explain :mutable_container,
41
68
  severity: :error,
42
69
  message: "constant %{name} holds a mutable %{type} " \
@@ -46,7 +73,11 @@ module Audition
46
73
  "shareable; otherwise make it deeply shareable with " \
47
74
  "`# shareable_constant_value: literal` or " \
48
75
  "Ractor.make_shareable(...), since a bare " \
49
- "`.freeze` is shallow."
76
+ "`.freeze` is shallow. A public constant that " \
77
+ "applications mutate keeps its name and is read " \
78
+ "through an initialize keyword default into an " \
79
+ "ivar, so a shareable instance carries a frozen " \
80
+ "copy; mutating the constant is then deprecated."
50
81
 
51
82
  explain :shallow_freeze,
52
83
  severity: :error,
@@ -103,6 +134,44 @@ module Audition
103
134
  "or drop the default proc and fetch with a " \
104
135
  "literal default: hash.fetch(key, [])."
105
136
 
137
+ explain :unshareable_instance,
138
+ severity: :warning,
139
+ message: "constant %{name} holds an unfrozen %{klass} " \
140
+ "instance",
141
+ why: "A fresh instance is unfrozen, so non-main " \
142
+ "Ractors raise Ractor::IsolationError reading " \
143
+ "it. A warning, not an error: .new can be " \
144
+ "overridden to return a shareable value.",
145
+ fix: "Freeze it when deeply immutable, wrap in " \
146
+ "Ractor.make_shareable, or keep a per-Ractor " \
147
+ "copy via Ractor.store_if_absent."
148
+
149
+ explain :shallow_opaque,
150
+ severity: :warning,
151
+ message: "constant %{name} is frozen at the top level " \
152
+ "only; what it holds is unproven",
153
+ why: "Freezing is shallow: elements and instance " \
154
+ "variables stay as their calls returned them, and " \
155
+ "unless those are deeply frozen a non-main Ractor " \
156
+ "reading the constant raises " \
157
+ "Ractor::IsolationError. The dynamic probe settles " \
158
+ "it when the target boots.",
159
+ fix: "Build the value with Ractor.make_shareable for a " \
160
+ "deep freeze, or silence a known-shareable value " \
161
+ "with a disable comment."
162
+
163
+ explain :opaque_constant,
164
+ severity: :warning,
165
+ message: "constant %{name} holds the result of " \
166
+ "%{method}; shareability unproven",
167
+ why: "Unless the call returns a deeply frozen value, " \
168
+ "non-main Ractors raise Ractor::IsolationError " \
169
+ "reading the constant. The dynamic probe settles " \
170
+ "it when the target boots.",
171
+ fix: "Freeze the result at definition time, or " \
172
+ "silence a known-shareable value with a disable " \
173
+ "comment."
174
+
106
175
  explain :constant_mutation,
107
176
  severity: :warning,
108
177
  message: "in-place %{method} on constant %{name}",
@@ -115,12 +184,16 @@ module Audition
115
184
  "freeze it (each_with_object then .freeze), " \
116
185
  "or move the registry behind a writer that " \
117
186
  "rebuilds and refreezes on each change " \
118
- "(copy-on-write). A " \
187
+ "(copy-on-write; Ractor.make_shareable when the " \
188
+ "additions may be unfrozen). A " \
119
189
  "registry that plugins extend during boot is " \
120
190
  "frozen in the last boot hook (after_initialize) " \
121
191
  "rather than at definition, and writes after the " \
122
192
  "freeze merge into a fresh frozen copy with a " \
123
- "deprecation instead of raising."
193
+ "deprecation instead of raising. A public " \
194
+ "constant that applications mutate keeps its " \
195
+ "name and is read through an initialize keyword " \
196
+ "default into an ivar; mutating it is deprecated."
124
197
 
125
198
  on :constant_write_node, :constant_or_write_node do |node|
126
199
  examine(node.name.to_s, node, node.value)
@@ -152,6 +225,10 @@ module Audition
152
225
  # finding stays, the autofix goes.
153
226
  fix_ok = !mutated?(name) && !customized?(name)
154
227
  kind = classifier.classify(value)
228
+ # A Sorbet cast returns its argument and a begin
229
+ # block its last statement: fixes, type names and
230
+ # depth checks target the value inside.
231
+ value = classifier.unwrap(value)
155
232
  # Build-then-freeze: a bare `NAME.freeze` later in the
156
233
  # same body makes the literal as good as frozen, so
157
234
  # only provably mutable elements remain to report.
@@ -171,6 +248,19 @@ module Audition
171
248
  flag(node, :mutable_container, name: name,
172
249
  type: container_type(value),
173
250
  autofix: fix_ok ? freeze_container(value) : nil)
251
+ when :fresh_container
252
+ # A later bare freeze settles the top level; only
253
+ # provably mutable elements are still worth a report.
254
+ unless frozen_later?(name) &&
255
+ classifier.fresh_elements(value) != :mutable
256
+ flag(node, :fresh_container, name: name,
257
+ type: classifier.fresh_type(value),
258
+ method: call_display(value),
259
+ autofix: fix_ok ? freeze_fresh(value) : nil)
260
+ end
261
+ when :unshareable_object
262
+ flag(node, :unshareable_object, name: name,
263
+ method: call_display(value))
174
264
  when :shallow_freeze
175
265
  flag(node, :shallow_freeze, name: name,
176
266
  autofix:
@@ -195,6 +285,20 @@ module Audition
195
285
  autofix: wrappable ? wrap_make_shareable(value) : nil)
196
286
  when :default_proc
197
287
  flag(node, :hash_default_proc, name: name)
288
+ when :shallow_opaque
289
+ flag(node, :shallow_opaque, name: name)
290
+ # Build-then-freeze leaves depth unknown: stay silent.
291
+ when :instance_new
292
+ unless frozen_later?(name)
293
+ flag(node, :unshareable_instance, name: name,
294
+ klass:
295
+ classifier.const_name(value.receiver) || "new")
296
+ end
297
+ when :opaque_call
298
+ unless frozen_later?(name)
299
+ flag(node, :opaque_constant, name: name,
300
+ method: opaque_display(value))
301
+ end
198
302
  end
199
303
  end
200
304
 
@@ -298,25 +402,72 @@ module Audition
298
402
 
299
403
  # Ternaries classify as strings when both branches are;
300
404
  # `.freeze` binds tighter than `?:`, so they get parens.
405
+ # `X + "s"` is a String or a Pathname, whatever X is; only
406
+ # a string literal receiver pins the type.
301
407
  def call_type(call)
302
408
  case classifier.const_name(call.receiver)
303
409
  when "Regexp" then "Regexp"
304
410
  when "Object", "BasicObject" then "Object"
305
- else "String"
411
+ else
412
+ if call.name == :+ && !string_receiver?(call)
413
+ "object"
414
+ else
415
+ "String"
416
+ end
306
417
  end
307
418
  end
308
419
 
420
+ def string_receiver?(call)
421
+ receiver = call.receiver
422
+ receiver.is_a?(Prism::StringNode) ||
423
+ receiver.is_a?(Prism::InterpolatedStringNode)
424
+ end
425
+
309
426
  def call_display(call)
310
- return call.name.to_s if call.receiver.nil?
427
+ name = call.name
428
+ return "the #{name} operator" if name.match?(/\A[^a-z_]/i)
429
+ return name.to_s if call.receiver.nil?
430
+
431
+ owner = classifier.const_name(call.receiver)
432
+ return "#{owner}.#{name}" if owner
433
+ return ".#{name}" unless literal_receiver?(call)
434
+
435
+ "#{classifier.fresh_string_owner(call)}##{name}"
436
+ end
437
+
438
+ def literal_receiver?(call)
439
+ receiver = call.receiver
440
+ receiver.is_a?(Prism::StringNode) ||
441
+ receiver.is_a?(Prism::InterpolatedStringNode) ||
442
+ receiver.is_a?(Prism::SymbolNode) ||
443
+ receiver.is_a?(Prism::RegularExpressionNode) ||
444
+ receiver.is_a?(Prism::ArrayNode) ||
445
+ receiver.is_a?(Prism::HashNode) ||
446
+ LiteralClassifier::NUMERIC_LITERALS
447
+ .any? { |type| receiver.is_a?(type) } ||
448
+ !classifier.array_root(receiver).nil?
449
+ end
311
450
 
451
+ # The receiver is arbitrary, often a chain, so the
452
+ # fallback names only the method.
453
+ def opaque_display(call)
312
454
  owner = classifier.const_name(call.receiver)
313
- owner ? "#{owner}.#{call.name}" : "String##{call.name}"
455
+ method =
456
+ if owner
457
+ "#{owner}.#{call.name}"
458
+ elsif call.receiver
459
+ ".#{call.name}"
460
+ else
461
+ call.name.to_s
462
+ end
463
+ "a #{method} call"
314
464
  end
315
465
 
316
466
  # `.freeze` binds tighter than an operator: `"a" + "b".freeze`
317
- # freezes only "b", so operator calls get parentheses while
318
- # literals and parenthesized or argument-free calls take
319
- # the bare suffix.
467
+ # freezes only "b" and `+"a".freeze` dups the frozen
468
+ # literal back into a mutable one, so operator and unary
469
+ # calls get parentheses while literals and parenthesized
470
+ # or argument-free calls take the bare suffix.
320
471
  def bare_freezable?(value)
321
472
  case value
322
473
  when Prism::StringNode, Prism::InterpolatedStringNode,
@@ -325,6 +476,8 @@ module Audition
325
476
  when Prism::ArrayNode
326
477
  !value.opening_loc.nil?
327
478
  when Prism::CallNode
479
+ return false if value.name.end_with?("@")
480
+
328
481
  !value.opening_loc.nil? ||
329
482
  (!value.receiver.nil? && value.arguments.nil?)
330
483
  else
@@ -374,6 +527,16 @@ module Audition
374
527
  )
375
528
  end
376
529
 
530
+ # A fresh container's elements are unknown, so the deep
531
+ # wrap is the fix, except for arrays of Integers.
532
+ def freeze_fresh(value)
533
+ if classifier.fresh_elements(value) == :shareable
534
+ append_freeze(value)
535
+ else
536
+ wrap_make_shareable(value)
537
+ end
538
+ end
539
+
377
540
  # Plain `.freeze` where every element is provably
378
541
  # shareable, the plain-Ruby shape; the deep wrap only
379
542
  # where a shallow freeze would not be enough.