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.
- checksums.yaml +4 -4
- data/README.md +125 -49
- data/lib/audition/bundle_sweep.rb +25 -15
- data/lib/audition/cli.rb +128 -140
- data/lib/audition/config.rb +30 -4
- data/lib/audition/dynamic/harness.rb +415 -41
- data/lib/audition/dynamic/prober.rb +271 -54
- data/lib/audition/finding.rb +14 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/reconciliation.rb +86 -0
- data/lib/audition/report/github.rb +4 -3
- data/lib/audition/report/json.rb +5 -1
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +13 -2
- data/lib/audition/report.rb +8 -1
- data/lib/audition/rewriters.rb +29 -2
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/instance_memoization.rb +226 -0
- data/lib/audition/static/checks/mutable_constants.rb +172 -9
- data/lib/audition/static/checks/ractor_isolation.rb +214 -15
- data/lib/audition/static/checks/unsafe_calls.rb +7 -4
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +6 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1113 -15
- data/lib/audition/static/literal_classifier.rb +385 -20
- data/lib/audition/static/native_extensions.rb +28 -16
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +82 -13
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +3 -0
- metadata +12 -4
|
@@ -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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
41
|
-
|
|
161
|
+
if node.name == :new && ractor_receiver?(node.receiver)
|
|
162
|
+
names = CaptureScanner.scan(block.body)
|
|
163
|
+
return if names.empty?
|
|
42
164
|
|
|
43
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
255
|
+
scanner.captures.uniq(&:first)
|
|
59
256
|
end
|
|
60
257
|
|
|
61
|
-
attr_reader :
|
|
258
|
+
attr_reader :captures
|
|
62
259
|
|
|
63
260
|
def initialize
|
|
64
|
-
@
|
|
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
|
-
|
|
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
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
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
|
-
|
|
15
|
-
RuntimeRequire,
|
|
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
|