audition 0.2.2 → 0.2.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffdbe2eefef3af0a17fba242519ba4816609fe9447869c39e71a48a7737dbd0d
4
- data.tar.gz: 84c792ede67f91c87253f73a8e55d4ca6d7bd08eef4cf7c7aa0d742cefb6aa51
3
+ metadata.gz: 76a1b0c57c7a373182f7b9f88f765fa8e0167a492e28d028998335bc246c10d9
4
+ data.tar.gz: 2c5ecb49ed0a63ebd7a58cb3db9e8b5e8f9893953198d1c3e9f0cc5ca74cfb3a
5
5
  SHA512:
6
- metadata.gz: 45393085ad096c90da3218561537b6dd0825eecb82a03f08c22757ab4abd7e1f765936e2dc81015501285b1348272e87830f6af60a00dc46ac05599c7096d6d0
7
- data.tar.gz: f587211f3f9e4a4c72b4719611359ae32a6219271183a17fa2a5be5f8ba80195e0aa7233c435e793bdf5e9eee23da49ce4f0261c6dc6d68111d87aa4b504c158
6
+ metadata.gz: 633d141cdc23a3e2e24df8b983b9112ffa9a759194669c4f9a6f62f0bf07219b507bd72cea8235de3c7193ab934c60ca8c98ee2095eabe21fad2e80f682ea8e2
7
+ data.tar.gz: bfd863839b4d5fbea291d22282487de79d261fd55bba5662d82d285e013827290257ca61d5b637000581453ec8f3a0510b1571bf6492d1b9f6f9e7de9f3af225
data/README.md CHANGED
@@ -116,6 +116,7 @@ proxying) and its verified semantics.
116
116
  - [Agent skill](#agent-skill)
117
117
  - [Extending](#extending)
118
118
  - [Development](#development)
119
+ - [Acknowledgements](#acknowledgements)
119
120
  - [License](#license)
120
121
 
121
122
  ## Installation
@@ -212,10 +213,12 @@ Static, with file:line precision:
212
213
  body, `def self.`, and `class << self`, across files; the classic
213
214
  `@cache ||= {}` and `return @x if defined?(@x)` memoizations.
214
215
  - **Constants that are not deeply shareable**: bare mutable
215
- literals, interpolated strings, and the subtle shallow freeze
216
- (`[[1], [2]].freeze` still raises; audition explains why).
217
- Honors `# frozen_string_literal:` and
218
- `# shareable_constant_value:` magic comments.
216
+ literals, interpolated strings, the subtle shallow freeze
217
+ (`[[1], [2]].freeze` still raises; audition explains why), and
218
+ call results the magic comment never covers (`X.tr(":", "")`,
219
+ `Regexp.new`, `Regexp.union`, `format`), the shapes Rails fixed
220
+ last in its own ractorization. Honors `# frozen_string_literal:`
221
+ and `# shareable_constant_value:` magic comments.
219
222
  - **Sync primitives and Procs in constants** (Mutex, Queue,
220
223
  lambdas), including `Hash.new { }` default procs, which stay
221
224
  unshareable even after `.freeze`.
@@ -228,7 +231,9 @@ Static, with file:line precision:
228
231
  - **`Ractor.new` blocks capturing outer locals** (the ArgumentError
229
232
  at creation time), resolved through Prism's exact scope depths.
230
233
  - **Hostile or removed APIs**: `Ractor.yield`/`take` (gone in 4.0),
231
- ActiveSupport `class_attribute`/`cattr_*`/`mattr_*`,
234
+ ActiveSupport `cattr_*`/`mattr_*` class variables (with the
235
+ `class_attribute` migration Rails itself made) and
236
+ `class_attribute` without copy-on-write writes,
232
237
  `include Singleton`, `fork`, `ObjectSpace._id2ref`, ENV mutation.
233
238
 
234
239
  Dynamic, on the live object graph:
@@ -312,6 +317,19 @@ The design notes in `docs/design.md` include the empirically
312
317
  verified Ruby 4.0 Ractor semantics table that the checks are
313
318
  calibrated against.
314
319
 
320
+ ## Acknowledgements
321
+
322
+ The whole-program checks stand on
323
+ [rubydex](https://github.com/Shopify/rubydex), Shopify's
324
+ high-performance static analysis suite for Ruby: audition feeds
325
+ every file into its graph and reads state ownership back out.
326
+ Thanks to its authors, in particular the top five contributors:
327
+ [Alexandre Terrasa](https://github.com/Morriar),
328
+ [Vinicius Stock](https://github.com/vinistock),
329
+ [Alex Rocha](https://github.com/alexcrocha),
330
+ [Stan Lo](https://github.com/st0012), and
331
+ [Soutaro Matsumoto](https://github.com/soutaro).
332
+
315
333
  ## Assisted by
316
334
 
317
335
  Claude Fable 5.
@@ -22,6 +22,19 @@ module Audition
22
22
  fix: "Add `# frozen_string_literal: true` to the " \
23
23
  "file, or append `.freeze`."
24
24
 
25
+ explain :mutable_call,
26
+ severity: :error,
27
+ message: "constant %{name} holds an unfrozen %{type} " \
28
+ "returned by %{method}",
29
+ why: "`# frozen_string_literal: true` freezes " \
30
+ "literals only; a method call returns a fresh " \
31
+ "unfrozen object, so a non-main Ractor reading " \
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."
37
+
25
38
  explain :mutable_container,
26
39
  severity: :error,
27
40
  message: "constant %{name} holds a mutable %{type} " \
@@ -56,8 +69,10 @@ module Audition
56
69
  "touching this constant raises " \
57
70
  "Ractor::IsolationError.",
58
71
  fix: "Use Ractor::Port for cross-Ractor " \
59
- "coordination, keep a per-Ractor primitive " \
60
- "via Ractor.store_if_absent, or use " \
72
+ "coordination; keep a per-Ractor primitive " \
73
+ "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 " \
61
76
  "Ractor-safe structures (ractor_safe, ratomic " \
62
77
  "gems)."
63
78
 
@@ -97,7 +112,12 @@ module Audition
97
112
  "freeze it (each_with_object then .freeze), " \
98
113
  "or move the registry behind a writer that " \
99
114
  "rebuilds and refreezes on each change, the " \
100
- "copy-on-write style Rails registries use."
115
+ "copy-on-write style Rails registries use. A " \
116
+ "registry that plugins extend during boot is " \
117
+ "frozen in the last boot hook (after_initialize) " \
118
+ "rather than at definition, and writes after the " \
119
+ "freeze merge into a fresh frozen copy with a " \
120
+ "deprecation instead of raising."
101
121
 
102
122
  on :constant_write_node, :constant_or_write_node do |node|
103
123
  examine(node.name.to_s, node, node.value)
@@ -131,6 +151,10 @@ module Audition
131
151
  when :mutable_string
132
152
  flag(node, :mutable_string, name: name,
133
153
  autofix: fix_ok ? append_freeze(value) : nil)
154
+ when :mutable_call
155
+ flag(node, :mutable_call, name: name,
156
+ type: call_type(value), method: call_display(value),
157
+ autofix: fix_ok ? append_freeze(value) : nil)
134
158
  when :mutable_container
135
159
  flag(node, :mutable_container, name: name,
136
160
  type: container_type(value),
@@ -151,6 +175,7 @@ module Audition
151
175
  # a class or module body get the wrap.
152
176
  body = proc_body(value)
153
177
  wrappable = fix_ok && namespaced?(node) &&
178
+ !opaque_proc?(value) &&
154
179
  (body.nil? ||
155
180
  RactorIsolation::CaptureScanner
156
181
  .scan(body).empty?)
@@ -175,10 +200,16 @@ module Audition
175
200
  def proc_body(value)
176
201
  case value
177
202
  when Prism::LambdaNode then value.body
178
- when Prism::CallNode then value.block&.body
203
+ when Prism::CallNode
204
+ block = value.block
205
+ block.body if block.is_a?(Prism::BlockNode)
179
206
  end
180
207
  end
181
208
 
209
+ def opaque_proc?(value)
210
+ value.is_a?(Prism::CallNode) && value.block.is_a?(Prism::BlockArgumentNode)
211
+ end
212
+
182
213
  def namespaced?(node)
183
214
  offset = node.location.start_offset
184
215
  module_ranges.any? { |range| range.cover?(offset) }
@@ -245,10 +276,36 @@ module Audition
245
276
 
246
277
  # Ternaries classify as strings when both branches are;
247
278
  # `.freeze` binds tighter than `?:`, so they get parens.
279
+ def call_type(call)
280
+ owner = classifier.const_name(call.receiver)
281
+ (owner == "Regexp") ? "Regexp" : "String"
282
+ end
283
+
284
+ def call_display(call)
285
+ return call.name.to_s if call.receiver.nil?
286
+
287
+ owner = classifier.const_name(call.receiver)
288
+ owner ? "#{owner}.#{call.name}" : "String##{call.name}"
289
+ end
290
+
291
+ # `.freeze` binds tighter than an operator: `"a" + "b".freeze`
292
+ # freezes only "b", so operator calls get parentheses while
293
+ # literals and parenthesized or argument-free calls take
294
+ # the bare suffix.
295
+ def bare_freezable?(value)
296
+ case value
297
+ when Prism::StringNode, Prism::InterpolatedStringNode
298
+ true
299
+ when Prism::CallNode
300
+ !value.opening_loc.nil? ||
301
+ (!value.receiver.nil? && value.arguments.nil?)
302
+ else
303
+ false
304
+ end
305
+ end
306
+
248
307
  def append_freeze(value)
249
- string = value.is_a?(Prism::StringNode) ||
250
- value.is_a?(Prism::InterpolatedStringNode)
251
- if string
308
+ if bare_freezable?(value)
252
309
  offset = value.location.end_offset
253
310
  Autofix.new(
254
311
  start_offset: offset,
@@ -20,19 +20,36 @@ module Audition
20
20
  "Ractor, and use port.send/port.receive; " \
21
21
  "collect results with Ractor#value."
22
22
 
23
- explain :rails_class_state_macro,
23
+ explain :rails_class_variable_macro,
24
24
  severity: :error,
25
- message: "%{method} stores state on the class " \
26
- "object",
27
- why: "These ActiveSupport macros are backed by " \
28
- "class-level instance variables or class " \
29
- "variables; both raise Ractor::IsolationError " \
30
- "when written (and class variables even when " \
31
- "read) from a non-main Ractor.",
32
- fix: "Compute the value at boot and store it in a " \
33
- "deeply frozen constant, or keep per-Ractor " \
34
- "state via Ractor.current[:key] / " \
35
- "Ractor.store_if_absent."
25
+ message: "%{method} stores state in a class variable",
26
+ why: "The cattr_* and mattr_* macros define @@ class " \
27
+ "variables; reads and writes alike raise " \
28
+ "Ractor::IsolationError from a non-main Ractor, " \
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 " \
34
+ "and refreeze on write, at boot on the main " \
35
+ "Ractor."
36
+
37
+ explain :rails_class_attribute,
38
+ severity: :warning,
39
+ message: "%{method} stores state on the class object",
40
+ why: "The value lives in a class-level instance " \
41
+ "variable. Rails 8.2 made the reader Ractor-safe, " \
42
+ "so a frozen value is readable from any Ractor; " \
43
+ "earlier readers are define_method closures that " \
44
+ "raise on the first call from a non-main Ractor " \
45
+ "(thread_mattr_accessor memoized its key the " \
46
+ "same way). Writes always need the main Ractor.",
47
+ fix: "Give it a frozen default (default: [].freeze) " \
48
+ "and write copy-on-write: self.x = (x | [v])" \
49
+ ".freeze, the idiom Rails applied across Action " \
50
+ "Pack and Active Record; do every write at boot " \
51
+ "on the main Ractor. The dynamic probe reports " \
52
+ "ground truth for the installed Rails."
36
53
 
37
54
  explain :objectspace_id2ref,
38
55
  severity: :warning,
@@ -91,13 +108,15 @@ module Audition
91
108
  "Ractor raises Ractor::IsolationError " \
92
109
  "(\"defined with an un-shareable Proc\") " \
93
110
  "unless the block was made shareable first.",
94
- fix: "Generate the method with class_eval and a " \
95
- "source string (how Rails fixed its autosave " \
96
- "callbacks), or pass an isolated block: " \
97
- "define_method(:x, " \
98
- "&Ractor.make_shareable(proc { ... })). " \
99
- "Isolated blocks cannot capture outer locals " \
100
- "or use super."
111
+ fix: "Pass a shareable lambda instead of a block: " \
112
+ "define_method(:x, Ractor.shareable_lambda " \
113
+ "{ ... }), as Rails did for its date selectors " \
114
+ "and url helpers. Captured locals must be " \
115
+ "shareable (strings become symbols) and assigned " \
116
+ "before the lambda is created, and super is " \
117
+ "unavailable. When the captures are literals, " \
118
+ "generate the method with class_eval and a " \
119
+ "source string instead."
101
120
 
102
121
  explain :singleton_include,
103
122
  severity: :warning,
@@ -115,10 +134,11 @@ module Audition
115
134
  RULES = Ractor.make_shareable([
116
135
  {key: :ractor_yield_removed, receiver: "Ractor",
117
136
  methods: %i[yield take]},
118
- {key: :rails_class_state_macro, receiver: nil,
119
- methods: %i[class_attribute cattr_accessor cattr_reader
120
- cattr_writer mattr_accessor mattr_reader
121
- mattr_writer thread_mattr_accessor]},
137
+ {key: :rails_class_variable_macro, receiver: nil,
138
+ methods: %i[cattr_accessor cattr_reader cattr_writer
139
+ mattr_accessor mattr_reader mattr_writer]},
140
+ {key: :rails_class_attribute, receiver: nil,
141
+ methods: %i[class_attribute thread_mattr_accessor]},
122
142
  {key: :objectspace_id2ref, receiver: "ObjectSpace",
123
143
  methods: %i[_id2ref]},
124
144
  {key: :at_exit, receiver: nil, methods: %i[at_exit]},
@@ -27,14 +27,18 @@ module Audition
27
27
  "it while it holds a non-shareable value (verified on " \
28
28
  "Ruby 4.0)."
29
29
  STATE_FIX =
30
- "Precompute and freeze the value at load time (for " \
31
- "per-subclass values, in the inherited hook). For " \
30
+ "Precompute and freeze the value at load time: a memo " \
31
+ "that needs no configuration becomes a frozen private " \
32
+ "constant (EMPTY = new(nil, nil).freeze), a cheap " \
33
+ "derivation drops its memo altogether, and " \
34
+ "per-subclass values compute in the inherited hook " \
35
+ "(guard on subclass.name for anonymous classes). For " \
32
36
  "collections, rebuild and refreeze on write, " \
33
37
  "Rails-style copy-on-write: self.list = " \
34
38
  "(list + [item]).freeze; never mutate in place. As a " \
35
- "last resort use Ractor.store_if_absent for lazy " \
36
- "initialization or per-Ractor state in " \
37
- "Ractor.current[:key]."
39
+ "last resort use Ractor.store_if_absent for " \
40
+ "per-Ractor state, or read the ivar first and proxy " \
41
+ "the write to the main Ractor."
38
42
  FROZEN_MEMO_WHY =
39
43
  "Every write memoizes a shareable (frozen) value, so " \
40
44
  "non-main Ractors can read it once it has been " \
@@ -44,10 +48,13 @@ module Audition
44
48
  "memoized class state."
45
49
  FROZEN_MEMO_FIX =
46
50
  "Warm the cache at boot, before spawning Ractors: call " \
47
- "the memoizing method from an initializer or on_load " \
48
- "hook. If the value genuinely must be computed at " \
49
- "runtime, proxy the write to the main Ractor or use " \
50
- "Ractor.store_if_absent."
51
+ "the memoizing method from an initializer, an on_load " \
52
+ "hook, an eager_load! override, or the inherited hook. " \
53
+ "A value that can be nil or false never sticks under " \
54
+ "||=, so guard it with defined? instead. If the value " \
55
+ "genuinely must be computed at runtime, read the ivar " \
56
+ "first and proxy only the write to the main Ractor, or " \
57
+ "use Ractor.store_if_absent."
51
58
  BEST_EFFORT_WHY =
52
59
  "Writes wrap their value in Ractor.make_shareable with " \
53
60
  "a rescue fallback: shareable values are deeply frozen " \
@@ -8,6 +8,8 @@ module Audition
8
8
  # :shareable proven deeply shareable
9
9
  # :mutable_string unfrozen String literal
10
10
  # :mutable_container Array/Hash literal or constructor
11
+ # :mutable_call unfrozen String or Regexp returned by
12
+ # a call (`.tr`, `format`, `Regexp.new`)
11
13
  # :shallow_freeze frozen container with mutable elements
12
14
  # :sync_primitive Mutex/Queue/... constructor
13
15
  # :proc lambda or proc
@@ -22,6 +24,24 @@ module Audition
22
24
  ].freeze
23
25
  SHAREABLE_FACTORIES = %w[Struct Class Module].freeze
24
26
 
27
+ # Calls returning a fresh, unfrozen String or Regexp;
28
+ # `# frozen_string_literal: true` covers literals only.
29
+ # Rails hit both shapes (`.tr` and `Regexp.new`) in
30
+ # constants during its ractorization. These names belong
31
+ # to String alone in core, so any receiver qualifies.
32
+ STRING_ONLY_METHODS = %i[
33
+ tr tr_s gsub sub squeeze strip lstrip rstrip chomp chop
34
+ center ljust rjust encode scrub unicode_normalize
35
+ ].freeze
36
+ # Unambiguous only on a String literal receiver: Symbols
37
+ # and numbers define these too and return shareable values.
38
+ STRING_LITERAL_METHODS = %i[
39
+ + * % upcase downcase capitalize swapcase reverse dup
40
+ succ next
41
+ ].freeze
42
+ FORMATTERS = %i[format sprintf].freeze
43
+ REGEXP_FACTORIES = %i[new union compile].freeze
44
+
25
45
  # @param frozen_string_literal [Boolean] whether the file has
26
46
  # the frozen_string_literal magic comment
27
47
  def initialize(frozen_string_literal:)
@@ -92,6 +112,9 @@ module Audition
92
112
  end
93
113
 
94
114
  def classify_call(node)
115
+ return :mutable_call if fresh_string?(node) ||
116
+ fresh_regexp?(node)
117
+
95
118
  receiver = node.receiver
96
119
  case node.name
97
120
  when :freeze
@@ -130,13 +153,42 @@ module Audition
130
153
  when Prism::ArrayNode, Prism::HashNode
131
154
  deep_classify(receiver.elements)
132
155
  when Prism::CallNode
133
- # A default proc survives freezing the Hash.
134
- (classify(receiver) == :default_proc) ? :default_proc : :unknown
156
+ # A default proc survives freezing the Hash; a frozen
157
+ # String or Regexp from a call is deeply shareable.
158
+ case classify(receiver)
159
+ when :default_proc then :default_proc
160
+ when :mutable_call then :shareable
161
+ else :unknown
162
+ end
135
163
  else
136
164
  :unknown
137
165
  end
138
166
  end
139
167
 
168
+ def fresh_string?(node)
169
+ receiver = node.receiver
170
+ name = node.name
171
+ case receiver
172
+ when nil
173
+ FORMATTERS.include?(name)
174
+ when Prism::StringNode, Prism::InterpolatedStringNode
175
+ STRING_ONLY_METHODS.include?(name) ||
176
+ STRING_LITERAL_METHODS.include?(name)
177
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
178
+ owner = const_name(receiver)
179
+ (name == :new && owner == "String") ||
180
+ (owner == "Kernel" && FORMATTERS.include?(name)) ||
181
+ STRING_ONLY_METHODS.include?(name)
182
+ else
183
+ false
184
+ end
185
+ end
186
+
187
+ def fresh_regexp?(node)
188
+ REGEXP_FACTORIES.include?(node.name) &&
189
+ const_name(node.receiver) == "Regexp"
190
+ end
191
+
140
192
  # A container holding a sync primitive can never become
141
193
  # shareable; Ractor.make_shareable raises on it (multi_json
142
194
  # keeps a frozen Hash of Mutexes). The classification
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Audition
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: audition
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Markin