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.
@@ -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,
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ module Static
5
+ module Checks
6
+ # Constants known to hold unshareable objects. Their
7
+ # definitions live outside the scanned tree (or are swept
8
+ # from it at setup), so the read site is the only place a
9
+ # static pass can flag. Learned type aliases match only in
10
+ # Sorbet type positions, where a constant can only name a
11
+ # type—a class sharing a name stays quiet. Namespaces that
12
+ # build constants with const_set at boot get their constant
13
+ # reads flagged too: the values never pass through a literal
14
+ # the analyzer could classify.
15
+ class UnshareableReads < Base
16
+ explain :sorbet_type_alias,
17
+ severity: :warning,
18
+ message: "read of %{name}, a Sorbet type alias that " \
19
+ "is not Ractor-shareable",
20
+ why: "T.type_alias wraps its block in an unfrozen " \
21
+ "T::Private::Types::TypeAlias that also memoizes " \
22
+ "on first use, so a non-main Ractor evaluating " \
23
+ "the reference raises Ractor::IsolationError. " \
24
+ "sig blocks evaluate lazily, on the first call " \
25
+ "of the method they annotate, so the raise can " \
26
+ "surface there too.",
27
+ fix: "Evaluate sigs at boot on the main Ractor " \
28
+ "(T::Utils.run_all_sig_blocks) and avoid runtime " \
29
+ "T.let casts against the alias on Ractor code " \
30
+ "paths, or rebind the constant to a deeply " \
31
+ "shareable equivalent at boot."
32
+
33
+ explain :dynamic_constant,
34
+ severity: :warning,
35
+ message: "read of %{name}, a constant its namespace " \
36
+ "defines dynamically at boot",
37
+ why: "The owning namespace binds this constant with " \
38
+ "const_set from runtime data, so no literal ever " \
39
+ "reaches the analyzer; loaders of this shape " \
40
+ "typically bind unfrozen strings or hashes, which " \
41
+ "a non-main Ractor cannot read from a constant.",
42
+ fix: "Make each value shareable as it is bound " \
43
+ "(Ractor.make_shareable) or hold the data in a " \
44
+ "frozen registry instead of loose constants."
45
+
46
+ KNOWN = Ractor.make_shareable(
47
+ {"T::Boolean" => :sorbet_type_alias}
48
+ )
49
+
50
+ # Stub generators write rbi definitions fully qualified on
51
+ # one line, so a regex sweep is enough there.
52
+ ALIAS_DEFINITION =
53
+ /^\s*((?:[A-Z]\w*::)*[A-Z]\w*)\s*=\s*T\.type_alias\b/
54
+
55
+ VALUE_NAME = /\A[A-Z][A-Z0-9_]*\z/
56
+
57
+ class << self
58
+ attr_reader :learned, :dynamic
59
+
60
+ # Both hold names as segment arrays, written eagerly and
61
+ # kept shareable so parallel scans read them from
62
+ # non-main Ractors.
63
+ def learned=(names)
64
+ @learned = Ractor.make_shareable( # audition:disable
65
+ names.uniq.group_by(&:last)
66
+ )
67
+ end
68
+
69
+ def dynamic=(names)
70
+ @dynamic = Ractor.make_shareable( # audition:disable
71
+ names.uniq
72
+ )
73
+ end
74
+
75
+ # Sweeps the tree for type-alias assignments and for
76
+ # namespaces that const_set under a computed name.
77
+ # Ruby sources are parsed so definitions keep their
78
+ # nesting; rbi files are line-scanned.
79
+ def learn(paths, progress: Progress::SILENT)
80
+ aliases = []
81
+ owners = []
82
+ paths.each do |path|
83
+ progress.tick
84
+ source = begin
85
+ File.read(path)
86
+ rescue SystemCallError
87
+ next
88
+ end
89
+ if path.end_with?(".rbi")
90
+ source.scan(ALIAS_DEFINITION) do |(name)|
91
+ aliases << name.split("::")
92
+ end
93
+ else
94
+ result = Prism.parse(source)
95
+ next unless result.success?
96
+
97
+ sweep(result.value, [], aliases, owners)
98
+ end
99
+ end
100
+ self.learned = aliases
101
+ self.dynamic = owners
102
+ end
103
+
104
+ private
105
+
106
+ def sweep(node, nesting, aliases, owners)
107
+ case node
108
+ when Prism::ClassNode, Prism::ModuleNode
109
+ nesting = [*nesting, *segments(node.constant_path)]
110
+ when Prism::ConstantWriteNode
111
+ if alias_value?(node.value)
112
+ aliases << [*nesting, node.name.to_s]
113
+ end
114
+ when Prism::ConstantPathWriteNode
115
+ if alias_value?(node.value)
116
+ aliases << [*nesting, *segments(node.target)]
117
+ end
118
+ when Prism::CallNode
119
+ if dynamic_definer?(node) &&
120
+ (owner = owner_of(node, nesting))
121
+ owners << owner
122
+ end
123
+ end
124
+ node.compact_child_nodes.each do |child|
125
+ sweep(child, nesting, aliases, owners)
126
+ end
127
+ end
128
+
129
+ def segments(node)
130
+ node.location.slice.delete_prefix("::").split("::")
131
+ end
132
+
133
+ def alias_value?(value)
134
+ value.is_a?(Prism::CallNode) &&
135
+ value.name == :type_alias &&
136
+ value.receiver&.location&.slice
137
+ &.delete_prefix("::") == "T"
138
+ end
139
+
140
+ # A literal name would be classifiable on its own; the
141
+ # loader shape worth learning computes the name.
142
+ def dynamic_definer?(node)
143
+ return false unless node.name == :const_set
144
+
145
+ name = node.arguments&.arguments&.first
146
+ !(name.nil? ||
147
+ name.is_a?(Prism::SymbolNode) ||
148
+ name.is_a?(Prism::StringNode))
149
+ end
150
+
151
+ def owner_of(node, nesting)
152
+ owner = case node.receiver
153
+ when nil, Prism::SelfNode
154
+ nesting
155
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
156
+ segments(node.receiver)
157
+ end
158
+ owner unless owner.nil? || owner.empty?
159
+ end
160
+ end
161
+
162
+ self.learned = []
163
+ self.dynamic = []
164
+
165
+ on :call_node do |node|
166
+ note_type_position(node)
167
+ end
168
+
169
+ on :constant_path_node, :constant_read_node do |node|
170
+ examine(node)
171
+ end
172
+
173
+ def initialize(file)
174
+ super
175
+ @typed = Set.new
176
+ end
177
+
178
+ private
179
+
180
+ def examine(node)
181
+ name = node.location.slice.delete_prefix("::")
182
+ if (key = KNOWN[name])
183
+ flag(node, key, name: name)
184
+ elsif @typed.include?(node.object_id) && alias_read?(name)
185
+ flag(node, :sorbet_type_alias, name: name)
186
+ elsif dynamic_read?(name)
187
+ flag(node, :dynamic_constant, name: name)
188
+ end
189
+ end
190
+
191
+ # The read has to line up with a definition's tail; a
192
+ # definition swept without nesting only pins its own name,
193
+ # a qualified one pins the namespace too.
194
+ def tail_match?(read, full)
195
+ overlap = [read.size, full.size].min
196
+ full.last(overlap) == read.last(overlap)
197
+ end
198
+
199
+ def alias_read?(name)
200
+ segments = name.split("::")
201
+ self.class.learned[segments.last]&.any? do |full|
202
+ tail_match?(segments, full)
203
+ end
204
+ end
205
+
206
+ # Only SCREAMING_CASE reads count: a loader binds values,
207
+ # and classes nested under the namespace stay quiet.
208
+ def dynamic_read?(name)
209
+ segments = name.split("::")
210
+ return false if segments.size < 2 ||
211
+ !segments.last.match?(VALUE_NAME)
212
+
213
+ parent = segments[0..-2]
214
+ self.class.dynamic.any? do |owner|
215
+ tail_match?(parent, owner)
216
+ end
217
+ end
218
+
219
+ # Constants under a sig block or a T type argument name
220
+ # types, so learned aliases may match there.
221
+ def note_type_position(node)
222
+ if node.name == :sig
223
+ mark(node.block)
224
+ elsif t_receiver?(node)
225
+ case node.name
226
+ when :let, :cast, :assert_type!
227
+ mark(node.arguments&.arguments&.dig(1))
228
+ when :nilable, :any, :all, :class_of, :type_alias
229
+ node.arguments&.arguments&.each { |arg| mark(arg) }
230
+ mark(node.block)
231
+ end
232
+ end
233
+ end
234
+
235
+ def t_receiver?(node)
236
+ receiver = node.receiver
237
+ case receiver
238
+ when Prism::ConstantReadNode
239
+ receiver.name == :T
240
+ when Prism::ConstantPathNode
241
+ receiver.location.slice.delete_prefix("::") == "T"
242
+ else
243
+ false
244
+ end
245
+ end
246
+
247
+ def mark(node)
248
+ return if node.nil?
249
+
250
+ case node
251
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
252
+ return @typed << node.object_id
253
+ end
254
+ node.each_child_node { |child| mark(child) }
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
@@ -1,18 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "checks/base"
4
+ require_relative "checks/dependency_class_state"
4
5
  require_relative "checks/global_variables"
6
+ require_relative "checks/instance_memoization"
5
7
  require_relative "checks/mutable_constants"
6
8
  require_relative "checks/ractor_isolation"
7
9
  require_relative "checks/runtime_require"
8
10
  require_relative "checks/unsafe_calls"
11
+ require_relative "checks/unshareable_reads"
9
12
 
10
13
  module Audition
11
14
  module Static
12
15
  module Checks
13
16
  BUILT_IN = [
14
- GlobalVariables, MutableConstants, RactorIsolation,
15
- RuntimeRequire, UnsafeCalls
17
+ DependencyClassState, GlobalVariables, InstanceMemoization,
18
+ MutableConstants, RactorIsolation, RuntimeRequire,
19
+ UnsafeCalls, UnshareableReads
16
20
  ].freeze
17
21
 
18
22
  # Expression-level checks, run per file. Class variables and