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
|
@@ -7,14 +7,19 @@ 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 or constructor
|
|
11
|
-
# :mutable_call unfrozen String
|
|
12
|
-
# a call (`.tr`,
|
|
10
|
+
# :mutable_container Array/Hash/Set literal or constructor
|
|
11
|
+
# :mutable_call unfrozen String, Regexp, or sentinel
|
|
12
|
+
# Object returned by a call (`.tr`,
|
|
13
|
+
# `format`, `Regexp.new`, `Object.new`)
|
|
13
14
|
# :shallow_freeze frozen container with mutable elements
|
|
14
15
|
# :sync_primitive Mutex/Queue/... constructor
|
|
15
16
|
# :proc lambda or proc
|
|
16
17
|
# :default_proc Hash.new with a block; the block
|
|
17
18
|
# survives .freeze and stays unshareable
|
|
19
|
+
# :instance_new unfrozen instance of an arbitrary class
|
|
20
|
+
# :opaque_call method call with an unprovable return
|
|
21
|
+
# :shallow_opaque frozen at the top, but what it holds
|
|
22
|
+
# is an unprovable call result
|
|
18
23
|
# :unknown cannot tell statically
|
|
19
24
|
class LiteralClassifier
|
|
20
25
|
SYNC_PRIMITIVES = %w[
|
|
@@ -23,11 +28,20 @@ module Audition
|
|
|
23
28
|
Thread::ConditionVariable
|
|
24
29
|
].freeze
|
|
25
30
|
SHAREABLE_FACTORIES = %w[Struct Class Module].freeze
|
|
31
|
+
# Sentinels: `NOT_GIVEN = Object.new` raises when read from
|
|
32
|
+
# a worker until frozen, and a frozen bare Object is
|
|
33
|
+
# shareable (verified on Ruby 4.0.6). BasicObject has no
|
|
34
|
+
# #freeze.
|
|
35
|
+
SENTINEL_FACTORIES = %w[Object BasicObject].freeze
|
|
36
|
+
# Concurrent::Map defines no #freeze, so make_shareable
|
|
37
|
+
# raises NoMethodError on it; a constant holding one can
|
|
38
|
+
# never be shared (verified on 4.0.6 with concurrent-ruby).
|
|
39
|
+
UNFREEZABLE_COLLECTIONS = %w[Concurrent::Map].freeze
|
|
26
40
|
|
|
27
41
|
# Calls returning a fresh, unfrozen String or Regexp;
|
|
28
42
|
# `# frozen_string_literal: true` covers literals only.
|
|
29
|
-
#
|
|
30
|
-
# constants
|
|
43
|
+
# Both shapes (`.tr` and `Regexp.new`) turn up in
|
|
44
|
+
# constants in the wild. These names belong
|
|
31
45
|
# to String alone in core, so any receiver qualifies.
|
|
32
46
|
STRING_ONLY_METHODS = %i[
|
|
33
47
|
tr tr_s gsub sub squeeze strip lstrip rstrip chomp chop
|
|
@@ -41,6 +55,18 @@ module Audition
|
|
|
41
55
|
].freeze
|
|
42
56
|
FORMATTERS = %i[format sprintf].freeze
|
|
43
57
|
REGEXP_FACTORIES = %i[new union compile].freeze
|
|
58
|
+
# File methods returning a fresh path String.
|
|
59
|
+
FILE_PATH_METHODS = %i[
|
|
60
|
+
expand_path join dirname basename absolute_path realpath
|
|
61
|
+
].freeze
|
|
62
|
+
|
|
63
|
+
# Calls returning a shareable primitive on any receiver.
|
|
64
|
+
SHAREABLE_RETURNS = %i[
|
|
65
|
+
to_i to_int to_f to_r to_c to_sym size length count
|
|
66
|
+
bytesize ord hash
|
|
67
|
+
].freeze
|
|
68
|
+
# Sorbet's inline casts, which return their value argument.
|
|
69
|
+
SORBET_CASTS = %i[let cast must].freeze
|
|
44
70
|
|
|
45
71
|
# @param frozen_string_literal [Boolean] whether the file has
|
|
46
72
|
# the frozen_string_literal magic comment
|
|
@@ -51,6 +77,7 @@ module Audition
|
|
|
51
77
|
# @param node [Prism::Node] an expression node
|
|
52
78
|
# @return [Symbol] classification, see class docs
|
|
53
79
|
def classify(node)
|
|
80
|
+
node = begin_value(node)
|
|
54
81
|
case node
|
|
55
82
|
when Prism::IntegerNode, Prism::FloatNode,
|
|
56
83
|
Prism::RationalNode, Prism::ImaginaryNode,
|
|
@@ -84,6 +111,29 @@ module Audition
|
|
|
84
111
|
end
|
|
85
112
|
end
|
|
86
113
|
|
|
114
|
+
# The value an expression hands back once begin blocks
|
|
115
|
+
# and inline casts are peeled off.
|
|
116
|
+
def unwrap(node)
|
|
117
|
+
loop do
|
|
118
|
+
inner = begin_value(node)
|
|
119
|
+
inner = cast_value(inner) || inner
|
|
120
|
+
break node if inner.equal?(node)
|
|
121
|
+
|
|
122
|
+
node = inner
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A begin block's value is its last statement. A rescue,
|
|
127
|
+
# else, or ensure clause can supply a different one, so
|
|
128
|
+
# only the plain form resolves.
|
|
129
|
+
def begin_value(node)
|
|
130
|
+
return node unless node.is_a?(Prism::BeginNode) &&
|
|
131
|
+
node.rescue_clause.nil? && node.else_clause.nil? &&
|
|
132
|
+
node.ensure_clause.nil?
|
|
133
|
+
|
|
134
|
+
node.statements&.body&.last || node
|
|
135
|
+
end
|
|
136
|
+
|
|
87
137
|
# `::Mutex` and `Mutex` are the same constant for matching
|
|
88
138
|
# purposes; the leading colons are stripped.
|
|
89
139
|
def const_name(node)
|
|
@@ -95,6 +145,34 @@ module Audition
|
|
|
95
145
|
end
|
|
96
146
|
end
|
|
97
147
|
|
|
148
|
+
# The value inside a Sorbet inline cast, or nil. The cast
|
|
149
|
+
# returns its argument, so fixes and type names belong on
|
|
150
|
+
# the value, not the cast.
|
|
151
|
+
def cast_value(node)
|
|
152
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
153
|
+
return nil unless const_name(node.receiver) == "T" &&
|
|
154
|
+
SORBET_CASTS.include?(node.name)
|
|
155
|
+
|
|
156
|
+
node.arguments&.arguments&.first
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# What `value.freeze` would classify as, for the check to
|
|
160
|
+
# choose a plain `.freeze` over a deep wrap: :shareable
|
|
161
|
+
# when every element is provably shareable, :shallow_freeze
|
|
162
|
+
# when one is provably mutable, :unknown otherwise.
|
|
163
|
+
def frozen_kind(value)
|
|
164
|
+
case value
|
|
165
|
+
when Prism::ArrayNode, Prism::HashNode,
|
|
166
|
+
Prism::KeywordHashNode
|
|
167
|
+
deep_classify(value.elements)
|
|
168
|
+
when Prism::CallNode
|
|
169
|
+
elements = set_elements(value)
|
|
170
|
+
elements ? deep_classify(elements) : :unknown
|
|
171
|
+
else
|
|
172
|
+
:unknown
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
98
176
|
private
|
|
99
177
|
|
|
100
178
|
# Adjacent literals ("a" "b") parse as interpolation but
|
|
@@ -121,18 +199,28 @@ module Audition
|
|
|
121
199
|
classify_freeze(node, receiver)
|
|
122
200
|
when :new
|
|
123
201
|
name = const_name(receiver)
|
|
124
|
-
return :sync_primitive if SYNC_PRIMITIVES.include?(name)
|
|
202
|
+
return :sync_primitive if SYNC_PRIMITIVES.include?(name) ||
|
|
203
|
+
UNFREEZABLE_COLLECTIONS.include?(name)
|
|
125
204
|
return :shareable if SHAREABLE_FACTORIES.include?(name)
|
|
126
205
|
return :proc if name == "Proc" && node.block
|
|
127
206
|
# Hash.new retains its block as the default proc;
|
|
128
207
|
# Array.new only uses its block to build elements.
|
|
129
208
|
return :default_proc if name == "Hash" && node.block
|
|
209
|
+
return set_kind(node) if name == "Set"
|
|
210
|
+
if SENTINEL_FACTORIES.include?(name)
|
|
211
|
+
bare = node.arguments.nil? && node.block.nil?
|
|
212
|
+
return bare ? :mutable_call : :unknown
|
|
213
|
+
end
|
|
130
214
|
|
|
131
215
|
if %w[Hash Array].include?(name)
|
|
132
216
|
return :mutable_container
|
|
133
217
|
end
|
|
134
218
|
|
|
135
|
-
:
|
|
219
|
+
name ? :instance_new : :opaque_call
|
|
220
|
+
when :to_set
|
|
221
|
+
set_kind(node)
|
|
222
|
+
when :[]
|
|
223
|
+
index_kind(node)
|
|
136
224
|
when :define
|
|
137
225
|
(const_name(receiver) == "Data") ? :shareable : :unknown
|
|
138
226
|
when :make_shareable
|
|
@@ -140,14 +228,46 @@ module Audition
|
|
|
140
228
|
when :lambda, :proc
|
|
141
229
|
(receiver.nil? && node.block) ? :proc : :unknown
|
|
142
230
|
else
|
|
143
|
-
|
|
231
|
+
opaque_kind(node)
|
|
144
232
|
end
|
|
145
233
|
end
|
|
146
234
|
|
|
235
|
+
# Catch-all for unrecognized calls: shareable returns pass,
|
|
236
|
+
# predicates stay silent, everything else is opaque.
|
|
237
|
+
def opaque_kind(node)
|
|
238
|
+
return :shareable if SHAREABLE_RETURNS.include?(node.name)
|
|
239
|
+
return :unknown if node.name.end_with?("?")
|
|
240
|
+
|
|
241
|
+
if (value = cast_value(node))
|
|
242
|
+
return classify(value)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
:opaque_call
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Indexing into a constant, another call's result, or a
|
|
249
|
+
# fresh instance returns a value of unprovable shareability.
|
|
250
|
+
# Sorbet type constructors and ENV, whose values are frozen
|
|
251
|
+
# strings, stay silent.
|
|
252
|
+
def index_kind(node)
|
|
253
|
+
owner = const_name(node.receiver)
|
|
254
|
+
return set_kind(node) if owner == "Set"
|
|
255
|
+
if owner.nil?
|
|
256
|
+
receiver_kind = classify(node.receiver)
|
|
257
|
+
return :opaque_call if %i[opaque_call instance_new]
|
|
258
|
+
.include?(receiver_kind)
|
|
259
|
+
return :unknown
|
|
260
|
+
end
|
|
261
|
+
return :unknown if owner == "ENV" ||
|
|
262
|
+
owner == "T" || owner.start_with?("T::")
|
|
263
|
+
|
|
264
|
+
:opaque_call
|
|
265
|
+
end
|
|
266
|
+
|
|
147
267
|
def classify_freeze(node, receiver)
|
|
148
268
|
return :unknown unless node.arguments.nil? && receiver
|
|
149
269
|
|
|
150
|
-
case receiver
|
|
270
|
+
case (receiver = begin_value(receiver))
|
|
151
271
|
when Prism::StringNode
|
|
152
272
|
:shareable
|
|
153
273
|
when Prism::ArrayNode, Prism::HashNode
|
|
@@ -158,6 +278,8 @@ module Audition
|
|
|
158
278
|
case classify(receiver)
|
|
159
279
|
when :default_proc then :default_proc
|
|
160
280
|
when :mutable_call then :shareable
|
|
281
|
+
when :mutable_container then frozen_kind(receiver)
|
|
282
|
+
when :instance_new, :opaque_call then :shallow_opaque
|
|
161
283
|
else :unknown
|
|
162
284
|
end
|
|
163
285
|
else
|
|
@@ -178,6 +300,7 @@ module Audition
|
|
|
178
300
|
owner = const_name(receiver)
|
|
179
301
|
(name == :new && owner == "String") ||
|
|
180
302
|
(owner == "Kernel" && FORMATTERS.include?(name)) ||
|
|
303
|
+
(owner == "File" && FILE_PATH_METHODS.include?(name)) ||
|
|
181
304
|
STRING_ONLY_METHODS.include?(name)
|
|
182
305
|
else
|
|
183
306
|
false
|
|
@@ -194,7 +317,11 @@ module Audition
|
|
|
194
317
|
# keeps a frozen Hash of Mutexes). The classification
|
|
195
318
|
# propagates so no freeze or wrap is ever suggested.
|
|
196
319
|
def container_kind(node)
|
|
197
|
-
|
|
320
|
+
elements_kind(node.elements)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def elements_kind(elements)
|
|
324
|
+
sync = elements.any? do |element|
|
|
198
325
|
element_children(element).any? do |child|
|
|
199
326
|
classify(child) == :sync_primitive
|
|
200
327
|
end
|
|
@@ -202,6 +329,37 @@ module Audition
|
|
|
202
329
|
sync ? :sync_primitive : :mutable_container
|
|
203
330
|
end
|
|
204
331
|
|
|
332
|
+
# A Set built from literals is a container of them:
|
|
333
|
+
# `Set.new([...])`, `Set[...]`, `%w[...].to_set`. A Set
|
|
334
|
+
# built from an opaque source or a mapping block is still
|
|
335
|
+
# a fresh unfrozen Set, mutable no matter its contents.
|
|
336
|
+
def set_kind(node)
|
|
337
|
+
elements = set_elements(node)
|
|
338
|
+
elements ? elements_kind(elements) : :mutable_container
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def set_elements(node)
|
|
342
|
+
return nil if node.block
|
|
343
|
+
|
|
344
|
+
case node.name
|
|
345
|
+
when :new, :[]
|
|
346
|
+
return nil unless const_name(node.receiver) == "Set"
|
|
347
|
+
|
|
348
|
+
args = node.arguments&.arguments || []
|
|
349
|
+
return args if node.name == :[]
|
|
350
|
+
return [] if args.empty?
|
|
351
|
+
return nil unless args.size == 1
|
|
352
|
+
|
|
353
|
+
args[0].is_a?(Prism::ArrayNode) ? args[0].elements : nil
|
|
354
|
+
when :to_set
|
|
355
|
+
receiver = node.receiver
|
|
356
|
+
return nil unless node.arguments.nil? &&
|
|
357
|
+
receiver.is_a?(Prism::ArrayNode)
|
|
358
|
+
|
|
359
|
+
receiver.elements
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
205
363
|
def element_children(element)
|
|
206
364
|
case element
|
|
207
365
|
when Prism::AssocNode then [element.key, element.value]
|
|
@@ -237,20 +395,33 @@ module Audition
|
|
|
237
395
|
body && body.size == 1 && body[0]
|
|
238
396
|
end
|
|
239
397
|
|
|
240
|
-
# Fold element classifications
|
|
241
|
-
#
|
|
242
|
-
# :shallow_freeze;
|
|
243
|
-
#
|
|
244
|
-
#
|
|
398
|
+
# Fold element classifications by the strongest evidence:
|
|
399
|
+
# a sync primitive poisons the whole container; a provably
|
|
400
|
+
# mutable element makes it :shallow_freeze; an opaque call
|
|
401
|
+
# result makes it :shallow_opaque. A bare constant read is
|
|
402
|
+
# commonly a class or another frozen constant, so on its
|
|
403
|
+
# own it keeps the container silent (:unknown), but it
|
|
404
|
+
# cannot excuse a bad element elsewhere.
|
|
405
|
+
VERDICT_RANK = {
|
|
406
|
+
shareable: 0, unknown: 1, shallow_opaque: 2,
|
|
407
|
+
shallow_freeze: 3
|
|
408
|
+
}.freeze
|
|
409
|
+
|
|
245
410
|
def deep_classify(elements)
|
|
246
411
|
verdict = :shareable
|
|
247
412
|
elements.each do |element|
|
|
248
413
|
element_children(element).each do |child|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
414
|
+
kind =
|
|
415
|
+
case classify(child)
|
|
416
|
+
when :shareable then :shareable
|
|
417
|
+
when :sync_primitive then return :sync_primitive
|
|
418
|
+
when :unknown then :unknown
|
|
419
|
+
when :instance_new, :opaque_call, :shallow_opaque
|
|
420
|
+
:shallow_opaque
|
|
421
|
+
else :shallow_freeze
|
|
422
|
+
end
|
|
423
|
+
if VERDICT_RANK[kind] > VERDICT_RANK[verdict]
|
|
424
|
+
verdict = kind
|
|
254
425
|
end
|
|
255
426
|
end
|
|
256
427
|
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
module Static
|
|
5
|
+
# Compiled extensions are invisible to the Ruby scanner, but the
|
|
6
|
+
# one fact that decides their Ractor behavior is visible from
|
|
7
|
+
# outside. Ruby marks every method an extension defines while
|
|
8
|
+
# Init_* runs without rb_ext_ractor_safe(true), and calling any
|
|
9
|
+
# of them from a non-main Ractor raises Ractor::UnsafeError. The
|
|
10
|
+
# declaration is a libruby import, so a compiled file names the
|
|
11
|
+
# symbol or does not, whatever language produced it; sources
|
|
12
|
+
# spell it as rb_ext_ractor_safe(true) or the
|
|
13
|
+
# RB_EXT_RACTOR_SAFE(true) macro (C, Rust via rb-sys, Zig).
|
|
14
|
+
#
|
|
15
|
+
# Compiled files win when present: they are what `require`
|
|
16
|
+
# loads. Sources are consulted only for a checkout that has not
|
|
17
|
+
# been built yet.
|
|
18
|
+
class NativeExtensions
|
|
19
|
+
CHECK = "native-extension"
|
|
20
|
+
SYMBOL = "rb_ext_ractor_safe"
|
|
21
|
+
DECLARATION = /rb_ext_ractor_safe\s*\(\s*true\s*\)/i
|
|
22
|
+
INIT = /\bInit_\w+\s*\(/
|
|
23
|
+
SOURCES = "*.{c,cc,cpp,cxx,m,mm,h,hpp,rs,zig}"
|
|
24
|
+
BUILD_FILES = %w[Cargo.toml build.zig extconf.rb].freeze
|
|
25
|
+
# Harness crates and test trees are not compiled into the
|
|
26
|
+
# extension; a fuzz target that links the VM itself may call
|
|
27
|
+
# rb_ext_ractor_safe without the extension doing so.
|
|
28
|
+
HARNESS_DIRS = %w[fuzz benches tests examples test spec].freeze
|
|
29
|
+
|
|
30
|
+
SILENT_WHY =
|
|
31
|
+
"Ruby marks every method an extension defines while " \
|
|
32
|
+
"Init_* runs without rb_ext_ractor_safe(true), and calling " \
|
|
33
|
+
"any of them from a non-main Ractor raises " \
|
|
34
|
+
"Ractor::UnsafeError (\"ractor unsafe method called from " \
|
|
35
|
+
"not main ractor\"), whether the extension is C, Rust, or " \
|
|
36
|
+
"Zig (verified on Ruby 4.0.6). "
|
|
37
|
+
# Tails appended to SILENT_WHY at finding time; a constant
|
|
38
|
+
# built with + would hold an unfrozen String.
|
|
39
|
+
COMPILED_TAIL =
|
|
40
|
+
"The declaration is a libruby import, so a compiled file " \
|
|
41
|
+
"that never names the symbol cannot have made it."
|
|
42
|
+
SOURCE_TAIL =
|
|
43
|
+
"These sources never call it, so the compiled extension " \
|
|
44
|
+
"will raise the same way."
|
|
45
|
+
SILENT_FIX =
|
|
46
|
+
"Audit the native code for process-global mutable state " \
|
|
47
|
+
"(statics, caches, VALUEs held outside Ruby objects), then " \
|
|
48
|
+
"call RB_EXT_RACTOR_SAFE(true) first thing in Init_* " \
|
|
49
|
+
"(rb_sys::rb_ext_ractor_safe(true) from Rust). Until then, " \
|
|
50
|
+
"keep every call into this extension on the main Ractor."
|
|
51
|
+
DECLARED_WHY =
|
|
52
|
+
"rb_ext_ractor_safe(true) is the maintainer's assertion " \
|
|
53
|
+
"that the extension keeps no process-global mutable state; " \
|
|
54
|
+
"Ruby does not verify it and neither can Audition. Its " \
|
|
55
|
+
"methods run from any Ractor, in parallel, on the strength " \
|
|
56
|
+
"of that assertion alone."
|
|
57
|
+
DECLARED_FIX =
|
|
58
|
+
"Exercise real calls from a non-main Ractor (a script " \
|
|
59
|
+
"probe, or the gem's test suite under Ractor.new) before " \
|
|
60
|
+
"relying on it."
|
|
61
|
+
|
|
62
|
+
# @param target [Target]
|
|
63
|
+
# @param compiled_files [Array<String>] compiled extension
|
|
64
|
+
# files to inspect (defaults to the target's own list; the
|
|
65
|
+
# CLI passes the config-filtered subset)
|
|
66
|
+
# @return [Array<Finding>]
|
|
67
|
+
def analyze(target, compiled_files: target.compiled_files)
|
|
68
|
+
if compiled_files.any?
|
|
69
|
+
return compiled_files.filter_map { |p| compiled_finding(p) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
extension_dirs(target.root).map do |dir|
|
|
73
|
+
source_finding(dir, target.root)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def compiled_finding(path)
|
|
80
|
+
name = File.basename(path)
|
|
81
|
+
if File.binread(path).include?(SYMBOL)
|
|
82
|
+
finding(:info, path, nil,
|
|
83
|
+
"compiled extension #{name} declares Ractor safety " \
|
|
84
|
+
"(imports #{SYMBOL})", DECLARED_WHY, DECLARED_FIX)
|
|
85
|
+
else
|
|
86
|
+
finding(:warning, path, nil,
|
|
87
|
+
"compiled extension #{name} does not declare Ractor " \
|
|
88
|
+
"safety", SILENT_WHY + COMPILED_TAIL, SILENT_FIX)
|
|
89
|
+
end
|
|
90
|
+
rescue SystemCallError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# One finding per extension directory, anchored
|
|
95
|
+
# at the declaration when there is one, else at Init_* or the
|
|
96
|
+
# build file, where the declaration belongs.
|
|
97
|
+
def source_finding(dir, root)
|
|
98
|
+
sources = sources_under(dir)
|
|
99
|
+
label = if dir == root
|
|
100
|
+
File.basename(root)
|
|
101
|
+
else
|
|
102
|
+
dir.delete_prefix("#{root}/")
|
|
103
|
+
end
|
|
104
|
+
path, line = locate(sources, DECLARATION)
|
|
105
|
+
if path
|
|
106
|
+
return finding(:info, path, line,
|
|
107
|
+
"extension sources under #{label} declare Ractor " \
|
|
108
|
+
"safety (#{SYMBOL})", DECLARED_WHY, DECLARED_FIX)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
path, line = locate(sources, INIT)
|
|
112
|
+
path ||= build_file(dir)
|
|
113
|
+
finding(:warning, path, line,
|
|
114
|
+
"extension sources under #{label} do not declare Ractor " \
|
|
115
|
+
"safety", SILENT_WHY + SOURCE_TAIL, SILENT_FIX)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Directories holding native sources, found by the build file
|
|
119
|
+
# that compiles them rather than by convention: a gem is free
|
|
120
|
+
# to put its extension anywhere. A manifest above another one
|
|
121
|
+
# is dropped, so a workspace does not report the sources of
|
|
122
|
+
# the crates under it a second time.
|
|
123
|
+
def extension_dirs(root)
|
|
124
|
+
dirs = build_dirs(root).select { |dir| sources_under(dir).any? }
|
|
125
|
+
dirs.reject do |dir|
|
|
126
|
+
dirs.any? { |other| other.start_with?("#{dir}/") }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build_dirs(root)
|
|
131
|
+
pattern = "{#{BUILD_FILES.join(",")}}"
|
|
132
|
+
Dir[File.join(root, "**", pattern)].filter_map do |path|
|
|
133
|
+
relative = path.delete_prefix("#{root}/").split("/")
|
|
134
|
+
next if relative[0..-2].any? { |part| skipped?(part) }
|
|
135
|
+
|
|
136
|
+
File.dirname(path)
|
|
137
|
+
end.uniq.sort
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def sources_under(dir)
|
|
141
|
+
Dir[File.join(dir, "**", SOURCES)].sort.reject do |path|
|
|
142
|
+
path.delete_prefix("#{dir}/").split("/")[0..-2]
|
|
143
|
+
.any? { |part| skipped?(part) }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def skipped?(part)
|
|
148
|
+
Target::EXCLUDED_DIRS.include?(part) ||
|
|
149
|
+
Target::BUILD_DIRS.include?(part) ||
|
|
150
|
+
HARNESS_DIRS.include?(part) || part.start_with?(".")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def locate(sources, pattern)
|
|
154
|
+
sources.each do |path|
|
|
155
|
+
File.foreach(path, mode: "rb").with_index(1) do |text, n|
|
|
156
|
+
return [path, n] if text.match?(pattern)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def build_file(dir)
|
|
163
|
+
BUILD_FILES.map { |name| File.join(dir, name) }
|
|
164
|
+
.find { |path| File.file?(path) } || dir
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def finding(severity, path, line, message, why, fix)
|
|
168
|
+
Finding.new(
|
|
169
|
+
check: CHECK, severity: severity, message: message,
|
|
170
|
+
why: why, fix: fix, path: path, line: line
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -152,6 +152,76 @@ module Audition
|
|
|
152
152
|
# shaped like `# key: value`, which sweeps up documentation
|
|
153
153
|
# (`# I18n.t: 'date.formats.short'`); inserting after those
|
|
154
154
|
# would land a magic comment mid-file.
|
|
155
|
+
# Constants frozen by a bare `NAME.freeze` statement at the
|
|
156
|
+
# same lexical level as a file, class, or module body: the
|
|
157
|
+
# build-then-freeze shape. A freeze inside a method does
|
|
158
|
+
# not count; nothing guarantees it runs before a Ractor
|
|
159
|
+
# reads the constant.
|
|
160
|
+
# @return [Array<String>]
|
|
161
|
+
def frozen_constants
|
|
162
|
+
@frozen_constants ||= begin
|
|
163
|
+
names = []
|
|
164
|
+
bodies = [root.statements]
|
|
165
|
+
until bodies.empty?
|
|
166
|
+
statements = bodies.shift
|
|
167
|
+
next unless statements.is_a?(Prism::StatementsNode)
|
|
168
|
+
|
|
169
|
+
statements.body.each do |node|
|
|
170
|
+
case node
|
|
171
|
+
when Prism::ModuleNode, Prism::ClassNode,
|
|
172
|
+
Prism::SingletonClassNode
|
|
173
|
+
bodies << node.body
|
|
174
|
+
when Prism::CallNode
|
|
175
|
+
next unless node.name == :freeze &&
|
|
176
|
+
node.arguments.nil? && node.block.nil?
|
|
177
|
+
|
|
178
|
+
name = constant_receiver(node.receiver)
|
|
179
|
+
names << name if name
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
names.uniq
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Calls that give a constant's object singleton behavior;
|
|
188
|
+
# freezing the object first makes them raise FrozenError
|
|
189
|
+
# (`NULL = Object.new; def NULL.to_s = "null"`).
|
|
190
|
+
CONST_CUSTOMIZERS = %i[
|
|
191
|
+
extend define_singleton_method instance_eval instance_exec
|
|
192
|
+
singleton_class instance_variable_set
|
|
193
|
+
].freeze
|
|
194
|
+
|
|
195
|
+
# @return [Array<String>] names of constants that receive a
|
|
196
|
+
# singleton method definition or a customizing call
|
|
197
|
+
def customized_constants
|
|
198
|
+
@customized_constants ||= begin
|
|
199
|
+
names = []
|
|
200
|
+
queue = [root]
|
|
201
|
+
until queue.empty?
|
|
202
|
+
node = queue.shift
|
|
203
|
+
queue.concat(node.child_nodes.compact)
|
|
204
|
+
receiver =
|
|
205
|
+
if node.is_a?(Prism::DefNode)
|
|
206
|
+
node.receiver
|
|
207
|
+
elsif node.is_a?(Prism::CallNode) &&
|
|
208
|
+
CONST_CUSTOMIZERS.include?(node.name)
|
|
209
|
+
node.receiver
|
|
210
|
+
end
|
|
211
|
+
name = constant_receiver(receiver)
|
|
212
|
+
names << name if name
|
|
213
|
+
end
|
|
214
|
+
names.uniq
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def constant_receiver(node)
|
|
219
|
+
case node
|
|
220
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
221
|
+
when Prism::ConstantPathNode then node.location.slice
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
155
225
|
MAGIC_KEYS = %w[
|
|
156
226
|
encoding coding frozen_string_literal
|
|
157
227
|
shareable_constant_value warn_indent
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Audition
|
|
6
|
+
module Static
|
|
7
|
+
# How a scan is divided between Ractor workers.
|
|
8
|
+
module WorkSplit
|
|
9
|
+
# Ractors run on a fixed pool of native threads sized by
|
|
10
|
+
# RUBY_MAX_CPU, which only the environment can set. Spawning
|
|
11
|
+
# past it buys no parallelism and costs a Ractor each.
|
|
12
|
+
RACTOR_CPU_DEFAULT = 8
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# One worker per core the Ractor pool can actually run. The
|
|
17
|
+
# main Ractor only waits while workers scan, so no core is
|
|
18
|
+
# held back for it.
|
|
19
|
+
#
|
|
20
|
+
# @return [Integer]
|
|
21
|
+
def workers
|
|
22
|
+
Etc.nprocessors.clamp(1, ractor_cpu_limit)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ractor_cpu_limit
|
|
26
|
+
limit = ENV["RUBY_MAX_CPU"].to_i
|
|
27
|
+
limit.positive? ? limit : RACTOR_CPU_DEFAULT
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Longest-processing-time-first: deal the heaviest item onto
|
|
31
|
+
# the lightest worker. Consecutive files are neighbors in the
|
|
32
|
+
# tree and so alike in size, so contiguous slices come out
|
|
33
|
+
# lopsided: one worker can draw a slice weighing several times
|
|
34
|
+
# the mean and still be running once the rest have finished.
|
|
35
|
+
# Greedy is enough here: LPT finishes within 4/3 of an
|
|
36
|
+
# optimal split.
|
|
37
|
+
#
|
|
38
|
+
# @param weighted [Array<Array>] `[item, weight]` pairs
|
|
39
|
+
# @param count [Integer] worker count
|
|
40
|
+
# @return [Array<Array>] one chunk of items per busy worker
|
|
41
|
+
def chunks(weighted, count)
|
|
42
|
+
chunks = Array.new(count) { [] }
|
|
43
|
+
loads = Array.new(count, 0)
|
|
44
|
+
weighted.sort_by { |item, weight| [-weight, item] }
|
|
45
|
+
.each do |item, weight|
|
|
46
|
+
lightest = loads.index(loads.min)
|
|
47
|
+
chunks[lightest] << item
|
|
48
|
+
loads[lightest] += weight
|
|
49
|
+
end
|
|
50
|
+
chunks.reject(&:empty?)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|