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.
- checksums.yaml +4 -4
- data/README.md +175 -39
- data/lib/audition/bundle_sweep.rb +32 -15
- data/lib/audition/cli.rb +181 -100
- data/lib/audition/config.rb +33 -7
- data/lib/audition/dynamic/harness.rb +323 -31
- data/lib/audition/dynamic/prober.rb +190 -43
- data/lib/audition/finding.rb +10 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/report/github.rb +69 -0
- data/lib/audition/report/json.rb +68 -0
- data/lib/audition/report/style.rb +74 -0
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +160 -0
- data/lib/audition/report.rb +16 -295
- data/lib/audition/rewriters.rb +8 -5
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/mutable_constants.rb +162 -28
- data/lib/audition/static/checks/runtime_require.rb +4 -1
- data/lib/audition/static/checks/unsafe_calls.rb +7 -6
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +5 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1053 -12
- data/lib/audition/static/literal_classifier.rb +191 -20
- data/lib/audition/static/native_extensions.rb +175 -0
- data/lib/audition/static/source_file.rb +70 -0
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +147 -14
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +3 -0
- metadata +15 -4
|
@@ -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: "
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
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
|
-
"{ ... })
|
|
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,
|
|
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
|