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
|
@@ -11,11 +11,20 @@ module Audition
|
|
|
11
11
|
# :mutable_call unfrozen String, Regexp, or sentinel
|
|
12
12
|
# Object returned by a call (`.tr`,
|
|
13
13
|
# `format`, `Regexp.new`, `Object.new`)
|
|
14
|
+
# :fresh_container unfrozen Array or Hash a core method
|
|
15
|
+
# allocates on any receiver (`.map`,
|
|
16
|
+
# `.keys`, `.merge`, `X + [..]`, `.dup`)
|
|
17
|
+
# :unshareable_object a Method or UnboundMethod, never
|
|
18
|
+
# shareable even frozen
|
|
14
19
|
# :shallow_freeze frozen container with mutable elements
|
|
15
20
|
# :sync_primitive Mutex/Queue/... constructor
|
|
16
21
|
# :proc lambda or proc
|
|
17
22
|
# :default_proc Hash.new with a block; the block
|
|
18
23
|
# survives .freeze and stays unshareable
|
|
24
|
+
# :instance_new unfrozen instance of an arbitrary class
|
|
25
|
+
# :opaque_call method call with an unprovable return
|
|
26
|
+
# :shallow_opaque frozen at the top, but what it holds
|
|
27
|
+
# is an unprovable call result
|
|
19
28
|
# :unknown cannot tell statically
|
|
20
29
|
class LiteralClassifier
|
|
21
30
|
SYNC_PRIMITIVES = %w[
|
|
@@ -41,16 +50,85 @@ module Audition
|
|
|
41
50
|
# to String alone in core, so any receiver qualifies.
|
|
42
51
|
STRING_ONLY_METHODS = %i[
|
|
43
52
|
tr tr_s gsub sub squeeze strip lstrip rstrip chomp chop
|
|
44
|
-
center ljust rjust encode scrub unicode_normalize
|
|
53
|
+
center ljust rjust encode scrub unicode_normalize b
|
|
45
54
|
].freeze
|
|
55
|
+
# Fresh Strings off a literal receiver: `to_s` on anything
|
|
56
|
+
# but a String (String#to_s returns self), `inspect` on any
|
|
57
|
+
# literal, and a Regexp literal's source.
|
|
58
|
+
STRINGIFIERS = %i[to_s inspect].freeze
|
|
59
|
+
REGEXP_LITERAL_METHODS = %i[source to_s inspect].freeze
|
|
46
60
|
# Unambiguous only on a String literal receiver: Symbols
|
|
47
61
|
# and numbers define these too and return shareable values.
|
|
48
62
|
STRING_LITERAL_METHODS = %i[
|
|
49
63
|
+ * % upcase downcase capitalize swapcase reverse dup
|
|
50
|
-
succ next
|
|
64
|
+
succ next +@
|
|
65
|
+
].freeze
|
|
66
|
+
# Array methods that hand back another Array, so a `.join`
|
|
67
|
+
# at the end of the chain still joins the literal it
|
|
68
|
+
# started from (`[MAJOR, MINOR, PRE].compact.join(".")`).
|
|
69
|
+
ARRAY_CHAIN_METHODS = %i[
|
|
70
|
+
compact map collect flatten uniq sort sort_by reverse
|
|
71
|
+
reject select filter flat_map take drop rotate shuffle
|
|
72
|
+
grep grep_v first last
|
|
51
73
|
].freeze
|
|
52
74
|
FORMATTERS = %i[format sprintf].freeze
|
|
53
75
|
REGEXP_FACTORIES = %i[new union compile].freeze
|
|
76
|
+
# File methods returning a fresh path String.
|
|
77
|
+
FILE_PATH_METHODS = %i[
|
|
78
|
+
expand_path join dirname basename absolute_path realpath
|
|
79
|
+
].freeze
|
|
80
|
+
|
|
81
|
+
# Calls returning a shareable primitive on any receiver.
|
|
82
|
+
SHAREABLE_RETURNS = %i[
|
|
83
|
+
to_i to_int to_f to_r to_c to_sym size length count
|
|
84
|
+
bytesize ord hash
|
|
85
|
+
].freeze
|
|
86
|
+
|
|
87
|
+
# Return-type contracts of core methods, executed against
|
|
88
|
+
# the running Ruby by literal_classifier_spec so no entry can
|
|
89
|
+
# drift from what Ruby does. Each list names methods whose
|
|
90
|
+
# result, on every core receiver that defines them, is a
|
|
91
|
+
# newly allocated object of the stated kind.
|
|
92
|
+
FRESH_ARRAY_METHODS = %i[
|
|
93
|
+
map collect flat_map collect_concat filter_map grep grep_v
|
|
94
|
+
partition sort sort_by uniq flatten reverse rotate shuffle
|
|
95
|
+
zip product take drop take_while drop_while entries keys
|
|
96
|
+
values values_at
|
|
97
|
+
].freeze
|
|
98
|
+
FRESH_HASH_METHODS = %i[
|
|
99
|
+
merge invert transform_values transform_keys tally group_by
|
|
100
|
+
except
|
|
101
|
+
].freeze
|
|
102
|
+
# Array on an Array or Range, Hash on a Hash; fresh either way.
|
|
103
|
+
FRESH_CONTAINER_METHODS = %i[select filter reject compact].freeze
|
|
104
|
+
# Arrays of freshly allocated Strings: a `.freeze` on the
|
|
105
|
+
# result is shallow.
|
|
106
|
+
FRESH_STRING_ARRAYS = %i[chars lines split scan].freeze
|
|
107
|
+
# Arrays of Integers: a `.freeze` on the result is enough.
|
|
108
|
+
SHAREABLE_ELEMENT_ARRAYS = %i[bytes codepoints].freeze
|
|
109
|
+
# Method objects are unshareable and freezing does not help
|
|
110
|
+
# (verified on Ruby 4.0.6).
|
|
111
|
+
METHOD_OBJECTS = %i[
|
|
112
|
+
method instance_method public_method public_instance_method
|
|
113
|
+
].freeze
|
|
114
|
+
# Booleans, nil, or an Integer on any receiver.
|
|
115
|
+
COMPARISONS = %i[== != < > <= >= <=> === !].freeze
|
|
116
|
+
# Numeric on a Numeric receiver, whatever the operand.
|
|
117
|
+
ARITHMETIC = %i[+ - * / % ** << >> & | ^].freeze
|
|
118
|
+
# Numeric on any receiver given a numeric literal operand: no
|
|
119
|
+
# core container accepts a number there. Array and String
|
|
120
|
+
# repeat with `*`, String formats with `%`, Array appends
|
|
121
|
+
# with `<<`, so those three stay unproven.
|
|
122
|
+
OPERAND_TYPED = (ARITHMETIC - %i[* % <<]).freeze
|
|
123
|
+
# Operators whose result takes its type from a literal
|
|
124
|
+
# operand: `X + [..]` is an Array, `X + "s"` a String.
|
|
125
|
+
SET_OPERATORS = %i[+ - | &].freeze
|
|
126
|
+
NUMERIC_LITERALS = [
|
|
127
|
+
Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode,
|
|
128
|
+
Prism::ImaginaryNode
|
|
129
|
+
].freeze
|
|
130
|
+
# Sorbet's inline casts, which return their value argument.
|
|
131
|
+
SORBET_CASTS = %i[let cast must].freeze
|
|
54
132
|
|
|
55
133
|
# @param frozen_string_literal [Boolean] whether the file has
|
|
56
134
|
# the frozen_string_literal magic comment
|
|
@@ -61,6 +139,7 @@ module Audition
|
|
|
61
139
|
# @param node [Prism::Node] an expression node
|
|
62
140
|
# @return [Symbol] classification, see class docs
|
|
63
141
|
def classify(node)
|
|
142
|
+
node = begin_value(node)
|
|
64
143
|
case node
|
|
65
144
|
when Prism::IntegerNode, Prism::FloatNode,
|
|
66
145
|
Prism::RationalNode, Prism::ImaginaryNode,
|
|
@@ -94,6 +173,29 @@ module Audition
|
|
|
94
173
|
end
|
|
95
174
|
end
|
|
96
175
|
|
|
176
|
+
# The value an expression hands back once begin blocks
|
|
177
|
+
# and inline casts are peeled off.
|
|
178
|
+
def unwrap(node)
|
|
179
|
+
loop do
|
|
180
|
+
inner = begin_value(node)
|
|
181
|
+
inner = cast_value(inner) || inner
|
|
182
|
+
break node if inner.equal?(node)
|
|
183
|
+
|
|
184
|
+
node = inner
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A begin block's value is its last statement. A rescue,
|
|
189
|
+
# else, or ensure clause can supply a different one, so
|
|
190
|
+
# only the plain form resolves.
|
|
191
|
+
def begin_value(node)
|
|
192
|
+
return node unless node.is_a?(Prism::BeginNode) &&
|
|
193
|
+
node.rescue_clause.nil? && node.else_clause.nil? &&
|
|
194
|
+
node.ensure_clause.nil?
|
|
195
|
+
|
|
196
|
+
node.statements&.body&.last || node
|
|
197
|
+
end
|
|
198
|
+
|
|
97
199
|
# `::Mutex` and `Mutex` are the same constant for matching
|
|
98
200
|
# purposes; the leading colons are stripped.
|
|
99
201
|
def const_name(node)
|
|
@@ -105,6 +207,17 @@ module Audition
|
|
|
105
207
|
end
|
|
106
208
|
end
|
|
107
209
|
|
|
210
|
+
# The value inside a Sorbet inline cast, or nil. The cast
|
|
211
|
+
# returns its argument, so fixes and type names belong on
|
|
212
|
+
# the value, not the cast.
|
|
213
|
+
def cast_value(node)
|
|
214
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
215
|
+
return nil unless const_name(node.receiver) == "T" &&
|
|
216
|
+
SORBET_CASTS.include?(node.name)
|
|
217
|
+
|
|
218
|
+
node.arguments&.arguments&.first
|
|
219
|
+
end
|
|
220
|
+
|
|
108
221
|
# What `value.freeze` would classify as, for the check to
|
|
109
222
|
# choose a plain `.freeze` over a deep wrap: :shareable
|
|
110
223
|
# when every element is provably shareable, :shallow_freeze
|
|
@@ -122,6 +235,69 @@ module Audition
|
|
|
122
235
|
end
|
|
123
236
|
end
|
|
124
237
|
|
|
238
|
+
# The Array literal a call chain starts from, when every
|
|
239
|
+
# link keeps it an Array; nil for any other receiver.
|
|
240
|
+
def array_root(node)
|
|
241
|
+
loop do
|
|
242
|
+
case node
|
|
243
|
+
when Prism::ArrayNode
|
|
244
|
+
return node
|
|
245
|
+
when Prism::CallNode
|
|
246
|
+
return nil unless ARRAY_CHAIN_METHODS.include?(node.name)
|
|
247
|
+
|
|
248
|
+
node = node.receiver
|
|
249
|
+
else
|
|
250
|
+
return nil
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The core class whose method a fresh-string call names, for
|
|
256
|
+
# the finding's display: Array#join, Symbol#to_s, String#tr.
|
|
257
|
+
def fresh_string_owner(node)
|
|
258
|
+
receiver = node.receiver
|
|
259
|
+
if node.name == :join && array_root(receiver)
|
|
260
|
+
"Array"
|
|
261
|
+
elsif receiver.is_a?(Prism::SymbolNode)
|
|
262
|
+
"Symbol"
|
|
263
|
+
elsif receiver.is_a?(Prism::RegularExpressionNode)
|
|
264
|
+
"Regexp"
|
|
265
|
+
elsif NUMERIC_LITERALS.any? { |type| receiver.is_a?(type) }
|
|
266
|
+
"Integer"
|
|
267
|
+
elsif receiver.is_a?(Prism::ArrayNode)
|
|
268
|
+
"Array"
|
|
269
|
+
elsif receiver.is_a?(Prism::HashNode)
|
|
270
|
+
"Hash"
|
|
271
|
+
else
|
|
272
|
+
"String"
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# What a fresh-container call allocates, for display.
|
|
277
|
+
def fresh_type(node)
|
|
278
|
+
name = node.name
|
|
279
|
+
return "Hash" if FRESH_HASH_METHODS.include?(name)
|
|
280
|
+
return "copy" if name == :dup
|
|
281
|
+
if name == :each_with_object
|
|
282
|
+
return literal_container_type(first_argument(node))
|
|
283
|
+
end
|
|
284
|
+
if SET_OPERATORS.include?(name)
|
|
285
|
+
return literal_container_type(first_argument(node))
|
|
286
|
+
end
|
|
287
|
+
return "container" if FRESH_CONTAINER_METHODS.include?(name)
|
|
288
|
+
|
|
289
|
+
"Array"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Whether a fresh container's elements are provably mutable
|
|
293
|
+
# (split strings), provably shareable (bytes), or unknown.
|
|
294
|
+
def fresh_elements(node)
|
|
295
|
+
return :mutable if FRESH_STRING_ARRAYS.include?(node.name)
|
|
296
|
+
return :shareable if SHAREABLE_ELEMENT_ARRAYS.include?(node.name)
|
|
297
|
+
|
|
298
|
+
:unknown
|
|
299
|
+
end
|
|
300
|
+
|
|
125
301
|
private
|
|
126
302
|
|
|
127
303
|
# Adjacent literals ("a" "b") parse as interpolation but
|
|
@@ -141,8 +317,15 @@ module Audition
|
|
|
141
317
|
def classify_call(node)
|
|
142
318
|
return :mutable_call if fresh_string?(node) ||
|
|
143
319
|
fresh_regexp?(node)
|
|
320
|
+
return :unshareable_object if METHOD_OBJECTS.include?(node.name)
|
|
321
|
+
return :shareable if shareable_operator?(node)
|
|
322
|
+
return :fresh_container if fresh_container?(node)
|
|
144
323
|
|
|
145
324
|
receiver = node.receiver
|
|
325
|
+
# `:sym.name` returns the interned frozen String.
|
|
326
|
+
return :shareable if node.name == :name &&
|
|
327
|
+
receiver.is_a?(Prism::SymbolNode)
|
|
328
|
+
|
|
146
329
|
case node.name
|
|
147
330
|
when :freeze
|
|
148
331
|
classify_freeze(node, receiver)
|
|
@@ -165,9 +348,11 @@ module Audition
|
|
|
165
348
|
return :mutable_container
|
|
166
349
|
end
|
|
167
350
|
|
|
168
|
-
:
|
|
169
|
-
when :
|
|
351
|
+
name ? :instance_new : :opaque_call
|
|
352
|
+
when :to_set
|
|
170
353
|
set_kind(node)
|
|
354
|
+
when :[]
|
|
355
|
+
index_kind(node)
|
|
171
356
|
when :define
|
|
172
357
|
(const_name(receiver) == "Data") ? :shareable : :unknown
|
|
173
358
|
when :make_shareable
|
|
@@ -175,14 +360,46 @@ module Audition
|
|
|
175
360
|
when :lambda, :proc
|
|
176
361
|
(receiver.nil? && node.block) ? :proc : :unknown
|
|
177
362
|
else
|
|
178
|
-
|
|
363
|
+
opaque_kind(node)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Catch-all for unrecognized calls: shareable returns pass,
|
|
368
|
+
# predicates stay silent, everything else is opaque.
|
|
369
|
+
def opaque_kind(node)
|
|
370
|
+
return :shareable if SHAREABLE_RETURNS.include?(node.name)
|
|
371
|
+
return :unknown if node.name.end_with?("?")
|
|
372
|
+
|
|
373
|
+
if (value = cast_value(node))
|
|
374
|
+
return classify(value)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
:opaque_call
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Indexing into a constant, another call's result, or a
|
|
381
|
+
# fresh instance returns a value of unprovable shareability.
|
|
382
|
+
# Sorbet type constructors and ENV, whose values are frozen
|
|
383
|
+
# strings, stay silent.
|
|
384
|
+
def index_kind(node)
|
|
385
|
+
owner = const_name(node.receiver)
|
|
386
|
+
return set_kind(node) if owner == "Set"
|
|
387
|
+
if owner.nil?
|
|
388
|
+
receiver_kind = classify(node.receiver)
|
|
389
|
+
return :opaque_call if %i[opaque_call instance_new]
|
|
390
|
+
.include?(receiver_kind)
|
|
391
|
+
return :unknown
|
|
179
392
|
end
|
|
393
|
+
return :unknown if owner == "ENV" ||
|
|
394
|
+
owner == "T" || owner.start_with?("T::")
|
|
395
|
+
|
|
396
|
+
:opaque_call
|
|
180
397
|
end
|
|
181
398
|
|
|
182
399
|
def classify_freeze(node, receiver)
|
|
183
400
|
return :unknown unless node.arguments.nil? && receiver
|
|
184
401
|
|
|
185
|
-
case receiver
|
|
402
|
+
case (receiver = begin_value(receiver))
|
|
186
403
|
when Prism::StringNode
|
|
187
404
|
:shareable
|
|
188
405
|
when Prism::ArrayNode, Prism::HashNode
|
|
@@ -192,8 +409,11 @@ module Audition
|
|
|
192
409
|
# String or Regexp from a call is deeply shareable.
|
|
193
410
|
case classify(receiver)
|
|
194
411
|
when :default_proc then :default_proc
|
|
412
|
+
when :unshareable_object then :unshareable_object
|
|
195
413
|
when :mutable_call then :shareable
|
|
196
414
|
when :mutable_container then frozen_kind(receiver)
|
|
415
|
+
when :fresh_container then frozen_fresh_kind(receiver)
|
|
416
|
+
when :instance_new, :opaque_call then :shallow_opaque
|
|
197
417
|
else :unknown
|
|
198
418
|
end
|
|
199
419
|
else
|
|
@@ -201,9 +421,107 @@ module Audition
|
|
|
201
421
|
end
|
|
202
422
|
end
|
|
203
423
|
|
|
424
|
+
# A frozen fresh container is as shareable as its elements.
|
|
425
|
+
def frozen_fresh_kind(call)
|
|
426
|
+
case fresh_elements(call)
|
|
427
|
+
when :shareable then :shareable
|
|
428
|
+
when :mutable then :shallow_freeze
|
|
429
|
+
else :shallow_opaque
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def first_argument(node)
|
|
434
|
+
node.arguments&.arguments&.first
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def literal_container_type(node)
|
|
438
|
+
case node
|
|
439
|
+
when Prism::ArrayNode then "Array"
|
|
440
|
+
when Prism::HashNode, Prism::KeywordHashNode then "Hash"
|
|
441
|
+
else "container"
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def literal_container?(node)
|
|
446
|
+
node.is_a?(Prism::ArrayNode) || node.is_a?(Prism::HashNode) ||
|
|
447
|
+
node.is_a?(Prism::KeywordHashNode)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Comparisons and negation on anything, unary minus on
|
|
451
|
+
# anything (a Numeric, or a String's frozen copy), arithmetic
|
|
452
|
+
# on a numeric expression, which stays numeric whatever the
|
|
453
|
+
# operand, and the operand-typed operators with a numeric
|
|
454
|
+
# literal operand on anything.
|
|
455
|
+
def shareable_operator?(node)
|
|
456
|
+
name = node.name
|
|
457
|
+
return true if COMPARISONS.include?(name)
|
|
458
|
+
return true if name == :-@ && node.receiver
|
|
459
|
+
return false unless ARITHMETIC.include?(name)
|
|
460
|
+
return true if numeric_expression?(node.receiver)
|
|
461
|
+
|
|
462
|
+
OPERAND_TYPED.include?(name) &&
|
|
463
|
+
numeric_expression?(first_argument(node))
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def numeric_expression?(node)
|
|
467
|
+
return false if node.nil?
|
|
468
|
+
|
|
469
|
+
node = begin_value(node)
|
|
470
|
+
case node
|
|
471
|
+
when *NUMERIC_LITERALS
|
|
472
|
+
true
|
|
473
|
+
when Prism::ParenthesesNode
|
|
474
|
+
body = node.body&.body
|
|
475
|
+
!body.nil? && body.size == 1 && numeric_expression?(body[0])
|
|
476
|
+
when Prism::CallNode
|
|
477
|
+
(ARITHMETIC.include?(node.name) ||
|
|
478
|
+
%i[-@ +@].include?(node.name)) &&
|
|
479
|
+
numeric_expression?(node.receiver)
|
|
480
|
+
else
|
|
481
|
+
false
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# A call whose core contract is a newly allocated container:
|
|
486
|
+
# the table methods on any receiver, `each_with_object` with
|
|
487
|
+
# a literal memo, a set operator with a literal operand, and
|
|
488
|
+
# `dup`.
|
|
489
|
+
def fresh_container?(node)
|
|
490
|
+
receiver = node.receiver
|
|
491
|
+
# A call on a class or module is a user-defined class
|
|
492
|
+
# method with no core contract; values (SCREAMING
|
|
493
|
+
# constants, literals, chains, locals) follow the core one.
|
|
494
|
+
# Nothing in the tables applies to a number.
|
|
495
|
+
return false if receiver.nil? || class_like?(receiver) ||
|
|
496
|
+
numeric_expression?(receiver)
|
|
497
|
+
|
|
498
|
+
name = node.name
|
|
499
|
+
argument = first_argument(node)
|
|
500
|
+
return literal_container?(argument) if name == :each_with_object
|
|
501
|
+
if SET_OPERATORS.include?(name)
|
|
502
|
+
return argument.is_a?(Prism::ArrayNode)
|
|
503
|
+
end
|
|
504
|
+
return node.arguments.nil? if name == :dup
|
|
505
|
+
|
|
506
|
+
FRESH_ARRAY_METHODS.include?(name) ||
|
|
507
|
+
FRESH_HASH_METHODS.include?(name) ||
|
|
508
|
+
FRESH_CONTAINER_METHODS.include?(name) ||
|
|
509
|
+
FRESH_STRING_ARRAYS.include?(name) ||
|
|
510
|
+
SHAREABLE_ELEMENT_ARRAYS.include?(name)
|
|
511
|
+
end
|
|
512
|
+
|
|
204
513
|
def fresh_string?(node)
|
|
205
514
|
receiver = node.receiver
|
|
206
515
|
name = node.name
|
|
516
|
+
# Array#join and Symbol#to_s build a fresh String every
|
|
517
|
+
# call; the magic comment never reaches them (the
|
|
518
|
+
# `[8, 2, 0].join(".")` version string).
|
|
519
|
+
return true if name == :join && array_root(receiver)
|
|
520
|
+
return true if literal_stringifier?(node)
|
|
521
|
+
# `X + "suffix"` is a String whatever X is.
|
|
522
|
+
return true if name == :+ && receiver &&
|
|
523
|
+
string_literal?(first_argument(node))
|
|
524
|
+
|
|
207
525
|
case receiver
|
|
208
526
|
when nil
|
|
209
527
|
FORMATTERS.include?(name)
|
|
@@ -214,6 +532,7 @@ module Audition
|
|
|
214
532
|
owner = const_name(receiver)
|
|
215
533
|
(name == :new && owner == "String") ||
|
|
216
534
|
(owner == "Kernel" && FORMATTERS.include?(name)) ||
|
|
535
|
+
(owner == "File" && FILE_PATH_METHODS.include?(name)) ||
|
|
217
536
|
STRING_ONLY_METHODS.include?(name)
|
|
218
537
|
else
|
|
219
538
|
false
|
|
@@ -225,6 +544,39 @@ module Audition
|
|
|
225
544
|
const_name(node.receiver) == "Regexp"
|
|
226
545
|
end
|
|
227
546
|
|
|
547
|
+
# A constant reference that names a class or module rather
|
|
548
|
+
# than a value: any segment that is not SCREAMING_CASE.
|
|
549
|
+
def class_like?(node)
|
|
550
|
+
name = const_name(node)
|
|
551
|
+
!name.nil? && !name.split("::").last.match?(/\A[A-Z0-9_]+\z/)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def string_literal?(node)
|
|
555
|
+
node.is_a?(Prism::StringNode) ||
|
|
556
|
+
node.is_a?(Prism::InterpolatedStringNode)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# `to_s` and `inspect` on a Symbol, number, Regexp, Array or
|
|
560
|
+
# Hash literal, `inspect` on a String literal, and a Regexp
|
|
561
|
+
# literal's `source` all allocate (String#to_s returns self).
|
|
562
|
+
def literal_stringifier?(node)
|
|
563
|
+
receiver = node.receiver
|
|
564
|
+
name = node.name
|
|
565
|
+
return false unless node.arguments.nil?
|
|
566
|
+
|
|
567
|
+
case receiver
|
|
568
|
+
when Prism::RegularExpressionNode
|
|
569
|
+
REGEXP_LITERAL_METHODS.include?(name)
|
|
570
|
+
when Prism::StringNode, Prism::InterpolatedStringNode
|
|
571
|
+
name == :inspect
|
|
572
|
+
when Prism::SymbolNode, Prism::ArrayNode, Prism::HashNode,
|
|
573
|
+
*NUMERIC_LITERALS
|
|
574
|
+
STRINGIFIERS.include?(name)
|
|
575
|
+
else
|
|
576
|
+
false
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
228
580
|
# A container holding a sync primitive can never become
|
|
229
581
|
# shareable; Ractor.make_shareable raises on it (multi_json
|
|
230
582
|
# keeps a frozen Hash of Mutexes). The classification
|
|
@@ -243,12 +595,12 @@ module Audition
|
|
|
243
595
|
end
|
|
244
596
|
|
|
245
597
|
# A Set built from literals is a container of them:
|
|
246
|
-
# `Set.new([...])`, `Set[...]`, `%w[...].to_set`.
|
|
247
|
-
#
|
|
248
|
-
#
|
|
598
|
+
# `Set.new([...])`, `Set[...]`, `%w[...].to_set`. A Set
|
|
599
|
+
# built from an opaque source or a mapping block is still
|
|
600
|
+
# a fresh unfrozen Set, mutable no matter its contents.
|
|
249
601
|
def set_kind(node)
|
|
250
602
|
elements = set_elements(node)
|
|
251
|
-
elements ? elements_kind(elements) : :
|
|
603
|
+
elements ? elements_kind(elements) : :mutable_container
|
|
252
604
|
end
|
|
253
605
|
|
|
254
606
|
def set_elements(node)
|
|
@@ -308,20 +660,33 @@ module Audition
|
|
|
308
660
|
body && body.size == 1 && body[0]
|
|
309
661
|
end
|
|
310
662
|
|
|
311
|
-
# Fold element classifications
|
|
312
|
-
#
|
|
313
|
-
# :shallow_freeze;
|
|
314
|
-
#
|
|
315
|
-
#
|
|
663
|
+
# Fold element classifications by the strongest evidence:
|
|
664
|
+
# a sync primitive poisons the whole container; a provably
|
|
665
|
+
# mutable element makes it :shallow_freeze; an opaque call
|
|
666
|
+
# result makes it :shallow_opaque. A bare constant read is
|
|
667
|
+
# commonly a class or another frozen constant, so on its
|
|
668
|
+
# own it keeps the container silent (:unknown), but it
|
|
669
|
+
# cannot excuse a bad element elsewhere.
|
|
670
|
+
VERDICT_RANK = {
|
|
671
|
+
shareable: 0, unknown: 1, shallow_opaque: 2,
|
|
672
|
+
shallow_freeze: 3
|
|
673
|
+
}.freeze
|
|
674
|
+
|
|
316
675
|
def deep_classify(elements)
|
|
317
676
|
verdict = :shareable
|
|
318
677
|
elements.each do |element|
|
|
319
678
|
element_children(element).each do |child|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
679
|
+
kind =
|
|
680
|
+
case classify(child)
|
|
681
|
+
when :shareable then :shareable
|
|
682
|
+
when :sync_primitive then return :sync_primitive
|
|
683
|
+
when :unknown then :unknown
|
|
684
|
+
when :instance_new, :opaque_call, :shallow_opaque
|
|
685
|
+
:shallow_opaque
|
|
686
|
+
else :shallow_freeze
|
|
687
|
+
end
|
|
688
|
+
if VERDICT_RANK[kind] > VERDICT_RANK[verdict]
|
|
689
|
+
verdict = kind
|
|
325
690
|
end
|
|
326
691
|
end
|
|
327
692
|
end
|
|
@@ -22,9 +22,9 @@ module Audition
|
|
|
22
22
|
INIT = /\bInit_\w+\s*\(/
|
|
23
23
|
SOURCES = "*.{c,cc,cpp,cxx,m,mm,h,hpp,rs,zig}"
|
|
24
24
|
BUILD_FILES = %w[Cargo.toml build.zig extconf.rb].freeze
|
|
25
|
-
# Harness crates and test trees
|
|
26
|
-
#
|
|
27
|
-
#
|
|
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
28
|
HARNESS_DIRS = %w[fuzz benches tests examples test spec].freeze
|
|
29
29
|
|
|
30
30
|
SILENT_WHY =
|
|
@@ -51,7 +51,7 @@ module Audition
|
|
|
51
51
|
DECLARED_WHY =
|
|
52
52
|
"rb_ext_ractor_safe(true) is the maintainer's assertion " \
|
|
53
53
|
"that the extension keeps no process-global mutable state; " \
|
|
54
|
-
"Ruby does not verify it and neither can
|
|
54
|
+
"Ruby does not verify it and neither can Audition. Its " \
|
|
55
55
|
"methods run from any Ractor, in parallel, on the strength " \
|
|
56
56
|
"of that assertion alone."
|
|
57
57
|
DECLARED_FIX =
|
|
@@ -91,12 +91,16 @@ module Audition
|
|
|
91
91
|
nil
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
# One finding per extension directory
|
|
94
|
+
# One finding per extension directory, anchored
|
|
95
95
|
# at the declaration when there is one, else at Init_* or the
|
|
96
96
|
# build file, where the declaration belongs.
|
|
97
97
|
def source_finding(dir, root)
|
|
98
98
|
sources = sources_under(dir)
|
|
99
|
-
label = dir
|
|
99
|
+
label = if dir == root
|
|
100
|
+
File.basename(root)
|
|
101
|
+
else
|
|
102
|
+
dir.delete_prefix("#{root}/")
|
|
103
|
+
end
|
|
100
104
|
path, line = locate(sources, DECLARATION)
|
|
101
105
|
if path
|
|
102
106
|
return finding(:info, path, line,
|
|
@@ -111,18 +115,26 @@ module Audition
|
|
|
111
115
|
"safety", SILENT_WHY + SOURCE_TAIL, SILENT_FIX)
|
|
112
116
|
end
|
|
113
117
|
|
|
114
|
-
#
|
|
115
|
-
#
|
|
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.
|
|
116
123
|
def extension_dirs(root)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
dirs = Dir[File.join(ext, "*")].sort.select do |dir|
|
|
121
|
-
File.directory?(dir) && !skipped?(File.basename(dir)) &&
|
|
122
|
-
sources_under(dir).any?
|
|
124
|
+
dirs = build_dirs(root).select { |dir| sources_under(dir).any? }
|
|
125
|
+
dirs.reject do |dir|
|
|
126
|
+
dirs.any? { |other| other.start_with?("#{dir}/") }
|
|
123
127
|
end
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
126
138
|
end
|
|
127
139
|
|
|
128
140
|
def sources_under(dir)
|
|
@@ -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
|