audition 0.4.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 +48 -16
- data/lib/audition/bundle_sweep.rb +1 -0
- data/lib/audition/cli.rb +1 -0
- data/lib/audition/dynamic/harness.rb +212 -37
- data/lib/audition/dynamic/prober.rb +121 -10
- data/lib/audition/finding.rb +6 -2
- data/lib/audition/reconciliation.rb +86 -0
- data/lib/audition/rewriters.rb +25 -1
- data/lib/audition/static/checks/instance_memoization.rb +226 -0
- data/lib/audition/static/checks/mutable_constants.rb +101 -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.rb +4 -3
- data/lib/audition/static/graph_audit.rb +67 -12
- data/lib/audition/static/literal_classifier.rb +267 -2
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +1 -0
- metadata +3 -1
|
@@ -11,6 +11,11 @@ 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
|
|
@@ -45,13 +50,26 @@ module Audition
|
|
|
45
50
|
# to String alone in core, so any receiver qualifies.
|
|
46
51
|
STRING_ONLY_METHODS = %i[
|
|
47
52
|
tr tr_s gsub sub squeeze strip lstrip rstrip chomp chop
|
|
48
|
-
center ljust rjust encode scrub unicode_normalize
|
|
53
|
+
center ljust rjust encode scrub unicode_normalize b
|
|
49
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
|
|
50
60
|
# Unambiguous only on a String literal receiver: Symbols
|
|
51
61
|
# and numbers define these too and return shareable values.
|
|
52
62
|
STRING_LITERAL_METHODS = %i[
|
|
53
63
|
+ * % upcase downcase capitalize swapcase reverse dup
|
|
54
|
-
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
|
|
55
73
|
].freeze
|
|
56
74
|
FORMATTERS = %i[format sprintf].freeze
|
|
57
75
|
REGEXP_FACTORIES = %i[new union compile].freeze
|
|
@@ -65,6 +83,50 @@ module Audition
|
|
|
65
83
|
to_i to_int to_f to_r to_c to_sym size length count
|
|
66
84
|
bytesize ord hash
|
|
67
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
|
|
68
130
|
# Sorbet's inline casts, which return their value argument.
|
|
69
131
|
SORBET_CASTS = %i[let cast must].freeze
|
|
70
132
|
|
|
@@ -173,6 +235,69 @@ module Audition
|
|
|
173
235
|
end
|
|
174
236
|
end
|
|
175
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
|
+
|
|
176
301
|
private
|
|
177
302
|
|
|
178
303
|
# Adjacent literals ("a" "b") parse as interpolation but
|
|
@@ -192,8 +317,15 @@ module Audition
|
|
|
192
317
|
def classify_call(node)
|
|
193
318
|
return :mutable_call if fresh_string?(node) ||
|
|
194
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)
|
|
195
323
|
|
|
196
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
|
+
|
|
197
329
|
case node.name
|
|
198
330
|
when :freeze
|
|
199
331
|
classify_freeze(node, receiver)
|
|
@@ -277,8 +409,10 @@ module Audition
|
|
|
277
409
|
# String or Regexp from a call is deeply shareable.
|
|
278
410
|
case classify(receiver)
|
|
279
411
|
when :default_proc then :default_proc
|
|
412
|
+
when :unshareable_object then :unshareable_object
|
|
280
413
|
when :mutable_call then :shareable
|
|
281
414
|
when :mutable_container then frozen_kind(receiver)
|
|
415
|
+
when :fresh_container then frozen_fresh_kind(receiver)
|
|
282
416
|
when :instance_new, :opaque_call then :shallow_opaque
|
|
283
417
|
else :unknown
|
|
284
418
|
end
|
|
@@ -287,9 +421,107 @@ module Audition
|
|
|
287
421
|
end
|
|
288
422
|
end
|
|
289
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
|
+
|
|
290
513
|
def fresh_string?(node)
|
|
291
514
|
receiver = node.receiver
|
|
292
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
|
+
|
|
293
525
|
case receiver
|
|
294
526
|
when nil
|
|
295
527
|
FORMATTERS.include?(name)
|
|
@@ -312,6 +544,39 @@ module Audition
|
|
|
312
544
|
const_name(node.receiver) == "Regexp"
|
|
313
545
|
end
|
|
314
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
|
+
|
|
315
580
|
# A container holding a sync primitive can never become
|
|
316
581
|
# shareable; Ractor.make_shareable raises on it (multi_json
|
|
317
582
|
# keeps a frozen Hash of Mutexes). The classification
|
data/lib/audition/version.rb
CHANGED
data/lib/audition.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative "audition/static/gem_calls"
|
|
|
12
12
|
require_relative "audition/static/native_extensions"
|
|
13
13
|
require_relative "audition/static/analyzer"
|
|
14
14
|
require_relative "audition/dynamic/prober"
|
|
15
|
+
require_relative "audition/reconciliation"
|
|
15
16
|
require_relative "audition/report"
|
|
16
17
|
require_relative "audition/config"
|
|
17
18
|
require_relative "audition/baseline"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: audition
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Markin
|
|
@@ -103,6 +103,7 @@ files:
|
|
|
103
103
|
- lib/audition/finding.rb
|
|
104
104
|
- lib/audition/fixer.rb
|
|
105
105
|
- lib/audition/progress.rb
|
|
106
|
+
- lib/audition/reconciliation.rb
|
|
106
107
|
- lib/audition/report.rb
|
|
107
108
|
- lib/audition/report/github.rb
|
|
108
109
|
- lib/audition/report/json.rb
|
|
@@ -115,6 +116,7 @@ files:
|
|
|
115
116
|
- lib/audition/static/checks/base.rb
|
|
116
117
|
- lib/audition/static/checks/dependency_class_state.rb
|
|
117
118
|
- lib/audition/static/checks/global_variables.rb
|
|
119
|
+
- lib/audition/static/checks/instance_memoization.rb
|
|
118
120
|
- lib/audition/static/checks/mutable_constants.rb
|
|
119
121
|
- lib/audition/static/checks/ractor_isolation.rb
|
|
120
122
|
- lib/audition/static/checks/runtime_require.rb
|