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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Audition
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  end
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.0
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