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.
@@ -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
- :unknown
169
- when :[], :to_set
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
- :unknown
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`. Anything
247
- # else, such as `Set.new(compute)` or a mapping block,
248
- # stays unknown.
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) : :unknown
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: everything provably shareable
312
- # gives :shareable; anything provably mutable gives
313
- # :shallow_freeze; a sync primitive poisons the whole
314
- # container; anything unknowable gives :unknown (stay silent
315
- # rather than guess).
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
- case classify(child)
321
- when :shareable then nil
322
- when :sync_primitive then return :sync_primitive
323
- when :unknown then return :unknown
324
- else verdict = :shallow_freeze
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 under ext/ are not compiled
26
- # into the extension; a fuzz target that links the VM itself
27
- # may call rb_ext_ractor_safe without the extension doing so.
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 audition. Its " \
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 (ext/<name>), anchored
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.delete_prefix("#{root}/")
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
- # ext/<name> directories holding native sources; ext/ itself
115
- # when the sources sit directly in it.
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
- ext = File.join(root, "ext")
118
- return [] unless File.directory?(ext)
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
- dirs << ext if Dir[File.join(ext, SOURCES)].any?
125
- dirs
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