audition 0.4.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.
@@ -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,
@@ -153,12 +184,16 @@ module Audition
153
184
  "freeze it (each_with_object then .freeze), " \
154
185
  "or move the registry behind a writer that " \
155
186
  "rebuilds and refreezes on each change " \
156
- "(copy-on-write). A " \
187
+ "(copy-on-write; Ractor.make_shareable when the " \
188
+ "additions may be unfrozen). A " \
157
189
  "registry that plugins extend during boot is " \
158
190
  "frozen in the last boot hook (after_initialize) " \
159
191
  "rather than at definition, and writes after the " \
160
192
  "freeze merge into a fresh frozen copy with a " \
161
- "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."
162
197
 
163
198
  on :constant_write_node, :constant_or_write_node do |node|
164
199
  examine(node.name.to_s, node, node.value)
@@ -213,6 +248,19 @@ module Audition
213
248
  flag(node, :mutable_container, name: name,
214
249
  type: container_type(value),
215
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))
216
264
  when :shallow_freeze
217
265
  flag(node, :shallow_freeze, name: name,
218
266
  autofix:
@@ -354,19 +402,50 @@ module Audition
354
402
 
355
403
  # Ternaries classify as strings when both branches are;
356
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.
357
407
  def call_type(call)
358
408
  case classifier.const_name(call.receiver)
359
409
  when "Regexp" then "Regexp"
360
410
  when "Object", "BasicObject" then "Object"
361
- else "String"
411
+ else
412
+ if call.name == :+ && !string_receiver?(call)
413
+ "object"
414
+ else
415
+ "String"
416
+ end
362
417
  end
363
418
  end
364
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
+
365
426
  def call_display(call)
366
- 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?
367
430
 
368
431
  owner = classifier.const_name(call.receiver)
369
- owner ? "#{owner}.#{call.name}" : "String##{call.name}"
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?
370
449
  end
371
450
 
372
451
  # The receiver is arbitrary, often a chain, so the
@@ -385,9 +464,10 @@ module Audition
385
464
  end
386
465
 
387
466
  # `.freeze` binds tighter than an operator: `"a" + "b".freeze`
388
- # freezes only "b", so operator calls get parentheses while
389
- # literals and parenthesized or argument-free calls take
390
- # 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.
391
471
  def bare_freezable?(value)
392
472
  case value
393
473
  when Prism::StringNode, Prism::InterpolatedStringNode,
@@ -396,6 +476,8 @@ module Audition
396
476
  when Prism::ArrayNode
397
477
  !value.opening_loc.nil?
398
478
  when Prism::CallNode
479
+ return false if value.name.end_with?("@")
480
+
399
481
  !value.opening_loc.nil? ||
400
482
  (!value.receiver.nil? && value.arguments.nil?)
401
483
  else
@@ -445,6 +527,16 @@ module Audition
445
527
  )
446
528
  end
447
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
+
448
540
  # Plain `.freeze` where every element is provably
449
541
  # shareable, the plain-Ruby shape; the deep wrap only
450
542
  # where a shallow freeze would not be enough.
@@ -9,6 +9,15 @@ module Audition
9
9
  # resolution depth of every local reference, so captures are
10
10
  # detectable exactly: a reference whose depth reaches past the
11
11
  # Ractor block's own scope is an outer capture.
12
+ #
13
+ # Ractor.shareable_proc applies a weaker rule to any block at
14
+ # conversion time: a captured local may not hold an
15
+ # unshareable object and may not be assigned more than once.
16
+ # Rails converts the blocks handed to its callback macros the
17
+ # same way once unshareable_proc_action is set, so a callback
18
+ # whose capture is provably unshareable is reported too; a
19
+ # capture of unknown value stays silent, and the boot gate is
20
+ # its detector.
12
21
  class RactorIsolation < Base
13
22
  explain :outer_capture,
14
23
  severity: :error,
@@ -22,6 +31,105 @@ module Audition
22
31
  "Ractor.new(x) { |x| ... }, or send them " \
23
32
  "through a Ractor::Port."
24
33
 
34
+ CAPTURE_FIX =
35
+ "Capture a shareable value: freeze the local (a " \
36
+ "frozen literal or .freeze), inline it, or hoist a " \
37
+ "shareable leaf such as a Symbol into a fresh local " \
38
+ "assigned once before the block."
39
+
40
+ explain :shareable_proc_capture,
41
+ severity: :error,
42
+ message: "%{method} block captures %{what}",
43
+ why: "Ractor.shareable_proc refuses a block that can " \
44
+ "refer to an unshareable object through an outer " \
45
+ "local, or whose outer local is assigned more " \
46
+ "than once, with Ractor::IsolationError at " \
47
+ "conversion time.",
48
+ fix: CAPTURE_FIX
49
+
50
+ explain :callback_capture,
51
+ severity: :warning,
52
+ message: "block passed to %{method} captures %{what}",
53
+ why: "Rails stores the block as a callback and, with " \
54
+ "unshareable_proc_action set to :warn or :raise, " \
55
+ "runs it through Ractor.shareable_proc; a block " \
56
+ "that refers to an unshareable local, or to one " \
57
+ "assigned more than once, cannot be made " \
58
+ "shareable and stays an unshareable Proc, which " \
59
+ "raises Ractor::IsolationError once a non-main " \
60
+ "Ractor runs the callback.",
61
+ fix: CAPTURE_FIX
62
+
63
+ # Methods that keep their block for later, as a callback or
64
+ # boot hook; Rails runs each through try_shareable_proc.
65
+ CALLBACK_MACROS = %i[
66
+ validate validates_each set_callback on_load initializer
67
+ to_prepare rescue_from scope default_scope
68
+ ].freeze
69
+ CALLBACK_PREFIXES = %w[
70
+ before_ after_ around_ prepend_before_ prepend_after_
71
+ prepend_around_ append_before_ append_after_
72
+ append_around_
73
+ ].freeze
74
+ CONVERTERS = %i[shareable_proc shareable_lambda].freeze
75
+
76
+ # Classifications a captured value must have for the
77
+ # conversion to fail for certain.
78
+ UNSHAREABLE_KINDS = %i[
79
+ mutable_string mutable_container mutable_call
80
+ sync_primitive default_proc proc
81
+ ].freeze
82
+
83
+ def initialize(file)
84
+ super
85
+ @frames = []
86
+ @pending = []
87
+ end
88
+
89
+ # Captures are judged once the whole file is read: a local
90
+ # reassigned after the block is refused just the same.
91
+ def visit_program_node(node)
92
+ framed { super }
93
+ @pending.each { |entry| judge(entry) }
94
+ end
95
+
96
+ def visit_class_node(node) = framed { super }
97
+
98
+ def visit_module_node(node) = framed { super }
99
+
100
+ def visit_singleton_class_node(node) = framed { super }
101
+
102
+ def visit_def_node(node) = framed { super }
103
+
104
+ def visit_block_node(node) = framed { super }
105
+
106
+ def visit_lambda_node(node) = framed { super }
107
+
108
+ def visit_local_variable_write_node(node)
109
+ assign(node.name, node.depth, node.value)
110
+ super
111
+ end
112
+
113
+ def visit_local_variable_or_write_node(node)
114
+ assign(node.name, node.depth, nil)
115
+ super
116
+ end
117
+
118
+ def visit_local_variable_operator_write_node(node)
119
+ assign(node.name, node.depth, nil)
120
+ super
121
+ end
122
+
123
+ def visit_local_variable_and_write_node(node)
124
+ assign(node.name, node.depth, nil)
125
+ super
126
+ end
127
+
128
+ def visit_local_variable_target_node(node)
129
+ assign(node.name, node.depth, nil)
130
+ super
131
+ end
132
+
25
133
  def visit_call_node(node)
26
134
  examine(node)
27
135
  super
@@ -29,18 +137,37 @@ module Audition
29
137
 
30
138
  private
31
139
 
32
- def examine(node)
33
- return unless node.name == :new
34
- return unless ractor_receiver?(node.receiver)
140
+ def framed
141
+ @frames.push({})
142
+ yield
143
+ ensure
144
+ @frames.pop
145
+ end
146
+
147
+ # Prism resolves a local write to the scope `depth` levels
148
+ # up; the frame at that level records every assignment so
149
+ # the capture judge can count them and classify the value.
150
+ def assign(name, depth, value)
151
+ frame = @frames[-1 - depth]
152
+ return unless frame
153
+
154
+ (frame[name.to_s] ||= []) << value
155
+ end
35
156
 
157
+ def examine(node)
36
158
  block = node.block
37
- return unless block.is_a?(Prism::BlockNode)
38
- return unless block.body
159
+ return unless block.is_a?(Prism::BlockNode) && block.body
39
160
 
40
- names = CaptureScanner.scan(block.body)
41
- return if names.empty?
161
+ if node.name == :new && ractor_receiver?(node.receiver)
162
+ names = CaptureScanner.scan(block.body)
163
+ return if names.empty?
42
164
 
43
- flag(node, :outer_capture, names: names.join(", "))
165
+ flag(node, :outer_capture, names: names.join(", "))
166
+ elsif CONVERTERS.include?(node.name)
167
+ defer(node, :shareable_proc_capture)
168
+ elsif callback_macro?(node.name)
169
+ defer(node, :callback_capture)
170
+ end
44
171
  end
45
172
 
46
173
  def ractor_receiver?(receiver)
@@ -48,20 +175,90 @@ module Audition
48
175
  receiver.name == :Ractor
49
176
  end
50
177
 
51
- # Walks the Ractor block's body tracking how many block
52
- # scopes deep we are; a local reference with depth greater
53
- # than that resolves outside the Ractor block.
178
+ def callback_macro?(name)
179
+ return true if CALLBACK_MACROS.include?(name)
180
+
181
+ text = name.to_s
182
+ CALLBACK_PREFIXES.any? { |prefix| text.start_with?(prefix) }
183
+ end
184
+
185
+ # The frames a capture resolves to are the ones open now;
186
+ # they keep filling as traversal continues, so the entry
187
+ # holds references and is judged at the end.
188
+ def defer(node, key)
189
+ captures = CaptureScanner.captures(node.block.body)
190
+ return if captures.empty?
191
+
192
+ resolved = captures.filter_map do |name, depth|
193
+ frame = @frames[-depth]
194
+ [name, frame] if frame
195
+ end
196
+ return if resolved.empty?
197
+
198
+ @pending << {node: node, key: key, captures: resolved}
199
+ end
200
+
201
+ def judge(entry)
202
+ what = entry[:captures].filter_map do |name, frame|
203
+ describe_capture(name, frame[name])
204
+ end
205
+ return if what.empty?
206
+
207
+ node = entry[:node]
208
+ flag(node, entry[:key],
209
+ method: method_display(node), what: what.join(", "))
210
+ end
211
+
212
+ def describe_capture(name, assignments)
213
+ return nil if assignments.nil? || assignments.empty?
214
+ return "reassigned local #{name}" if assignments.size > 1
215
+
216
+ value = assignments.first
217
+ return nil if value.nil?
218
+
219
+ if UNSHAREABLE_KINDS.include?(classifier.classify(value))
220
+ "unshareable local #{name}"
221
+ end
222
+ end
223
+
224
+ def method_display(node)
225
+ receiver = node.receiver
226
+ case receiver
227
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
228
+ "#{receiver.location.slice}.#{node.name}"
229
+ else
230
+ node.name.to_s
231
+ end
232
+ end
233
+
234
+ def classifier
235
+ @classifier ||= LiteralClassifier.new(
236
+ frozen_string_literal: file.frozen_string_literal?
237
+ )
238
+ end
239
+
240
+ # Walks a block's body tracking how many block scopes deep
241
+ # we are; a local reference with depth greater than that
242
+ # resolves outside the block, `depth - level` scopes above
243
+ # it.
54
244
  class CaptureScanner < Prism::Visitor
55
245
  def self.scan(body)
246
+ captures(body).map(&:first)
247
+ end
248
+
249
+ # @return [Array<Array(String, Integer)>] each captured
250
+ # name once, with how many scopes above the block it
251
+ # lives
252
+ def self.captures(body)
56
253
  scanner = new
57
254
  scanner.visit(body)
58
- scanner.names.uniq
255
+ scanner.captures.uniq(&:first)
59
256
  end
60
257
 
61
- attr_reader :names
258
+ attr_reader :captures
62
259
 
63
260
  def initialize
64
- @names = []
261
+ @captures = []
65
262
  @level = 0
66
263
  super
67
264
  end
@@ -126,7 +323,9 @@ module Audition
126
323
  private
127
324
 
128
325
  def note(node)
129
- @names << node.name.to_s if node.depth > @level
326
+ return unless node.depth > @level
327
+
328
+ @captures << [node.name.to_s, node.depth - @level]
130
329
  end
131
330
  end
132
331
  end
@@ -48,10 +48,13 @@ module Audition
48
48
  "same way). Writes always need the main Ractor.",
49
49
  fix: "Give it a frozen default (default: [].freeze) " \
50
50
  "and write copy-on-write: self.x = (x | [v])" \
51
- ".freeze, the idiom Rails applied across Action " \
52
- "Pack and Active Record; do every write at boot " \
53
- "on the main Ractor. The dynamic probe reports " \
54
- "ground truth for the installed Rails."
51
+ ".freeze when every element is shareable, " \
52
+ "self.x = Ractor.make_shareable(x | [v]) when the " \
53
+ "additions may be unfrozen (Symbol#to_s returns " \
54
+ "an unfrozen String), since a plain freeze is " \
55
+ "shallow; do every write at boot on the main " \
56
+ "Ractor. The dynamic probe reports ground truth " \
57
+ "for the installed Rails."
55
58
 
56
59
  explain :objectspace_id2ref,
57
60
  severity: :warning,
@@ -3,6 +3,7 @@
3
3
  require_relative "checks/base"
4
4
  require_relative "checks/dependency_class_state"
5
5
  require_relative "checks/global_variables"
6
+ require_relative "checks/instance_memoization"
6
7
  require_relative "checks/mutable_constants"
7
8
  require_relative "checks/ractor_isolation"
8
9
  require_relative "checks/runtime_require"
@@ -13,9 +14,9 @@ module Audition
13
14
  module Static
14
15
  module Checks
15
16
  BUILT_IN = [
16
- DependencyClassState, GlobalVariables, MutableConstants,
17
- RactorIsolation, RuntimeRequire, UnsafeCalls,
18
- UnshareableReads
17
+ DependencyClassState, GlobalVariables, InstanceMemoization,
18
+ MutableConstants, RactorIsolation, RuntimeRequire,
19
+ UnsafeCalls, UnshareableReads
19
20
  ].freeze
20
21
 
21
22
  # Expression-level checks, run per file. Class variables and
@@ -35,11 +35,25 @@ module Audition
35
35
  "per-subclass values compute in the inherited hook " \
36
36
  "(guard on subclass.name for anonymous classes). For " \
37
37
  "collections, rebuild and refreeze on write, " \
38
- "copy-on-write: self.list = " \
39
- "(list + [item]).freeze; never mutate in place. As a " \
40
- "last resort use Ractor.store_if_absent for " \
41
- "per-Ractor state, or read the ivar first and proxy " \
42
- "the write to the main Ractor."
38
+ "copy-on-write: self.list = (list + [item]).freeze " \
39
+ "when every element is shareable, self.list = " \
40
+ "Ractor.make_shareable(list + [item]) when the " \
41
+ "additions may be unfrozen, since a plain freeze is " \
42
+ "shallow; never mutate in place. As a last resort use " \
43
+ "Ractor.store_if_absent for per-Ractor state, or read " \
44
+ "the ivar first and proxy the write to the main Ractor."
45
+ PROXIED_WHY =
46
+ "The read is the lock-free fast path and the write runs " \
47
+ "on the main Ractor through on_main, so no non-main " \
48
+ "Ractor ever writes class state. Every worker waiting " \
49
+ "on main serializes there, and a worker's next read " \
50
+ "raises Ractor::IsolationError unless the memoized " \
51
+ "value is shareable."
52
+ PROXIED_FIX =
53
+ "Keep the hatch rare: delete the memo or warm it at " \
54
+ "boot where possible, and make the computed value " \
55
+ "deeply frozen inside the block (.freeze or " \
56
+ "Ractor.make_shareable) so workers can read it."
43
57
  FROZEN_MEMO_WHY =
44
58
  "Every write memoizes a shareable (frozen) value, so " \
45
59
  "non-main Ractors can read it once it has been " \
@@ -760,6 +774,7 @@ module Audition
760
774
  finding_at(
761
775
  defn,
762
776
  check: "class-level-state",
777
+ subject: "#{owner}/#{variable}",
763
778
  severity: :info,
764
779
  message: "frozen memoization #{variable} on " \
765
780
  "#{owner}; warm it on the main Ractor",
@@ -770,16 +785,29 @@ module Audition
770
785
  finding_at(
771
786
  defn,
772
787
  check: "class-level-state",
788
+ subject: "#{owner}/#{variable}",
773
789
  severity: :warning,
774
790
  message: "best-effort frozen state #{variable} " \
775
791
  "on #{owner}",
776
792
  why: BEST_EFFORT_WHY,
777
793
  fix: BEST_EFFORT_FIX
778
794
  )
795
+ when :proxied, :proxied_frozen
796
+ finding_at(
797
+ defn,
798
+ check: "class-level-state",
799
+ subject: "#{owner}/#{variable}",
800
+ severity: (verdict == :proxied) ? :warning : :info,
801
+ message: "memoization #{variable} on #{owner} " \
802
+ "proxied to the main Ractor",
803
+ why: PROXIED_WHY,
804
+ fix: PROXIED_FIX
805
+ )
779
806
  else
780
807
  finding_at(
781
808
  defn,
782
809
  check: "class-level-state",
810
+ subject: "#{owner}/#{variable}",
783
811
  message: "class-level instance variable " \
784
812
  "#{variable} on #{owner}",
785
813
  why: STATE_WHY,
@@ -817,7 +845,8 @@ module Audition
817
845
  fix: STATE_FIX,
818
846
  path: path,
819
847
  line: line,
820
- source: source_line(path, line)
848
+ source: source_line(path, line),
849
+ subject: "#{owner}/@#{ivar}"
821
850
  )
822
851
  end
823
852
  end
@@ -855,7 +884,8 @@ module Audition
855
884
  fix: STATE_FIX,
856
885
  path: path,
857
886
  line: line,
858
- source: source_line(path, line)
887
+ source: source_line(path, line),
888
+ subject: "#{owner}/@#{name}"
859
889
  )
860
890
  end
861
891
  end
@@ -920,7 +950,8 @@ module Audition
920
950
  fix: STATE_FIX,
921
951
  path: path,
922
952
  line: line,
923
- source: source_line(path, line)
953
+ source: source_line(path, line),
954
+ subject: "#{owner}/#{ivar}"
924
955
  )
925
956
  end
926
957
  end
@@ -958,7 +989,8 @@ module Audition
958
989
  fix: STATE_FIX,
959
990
  path: path,
960
991
  line: line,
961
- source: source_line(path, line)
992
+ source: source_line(path, line),
993
+ subject: "#{owner}/#{ivar}"
962
994
  )
963
995
  end
964
996
  end
@@ -1214,7 +1246,7 @@ module Audition
1214
1246
  end
1215
1247
 
1216
1248
  def finding_at(defn, check:, message:, why:, fix:,
1217
- severity: :error)
1249
+ severity: :error, subject: nil)
1218
1250
  path = path_from_uri(defn.location.uri)
1219
1251
  line = defn.location.start_line + 1
1220
1252
  Finding.new(
@@ -1225,7 +1257,8 @@ module Audition
1225
1257
  fix: fix,
1226
1258
  path: path,
1227
1259
  line: line,
1228
- source: source_line(path, line)
1260
+ source: source_line(path, line),
1261
+ subject: subject
1229
1262
  )
1230
1263
  end
1231
1264
 
@@ -1308,7 +1341,10 @@ module Audition
1308
1341
 
1309
1342
  # Cross-file merge keeps the weakest promise: any dirty file
1310
1343
  # taints the group, and best-effort beats fully frozen.
1311
- VERDICT_RANK = {dirty: 0, best_effort: 1, frozen: 2}.freeze
1344
+ VERDICT_RANK = {
1345
+ dirty: 0, best_effort: 1, proxied: 2, proxied_frozen: 3,
1346
+ frozen: 4
1347
+ }.freeze
1312
1348
 
1313
1349
  def weaker_verdict(existing, verdict)
1314
1350
  return verdict unless existing
@@ -1321,6 +1357,9 @@ module Audition
1321
1357
  return :dirty if ops.any? { |op| op[:kind] == :other }
1322
1358
 
1323
1359
  memos = Rewriters::Memoization.memo_sites(ops)
1360
+ if memos.any? && memos.all? { |m| m[:op][:proxied] }
1361
+ return proxied_verdict(ops, memos, classifier)
1362
+ end
1324
1363
  if memos.any?
1325
1364
  return group_frozen?(ops, classifier) ? :frozen : :dirty
1326
1365
  end
@@ -1340,6 +1379,22 @@ module Audition
1340
1379
  (all_safe && wrapped) ? :best_effort : :dirty
1341
1380
  end
1342
1381
 
1382
+ # The read-then-proxy hatch, `@x || on_main(self) { @x ||= v }`:
1383
+ # every write is a memo site inside the block, so the main
1384
+ # Ractor performs it by construction, and the value decides
1385
+ # between a note and a warning. A plain write elsewhere
1386
+ # (a reset) is a worker-side write again.
1387
+ def proxied_verdict(ops, memos, classifier)
1388
+ memo_ops = memos.map { |memo| memo[:op] }
1389
+ writes = ops.select { |op| op[:kind] == :write }
1390
+ return :dirty unless (writes - memo_ops).empty?
1391
+
1392
+ frozen = memos.all? do |memo|
1393
+ frozen_memo_value?(memo[:op][:node].value, classifier)
1394
+ end
1395
+ frozen ? :proxied_frozen : :proxied
1396
+ end
1397
+
1343
1398
  # Matches the emitted setter recipe:
1344
1399
  # (Ractor.make_shareable(value) rescue value)
1345
1400
  def best_effort_value?(node)