audition 0.1.0 → 0.2.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rubydex"
4
+ require_relative "../rewriters"
4
5
 
5
6
  module Audition
6
7
  module Static
@@ -26,9 +27,39 @@ module Audition
26
27
  "it while it holds a non-shareable value (verified on " \
27
28
  "Ruby 4.0)."
28
29
  STATE_FIX =
29
- "Precompute and freeze the value at load time, use " \
30
- "Ractor.store_if_absent for lazy initialization, or keep " \
31
- "per-Ractor state in Ractor.current[:key]."
30
+ "Precompute and freeze the value at load time (for " \
31
+ "per-subclass values, in the inherited hook). For " \
32
+ "collections, rebuild and refreeze on write, " \
33
+ "Rails-style copy-on-write: self.list = " \
34
+ "(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]."
38
+ FROZEN_MEMO_WHY =
39
+ "Every write memoizes a shareable (frozen) value, so " \
40
+ "non-main Ractors can read it once it has been " \
41
+ "computed; only the first write must happen on the " \
42
+ "main Ractor, or it raises Ractor::IsolationError. " \
43
+ "This is the pattern Rails core uses for its own " \
44
+ "memoized class state."
45
+ FROZEN_MEMO_FIX =
46
+ "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
+ BEST_EFFORT_WHY =
52
+ "Writes wrap their value in Ractor.make_shareable with " \
53
+ "a rescue fallback: shareable values are deeply frozen " \
54
+ "and readable from any Ractor, while unshareable values " \
55
+ "keep their old (Ractor-hostile) behavior. Whether this " \
56
+ "state is actually safe depends on what the application " \
57
+ "assigns; the dynamic probe reports ground truth."
58
+ BEST_EFFORT_FIX =
59
+ "Assign only shareable values (strings, symbols, frozen " \
60
+ "containers) before spawning Ractors. Configuration " \
61
+ "that cannot be shareable needs per-Ractor state or a " \
62
+ "main-Ractor proxy instead."
32
63
 
33
64
  # @param sources [Hash{String => String}] path => source
34
65
  # @return [Array<Finding>]
@@ -38,6 +69,7 @@ module Audition
38
69
  graph.index_source(path, code, "ruby")
39
70
  end
40
71
  @sources = sources
72
+ @frozen_memos = frozen_memo_map(sources)
41
73
  audit(graph)
42
74
  end
43
75
 
@@ -91,15 +123,39 @@ module Audition
91
123
 
92
124
  variable = decl.name.split("#").last
93
125
  owner = display_owner(decl.owner)
126
+ verdict = @frozen_memos["#{owner}/#{variable}"]
94
127
  each_local_definition(decl).map do |defn|
95
- finding_at(
96
- defn,
97
- check: "class-level-state",
98
- message: "class-level instance variable #{variable} " \
99
- "on #{owner}",
100
- why: STATE_WHY,
101
- fix: STATE_FIX
102
- )
128
+ case verdict
129
+ when :frozen
130
+ finding_at(
131
+ defn,
132
+ check: "class-level-state",
133
+ severity: :info,
134
+ message: "frozen memoization #{variable} on " \
135
+ "#{owner}; warm it on the main Ractor",
136
+ why: FROZEN_MEMO_WHY,
137
+ fix: FROZEN_MEMO_FIX
138
+ )
139
+ when :best_effort
140
+ finding_at(
141
+ defn,
142
+ check: "class-level-state",
143
+ severity: :warning,
144
+ message: "best-effort frozen state #{variable} " \
145
+ "on #{owner}",
146
+ why: BEST_EFFORT_WHY,
147
+ fix: BEST_EFFORT_FIX
148
+ )
149
+ else
150
+ finding_at(
151
+ defn,
152
+ check: "class-level-state",
153
+ message: "class-level instance variable " \
154
+ "#{variable} on #{owner}",
155
+ why: STATE_WHY,
156
+ fix: STATE_FIX
157
+ )
158
+ end
103
159
  end
104
160
  end
105
161
 
@@ -109,12 +165,13 @@ module Audition
109
165
  end
110
166
  end
111
167
 
112
- def finding_at(defn, check:, message:, why:, fix:)
168
+ def finding_at(defn, check:, message:, why:, fix:,
169
+ severity: :error)
113
170
  path = path_from_uri(defn.location.uri)
114
171
  line = defn.location.start_line + 1
115
172
  Finding.new(
116
173
  check: check,
117
- severity: :error,
174
+ severity: severity,
118
175
  message: message,
119
176
  why: why,
120
177
  fix: fix,
@@ -124,9 +181,180 @@ module Audition
124
181
  )
125
182
  end
126
183
 
184
+ # Frozen memoization, the shape Rails core ships: every
185
+ # write to the ivar is a memo site (`@x ||=` or a defined?
186
+ # guard) whose value is provably shareable, either a frozen
187
+ # literal, an explicit `.freeze` or make_shareable call.
188
+ # Such state is read-safe from any Ractor once warmed, so
189
+ # the finding downgrades to an info note. Any stray write
190
+ # or unproven value keeps the error. Keys are
191
+ # "Owner::Path/@name"; a dirty verdict in any file wins.
192
+ def frozen_memo_map(sources)
193
+ map = {}
194
+ sources.each do |path, code|
195
+ file = SourceFile.new(source: code, path: path)
196
+ next unless file.valid_syntax?
197
+
198
+ collector = Rewriters::Memoization::SingletonIvars.new
199
+ collector.visit(file.root)
200
+ classifier = LiteralClassifier.new(
201
+ frozen_string_literal: file.frozen_string_literal?
202
+ )
203
+ collector.groups.each do |(namespace, name), ops|
204
+ key = "#{namespace}/#{name}"
205
+ verdict = group_verdict(ops, classifier)
206
+ map[key] = weaker_verdict(map[key], verdict)
207
+ end
208
+ mark_singleton_reopenings(file, map)
209
+ end
210
+ map
211
+ end
212
+
213
+ # `class << Foo` bodies write ivars on Foo's singleton
214
+ # class outside the collector's `class << self` tracking;
215
+ # any such write taints the group so a reopening in one
216
+ # file can never be shadowed by a clean memo in another.
217
+ def mark_singleton_reopenings(file, map)
218
+ queue = [file.root]
219
+ until queue.empty?
220
+ node = queue.shift
221
+ queue.concat(node.child_nodes.compact)
222
+ next unless node.is_a?(Prism::SingletonClassNode)
223
+
224
+ owner =
225
+ case node.expression
226
+ when Prism::ConstantReadNode
227
+ node.expression.name.to_s
228
+ when Prism::ConstantPathNode
229
+ node.expression.location.slice
230
+ end
231
+ next unless owner
232
+
233
+ ivar_names_in(node).each do |ivar|
234
+ map["#{owner}/#{ivar}"] = :dirty
235
+ end
236
+ end
237
+ end
238
+
239
+ IVAR_NODES = [
240
+ Prism::InstanceVariableReadNode,
241
+ Prism::InstanceVariableWriteNode,
242
+ Prism::InstanceVariableOrWriteNode,
243
+ Prism::InstanceVariableOperatorWriteNode,
244
+ Prism::InstanceVariableAndWriteNode,
245
+ Prism::InstanceVariableTargetNode
246
+ ].freeze
247
+
248
+ def ivar_names_in(node)
249
+ names = []
250
+ queue = [node]
251
+ until queue.empty?
252
+ current = queue.shift
253
+ queue.concat(current.child_nodes.compact)
254
+ if IVAR_NODES.any? { |type| current.is_a?(type) }
255
+ names << current.name.to_s
256
+ end
257
+ end
258
+ names.uniq
259
+ end
260
+
261
+ # Cross-file merge keeps the weakest promise: any dirty file
262
+ # taints the group, and best-effort beats fully frozen.
263
+ VERDICT_RANK = {dirty: 0, best_effort: 1, frozen: 2}.freeze
264
+
265
+ def weaker_verdict(existing, verdict)
266
+ return verdict unless existing
267
+
268
+ [existing, verdict].min_by { |v| VERDICT_RANK[v] }
269
+ end
270
+
271
+ def group_verdict(ops, classifier)
272
+ return :dirty if ops.any? { |op| op[:body] }
273
+ return :dirty if ops.any? { |op| op[:kind] == :other }
274
+
275
+ memos = Rewriters::Memoization.memo_sites(ops)
276
+ if memos.any?
277
+ return group_frozen?(ops, classifier) ? :frozen : :dirty
278
+ end
279
+
280
+ writes = ops.select { |op| op[:kind] == :write }
281
+ return :dirty if writes.empty?
282
+
283
+ all_safe = writes.all? do |w|
284
+ value = w[:node].value
285
+ best_effort_value?(value) ||
286
+ Rewriters::Memoization.frozen_call?(value) ||
287
+ classifier.classify(value) == :shareable
288
+ end
289
+ wrapped = writes.any? do |w|
290
+ best_effort_value?(w[:node].value)
291
+ end
292
+ (all_safe && wrapped) ? :best_effort : :dirty
293
+ end
294
+
295
+ # Matches the emitted setter recipe:
296
+ # (Ractor.make_shareable(value) rescue value)
297
+ def best_effort_value?(node)
298
+ inner = node
299
+ if inner.is_a?(Prism::ParenthesesNode)
300
+ body = inner.body&.body
301
+ return false unless body && body.size == 1
302
+
303
+ inner = body[0]
304
+ end
305
+ return false unless inner.is_a?(Prism::RescueModifierNode)
306
+
307
+ call = inner.expression
308
+ call.is_a?(Prism::CallNode) &&
309
+ call.name == :make_shareable &&
310
+ call.receiver.is_a?(Prism::ConstantReadNode) &&
311
+ call.receiver.name == :Ractor
312
+ end
313
+
314
+ def group_frozen?(ops, classifier)
315
+ return false if ops.any? { |op| op[:body] }
316
+ return false if ops.any? { |op| op[:kind] == :other }
317
+
318
+ memos = Rewriters::Memoization.memo_sites(ops)
319
+ return false if memos.empty?
320
+ return false if
321
+ Rewriters::Memoization.orphan_guards?(ops, memos)
322
+
323
+ memo_ops = memos.map { |memo| memo[:op] }
324
+ writes = ops.select { |op| op[:kind] == :write }
325
+ return false unless (writes - memo_ops).empty?
326
+
327
+ memos.all? do |memo|
328
+ value = memo[:op][:node].value
329
+ frozen_memo_value?(value, classifier)
330
+ end
331
+ end
332
+
333
+ # A bare `.freeze` on a container literal is shallow: the
334
+ # elements stay mutable and the cross-Ractor read still
335
+ # raises, so it must not count as frozen. A `.freeze` on a
336
+ # call result is accepted as the memo recipe (the dynamic
337
+ # probe verifies the value); provably shareable values pass.
338
+ def frozen_memo_value?(value, classifier)
339
+ return true if classifier.classify(value) == :shareable
340
+ return false unless Rewriters::Memoization.frozen_call?(value)
341
+
342
+ case value.receiver
343
+ when Prism::ArrayNode, Prism::HashNode,
344
+ Prism::KeywordHashNode
345
+ false
346
+ else
347
+ true
348
+ end
349
+ end
350
+
127
351
  # "Payments::<Payments>" reads as noise; show "Payments".
352
+ # Nested singleton owners produce nested angle brackets, so
353
+ # the strip repeats until the tail is gone.
128
354
  def display_owner(owner)
129
- (owner&.name || "?").sub(/::<[^>]+>\z/, "")
355
+ name = owner&.name || "?"
356
+ name = name.sub(/::<.*>\z/m, "") while name.match?(/::<.*>\z/m)
357
+ name
130
358
  end
131
359
 
132
360
  def path_from_uri(uri)
@@ -7,10 +7,12 @@ module Audition
7
7
  # Classifies a Prism expression node by Ractor shareability:
8
8
  # :shareable proven deeply shareable
9
9
  # :mutable_string unfrozen String literal
10
- # :mutable_container Array/Hash literal
10
+ # :mutable_container Array/Hash literal or constructor
11
11
  # :shallow_freeze frozen container with mutable elements
12
12
  # :sync_primitive Mutex/Queue/... constructor
13
13
  # :proc lambda or proc
14
+ # :default_proc Hash.new with a block; the block
15
+ # survives .freeze and stays unshareable
14
16
  # :unknown cannot tell statically
15
17
  class LiteralClassifier
16
18
  SYNC_PRIMITIVES = %w[
@@ -43,7 +45,7 @@ module Audition
43
45
  classify_interpolated_string(node)
44
46
  when Prism::ArrayNode, Prism::HashNode,
45
47
  Prism::KeywordHashNode
46
- :mutable_container
48
+ container_kind(node)
47
49
  when Prism::RangeNode
48
50
  ends = [node.left, node.right].compact
49
51
  if ends.all? { |n| classify(n) == :shareable }
@@ -55,15 +57,21 @@ module Audition
55
57
  :proc
56
58
  when Prism::CallNode
57
59
  classify_call(node)
60
+ when Prism::IfNode
61
+ ternary_kind(node)
58
62
  else
59
63
  :unknown
60
64
  end
61
65
  end
62
66
 
67
+ # `::Mutex` and `Mutex` are the same constant for matching
68
+ # purposes; the leading colons are stripped.
63
69
  def const_name(node)
64
70
  case node
65
- when Prism::ConstantReadNode then node.name.to_s
66
- when Prism::ConstantPathNode then node.location.slice
71
+ when Prism::ConstantReadNode
72
+ node.name.to_s
73
+ when Prism::ConstantPathNode
74
+ node.location.slice.delete_prefix("::")
67
75
  end
68
76
  end
69
77
 
@@ -92,6 +100,14 @@ module Audition
92
100
  name = const_name(receiver)
93
101
  return :sync_primitive if SYNC_PRIMITIVES.include?(name)
94
102
  return :shareable if SHAREABLE_FACTORIES.include?(name)
103
+ return :proc if name == "Proc" && node.block
104
+ # Hash.new retains its block as the default proc;
105
+ # Array.new only uses its block to build elements.
106
+ return :default_proc if name == "Hash" && node.block
107
+
108
+ if %w[Hash Array].include?(name)
109
+ return :mutable_container
110
+ end
95
111
 
96
112
  :unknown
97
113
  when :define
@@ -113,26 +129,74 @@ module Audition
113
129
  :shareable
114
130
  when Prism::ArrayNode, Prism::HashNode
115
131
  deep_classify(receiver.elements)
132
+ when Prism::CallNode
133
+ # A default proc survives freezing the Hash.
134
+ (classify(receiver) == :default_proc) ? :default_proc : :unknown
135
+ else
136
+ :unknown
137
+ end
138
+ end
139
+
140
+ # A container holding a sync primitive can never become
141
+ # shareable; Ractor.make_shareable raises on it (multi_json
142
+ # keeps a frozen Hash of Mutexes). The classification
143
+ # propagates so no freeze or wrap is ever suggested.
144
+ def container_kind(node)
145
+ sync = node.elements.any? do |element|
146
+ element_children(element).any? do |child|
147
+ classify(child) == :sync_primitive
148
+ end
149
+ end
150
+ sync ? :sync_primitive : :mutable_container
151
+ end
152
+
153
+ def element_children(element)
154
+ case element
155
+ when Prism::AssocNode then [element.key, element.value]
156
+ else [element]
157
+ end
158
+ end
159
+
160
+ # A ternary of provable branches classifies as the worst
161
+ # branch: two string literals make a string, so a plain
162
+ # `.freeze` stays available for `cond ? ";" : ":"`.
163
+ def ternary_kind(node)
164
+ return :unknown unless node.subsequent
165
+ .is_a?(Prism::ElseNode)
166
+
167
+ branches = [
168
+ single_statement(node.statements),
169
+ single_statement(node.subsequent.statements)
170
+ ]
171
+ return :unknown unless branches.all?
172
+
173
+ kinds = branches.map { |branch| classify(branch) }
174
+ return :shareable if kinds.all?(:shareable)
175
+
176
+ if kinds.all? { |k| %i[shareable mutable_string].include?(k) }
177
+ :mutable_string
116
178
  else
117
179
  :unknown
118
180
  end
119
181
  end
120
182
 
183
+ def single_statement(statements)
184
+ body = statements&.body
185
+ body && body.size == 1 && body[0]
186
+ end
187
+
121
188
  # Fold element classifications: everything provably shareable
122
189
  # gives :shareable; anything provably mutable gives
123
- # :shallow_freeze; anything unknowable gives :unknown (stay
124
- # silent rather than guess).
190
+ # :shallow_freeze; a sync primitive poisons the whole
191
+ # container; anything unknowable gives :unknown (stay silent
192
+ # rather than guess).
125
193
  def deep_classify(elements)
126
194
  verdict = :shareable
127
195
  elements.each do |element|
128
- children =
129
- case element
130
- when Prism::AssocNode then [element.key, element.value]
131
- else [element]
132
- end
133
- children.each do |child|
196
+ element_children(element).each do |child|
134
197
  case classify(child)
135
198
  when :shareable then nil
199
+ when :sync_primitive then return :sync_primitive
136
200
  when :unknown then return :unknown
137
201
  else verdict = :shallow_freeze
138
202
  end
@@ -25,11 +25,22 @@ module Audition
25
25
 
26
26
  def root = parse_result.value
27
27
 
28
+ # Ruby matches magic-comment keys and values
29
+ # case-insensitively and ignores magic comments that appear
30
+ # after the first statement (verified on 4.0); Prism reports
31
+ # them all, so both rules are applied here.
28
32
  def magic_comment(key)
33
+ limit = first_statement_offset
29
34
  comment = parse_result.magic_comments.find do |mc|
30
- mc.key_loc.slice == key
35
+ mc.key_loc.slice.downcase == key &&
36
+ mc.key_loc.start_offset < limit
31
37
  end
32
- comment&.value_loc&.slice
38
+ comment&.value_loc&.slice&.downcase
39
+ end
40
+
41
+ def first_statement_offset
42
+ first = root.statements.body.first
43
+ first ? first.location.start_offset : source.bytesize
33
44
  end
34
45
 
35
46
  # String literals in this file are frozen (and therefore
@@ -48,7 +59,9 @@ module Audition
48
59
  end
49
60
 
50
61
  def line_at(number)
51
- @lines ||= source.lines
62
+ @lines ||= source.lines.map do |line|
63
+ line.dup.force_encoding(Encoding::UTF_8).scrub
64
+ end
52
65
  @lines[number - 1]&.strip
53
66
  end
54
67
 
@@ -72,25 +85,93 @@ module Audition
72
85
  %i[require require_relative].include?(s.name)
73
86
  end
74
87
  if last_require
75
- newline = source.index("\n", last_require.location.end_offset)
76
- offset = newline ? newline + 1 : source.bytesize
88
+ newline = raw.index("\n", last_require.location.end_offset)
89
+ offset = newline ? newline + 1 : raw.bytesize
77
90
  {offset: offset, after_require: true}
78
91
  else
79
92
  {offset: leading_comments_end, after_require: false}
80
93
  end
81
94
  end
82
95
 
96
+ # Prism reports byte offsets; index math against the source
97
+ # must run on a binary copy or multibyte content shifts
98
+ # every computed position.
99
+ def raw
100
+ @raw ||= source.dup.force_encoding(Encoding::BINARY)
101
+ end
102
+
103
+ # Method names that read as in-place data mutation when
104
+ # sent to a constant. Shared by the mutable-constants check
105
+ # and the fixers: a constant this file mutates is a
106
+ # deliberate accumulator (sinatra's PARAMS_CONFIG) and must
107
+ # never be frozen by magic comment or wrap.
108
+ CONST_MUTATORS = %i[
109
+ []= << push unshift concat merge! replace
110
+ ].freeze
111
+
112
+ # Index writes are their own node types, not calls:
113
+ # `COUNTS[k] += 1` is IndexOperatorWriteNode and
114
+ # `CACHE[k] ||= v` is IndexOrWriteNode; missing them let a
115
+ # `.freeze` autofix produce FrozenError at runtime.
116
+ INDEX_WRITES = [
117
+ Prism::IndexOperatorWriteNode,
118
+ Prism::IndexOrWriteNode,
119
+ Prism::IndexAndWriteNode,
120
+ Prism::IndexTargetNode
121
+ ].freeze
122
+
123
+ # @return [Array<String>] names of constants that receive a
124
+ # mutator call somewhere in this file
125
+ def mutated_constants
126
+ @mutated_constants ||= begin
127
+ names = []
128
+ queue = [root]
129
+ until queue.empty?
130
+ node = queue.shift
131
+ queue.concat(node.child_nodes.compact)
132
+ receiver =
133
+ if node.is_a?(Prism::CallNode) &&
134
+ CONST_MUTATORS.include?(node.name)
135
+ node.receiver
136
+ elsif INDEX_WRITES.any? { |type| node.is_a?(type) }
137
+ node.receiver
138
+ end
139
+
140
+ case receiver
141
+ when Prism::ConstantReadNode
142
+ names << receiver.name.to_s
143
+ when Prism::ConstantPathNode
144
+ names << receiver.location.slice
145
+ end
146
+ end
147
+ names.uniq
148
+ end
149
+ end
150
+
151
+ # Keys Ruby actually honors. Prism reports every comment
152
+ # shaped like `# key: value`, which sweeps up documentation
153
+ # (`# I18n.t: 'date.formats.short'`); inserting after those
154
+ # would land a magic comment mid-file.
155
+ MAGIC_KEYS = %w[
156
+ encoding coding frozen_string_literal
157
+ shareable_constant_value warn_indent
158
+ ].freeze
159
+
83
160
  # Where a new magic comment can go: after the shebang and any
84
161
  # existing magic comments, before code.
85
162
  def magic_insertion_offset
86
163
  offset = 0
87
- if source.start_with?("#!")
88
- newline = source.index("\n")
89
- offset = newline ? newline + 1 : source.bytesize
164
+ if raw.start_with?("#!")
165
+ newline = raw.index("\n")
166
+ offset = newline ? newline + 1 : raw.bytesize
90
167
  end
91
- after_magic = parse_result.magic_comments.map do |mc|
92
- newline = source.index("\n", mc.value_loc.end_offset)
93
- newline ? newline + 1 : source.bytesize
168
+ limit = first_statement_offset
169
+ after_magic = parse_result.magic_comments.filter_map do |mc|
170
+ next unless MAGIC_KEYS.include?(mc.key_loc.slice.downcase)
171
+ next unless mc.key_loc.start_offset < limit
172
+
173
+ newline = raw.index("\n", mc.value_loc.end_offset)
174
+ newline ? newline + 1 : raw.bytesize
94
175
  end.max
95
176
  [offset, after_magic || 0].max
96
177
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Audition
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
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.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Markin
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: '0.1'
60
+ version: '1.0'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
- version: '0.1'
67
+ version: '1.0'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: tty-link
70
70
  requirement: !ruby/object:Gem::Requirement