audition 0.2.4 → 0.4.0

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.
@@ -27,10 +27,12 @@ module Audition
27
27
  "variables; reads and writes alike raise " \
28
28
  "Ractor::IsolationError from a non-main Ractor, " \
29
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 " \
30
+ fix: "Move the state to class_attribute (a " \
31
+ "class-level ivar whose frozen value any Ractor " \
32
+ "may read) or, for plain settings, to " \
33
+ "singleton_class.attr_accessor plus " \
34
+ "delegate(..., to: TheModule) for the instance " \
35
+ "readers; give it a frozen default and rebuild " \
34
36
  "and refreeze on write, at boot on the main " \
35
37
  "Ractor."
36
38
 
@@ -110,8 +112,7 @@ module Audition
110
112
  "unless the block was made shareable first.",
111
113
  fix: "Pass a shareable lambda instead of a block: " \
112
114
  "define_method(:x, Ractor.shareable_lambda " \
113
- "{ ... }), as Rails did for its date selectors " \
114
- "and url helpers. Captured locals must be " \
115
+ "{ ... }). Captured locals must be " \
115
116
  "shareable (strings become symbols) and assigned " \
116
117
  "before the lambda is created, and super is " \
117
118
  "unavailable. When the captures are literals, " \
@@ -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,21 @@
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"
5
6
  require_relative "checks/mutable_constants"
6
7
  require_relative "checks/ractor_isolation"
7
8
  require_relative "checks/runtime_require"
8
9
  require_relative "checks/unsafe_calls"
10
+ require_relative "checks/unshareable_reads"
9
11
 
10
12
  module Audition
11
13
  module Static
12
14
  module Checks
13
15
  BUILT_IN = [
14
- GlobalVariables, MutableConstants, RactorIsolation,
15
- RuntimeRequire, UnsafeCalls
16
+ DependencyClassState, GlobalVariables, MutableConstants,
17
+ RactorIsolation, RuntimeRequire, UnsafeCalls,
18
+ UnshareableReads
16
19
  ].freeze
17
20
 
18
21
  # Expression-level checks, run per file. Class variables and