audition 0.2.0 → 0.2.2

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.
@@ -4,10 +4,51 @@ require "prism"
4
4
 
5
5
  module Audition
6
6
  # Unsafe-tier, multi-site rewrites planned at fix time from a
7
- # parsed file plus its findings. Each .plan returns Autofix edits
8
- # (safety :unsafe) or, for MagicComments, a file-level edit plus
9
- # the list of checks it makes redundant.
7
+ # parsed file plus its findings. MagicComments.plan returns a
8
+ # file-level edit plus the list of checks it makes redundant.
9
+ # Memoization.plan and WriteOnce.plan return one bundle per
10
+ # converted variable group ({edits:, sites:}, safety :unsafe);
11
+ # Rewriters.resolve flattens them to edits after dropping groups
12
+ # whose edits would swallow another converted group's sites.
10
13
  module Rewriters
14
+ # A planned conversion for one variable group: the group's
15
+ # edits plus the byte spans of every recorded site, so
16
+ # cross-group nesting is visible before edits are applied.
17
+ # Nil when the group produced no edits.
18
+ def self.bundle(ops, edits)
19
+ return nil if edits.empty?
20
+
21
+ sites = ops.map do |op|
22
+ location = op[:node].location
23
+ [location.start_offset, location.end_offset]
24
+ end
25
+ {edits: edits, sites: sites}
26
+ end
27
+
28
+ # The fixer keeps the first of two overlapping edits, so an
29
+ # edit whose span contains a site of a different converted
30
+ # group would keep that nested site's old text while the rest
31
+ # of the other group moves (a stale read at runtime). Such
32
+ # groups are skipped here and keep their findings.
33
+ def self.resolve(bundles)
34
+ bundles.flat_map do |bundle|
35
+ clobbers = bundles.any? do |other|
36
+ !other.equal?(bundle) && swallows?(bundle, other)
37
+ end
38
+ clobbers ? [] : bundle[:edits]
39
+ end
40
+ end
41
+
42
+ def self.swallows?(bundle, other)
43
+ bundle[:edits].any? do |edit|
44
+ next false if edit.start_offset >= edit.end_offset
45
+
46
+ other[:sites].any? do |from, upto|
47
+ edit.start_offset <= from && upto <= edit.end_offset
48
+ end
49
+ end
50
+ end
51
+
11
52
  # -- magic comments ----------------------------------------------
12
53
 
13
54
  module MagicComments
@@ -30,23 +71,45 @@ module Audition
30
71
  classifier = Static::LiteralClassifier.new(
31
72
  frozen_string_literal: file.frozen_string_literal?
32
73
  )
33
- values = constant_values(file)
74
+ collector = constant_collector(file)
75
+ # A constant assigned here and mutated here is a
76
+ # deliberate accumulator (sinatra's PARAMS_CONFIG);
77
+ # freezing the file's constants would raise at the
78
+ # mutation site.
79
+ return nil if mutates_own_constant?(file, collector.names)
80
+
81
+ values = collector.values
34
82
  kinds = values.map { |v| classifier.classify(v) }
35
83
  flagged = kinds.reject do |k|
36
84
  %i[shareable unknown].include?(k)
37
85
  end
38
- scv_ok = values.all? do |value|
39
- deep_literal?(value, classifier)
40
- end
41
-
86
+ scv_ok = values.any? &&
87
+ values.all? { |value| deep_literal?(value, classifier) }
88
+
89
+ # Both branches demand something to fix: with no constant
90
+ # values (a lone constant-mutation finding) or nothing
91
+ # flagged, a comment would freeze unrelated code while
92
+ # fixing nothing. An explicit `frozen_string_literal:
93
+ # false` is an author opt-out and is never overridden.
42
94
  if scv_ok
43
95
  comment_plan(file, SCV_LINE)
44
- elsif !file.frozen_string_literal? &&
96
+ elsif flagged.any? &&
97
+ file.magic_comment("frozen_string_literal").nil? &&
45
98
  flagged.all? { |k| k == :mutable_string }
46
99
  comment_plan(file, FSL_LINE)
47
100
  end
48
101
  end
49
102
 
103
+ def self.mutates_own_constant?(file, assigned)
104
+ mutated = file.mutated_constants
105
+ assigned.any? do |name|
106
+ bare = name.split("::").last
107
+ mutated.any? do |m|
108
+ m == name || m.split("::").last == bare
109
+ end
110
+ end
111
+ end
112
+
50
113
  # Bare literals only: `[...].freeze` is a method call, and
51
114
  # under the magic comment Ruby raises for it at assignment
52
115
  # because the shallowly-frozen value is unshareable
@@ -82,28 +145,39 @@ module Audition
82
145
  }
83
146
  end
84
147
 
85
- def self.constant_values(file)
148
+ def self.constant_collector(file)
86
149
  collector = ConstantValues.new
87
150
  collector.visit(file.root)
88
- collector.values
151
+ collector
89
152
  end
90
153
 
91
154
  class ConstantValues < Prism::Visitor
92
- attr_reader :values
155
+ attr_reader :values, :names
93
156
 
94
157
  def initialize
95
158
  @values = []
159
+ @names = []
96
160
  super
97
161
  end
98
162
 
99
163
  %i[
100
164
  visit_constant_write_node
101
165
  visit_constant_or_write_node
166
+ ].each do |method|
167
+ define_method(method) do |node| # audition:disable unsafe-calls
168
+ @values << node.value
169
+ @names << node.name.to_s
170
+ super(node)
171
+ end
172
+ end
173
+
174
+ %i[
102
175
  visit_constant_path_write_node
103
176
  visit_constant_path_or_write_node
104
177
  ].each do |method|
105
178
  define_method(method) do |node| # audition:disable unsafe-calls
106
179
  @values << node.value
180
+ @names << node.target.location.slice
107
181
  super(node)
108
182
  end
109
183
  end
@@ -143,7 +217,7 @@ module Audition
143
217
 
144
218
  collector = SingletonIvars.new
145
219
  collector.visit(file.root)
146
- edits = []
220
+ bundles = []
147
221
  collector.groups.each do |(namespace, name), ops|
148
222
  next if namespace.empty?
149
223
 
@@ -152,7 +226,10 @@ module Audition
152
226
  next if ops.any? { |op| op[:body] }
153
227
 
154
228
  memos = memo_sites(ops)
155
- next if memos.empty?
229
+ if memos.empty?
230
+ bundles << Rewriters.bundle(ops, setter_edits(file, ops))
231
+ next
232
+ end
156
233
  next if orphan_guards?(ops, memos)
157
234
  next if guarded_with_strays?(ops, memos)
158
235
  # `@x ||= {}` is a lazily-built accumulator, mutated
@@ -160,17 +237,69 @@ module Audition
160
237
  # algorithm registry). Freezing it breaks registration
161
238
  # and Ractor-local copies would leave other Ractors an
162
239
  # empty registry; the copy-on-write refactor is human
163
- # work, so no edit is offered.
240
+ # work, so no edit is offered. Constructor memos (`new`,
241
+ # `Set.new`, `.dup`) are the same family: liquid's
242
+ # filter set and money's bank singleton both broke under
243
+ # freezing, and per-Ractor copies hide main-Ractor
244
+ # registrations.
164
245
  next if memos.any? { |m| accumulator?(m[:op][:node].value) }
246
+ next if memos.any? do |m|
247
+ constructor?(m[:op][:node].value, classifier(file))
248
+ end
165
249
 
166
250
  if freezable?(ops, memos)
167
- edits.concat(freeze_edits(file, memos))
251
+ bundles << Rewriters.bundle(ops, freeze_edits(file, memos))
168
252
  else
253
+ # Ractor-local slots are keyed by lexical owner; on a
254
+ # class the ivar is per-subclass (faraday's
255
+ # DEFAULT_OPTIONS), and one shared key would merge
256
+ # every subclass's state. Modules cannot be
257
+ # subclassed, so only module-owned state converts.
258
+ next if ops.any? { |op| op[:class_owner] }
259
+ # Deleting the defined? guard sends every call through
260
+ # the statements after the write, so the warm path
261
+ # only keeps returning the memo when the guarded write
262
+ # ends its def body (or a bare read of the same ivar
263
+ # does).
264
+ next unless memos.all? do |m|
265
+ m[:guard].nil? || tail_write?(m[:op])
266
+ end
267
+
169
268
  key = %(:"#{namespace}/#{name}")
170
- edits.concat(ractor_edits(file, ops, memos, key))
269
+ bundles << Rewriters.bundle(
270
+ ops, ractor_edits(file, ops, memos, key)
271
+ )
171
272
  end
172
273
  end
173
- edits
274
+ bundles.compact
275
+ end
276
+
277
+ def self.tail_write?(op)
278
+ body = op[:def_node]&.body
279
+ return false unless body.is_a?(Prism::StatementsNode)
280
+
281
+ statements = body.body
282
+ index = statements.index { |s| s.equal?(op[:node]) }
283
+ return false unless index
284
+
285
+ rest = statements[(index + 1)..]
286
+ return true if rest.empty?
287
+
288
+ rest.size == 1 &&
289
+ rest[0].is_a?(Prism::InstanceVariableReadNode) &&
290
+ rest[0].name == op[:node].name
291
+ end
292
+
293
+ def self.classifier(file)
294
+ Static::LiteralClassifier.new(
295
+ frozen_string_literal: file.frozen_string_literal?
296
+ )
297
+ end
298
+
299
+ def self.constructor?(value, classifier)
300
+ value.is_a?(Prism::CallNode) &&
301
+ %i[new dup clone].include?(value.name) &&
302
+ classifier.classify(value) != :shareable
174
303
  end
175
304
 
176
305
  # A memo site is an ||= write, or a plain write paired with a
@@ -195,6 +324,71 @@ module Audition
195
324
  value.is_a?(Prism::HashNode)) && value.elements.empty?
196
325
  end
197
326
 
327
+ # Config setters (`def self.backend=(value); @backend =
328
+ # value; end`) get the Rails try_make_shareable recipe in
329
+ # plain Ruby: shareable values are deeply frozen so reads
330
+ # from any Ractor become legal, unshareable values keep
331
+ # today's behavior through the rescue. Only bare local reads
332
+ # are wrapped; computed values are left for a human.
333
+ def self.setter_edits(file, ops)
334
+ receivers = nil
335
+ ops.filter_map do |op|
336
+ next unless op[:kind] == :write
337
+ # Only genuine setters: a plain method restoring a saved
338
+ # local (sinatra's route conditions) and operator defs
339
+ # ([]=) must stay untouched.
340
+ setter = op[:def_name].to_s
341
+ next unless setter.match?(/\A\w+=\z/)
342
+
343
+ value = op[:node].value
344
+ next unless value.is_a?(Prism::LocalVariableReadNode)
345
+
346
+ # A value the file mutates in place through the reader
347
+ # accessor (`def self.set(k, v); options[k] = v; end`)
348
+ # or through a direct read of the ivar must never be
349
+ # frozen: the mutation would raise FrozenError.
350
+ receivers ||= mutator_receivers(file)
351
+ reader = setter.delete_suffix("=").to_sym
352
+ mutated = receivers.any? do |receiver|
353
+ (receiver.is_a?(Prism::CallNode) &&
354
+ receiver.name == reader) ||
355
+ (receiver.is_a?(Prism::InstanceVariableReadNode) &&
356
+ receiver.name == op[:node].name)
357
+ end
358
+ next if mutated
359
+
360
+ local = value.name
361
+ Autofix.new(
362
+ start_offset: value.location.start_offset,
363
+ end_offset: value.location.end_offset,
364
+ replacement:
365
+ "(Ractor.make_shareable(#{local}) rescue #{local})",
366
+ safety: :unsafe
367
+ )
368
+ end
369
+ end
370
+
371
+ # Receivers of in-place mutator calls anywhere in the file,
372
+ # index writes included; mirrors SourceFile#mutated_constants.
373
+ def self.mutator_receivers(file)
374
+ receivers = []
375
+ queue = [file.root]
376
+ until queue.empty?
377
+ node = queue.shift
378
+ queue.concat(node.child_nodes.compact)
379
+ mutator =
380
+ (node.is_a?(Prism::CallNode) &&
381
+ Static::SourceFile::CONST_MUTATORS
382
+ .include?(node.name)) ||
383
+ Static::SourceFile::INDEX_WRITES
384
+ .any? { |type| node.is_a?(type) }
385
+ next unless mutator && node.receiver
386
+
387
+ receivers << node.receiver
388
+ end
389
+ receivers
390
+ end
391
+
198
392
  def self.guarded_with_strays?(ops, memos)
199
393
  return false if memos.none? { |memo| memo[:guard] }
200
394
 
@@ -243,12 +437,10 @@ module Audition
243
437
  # while leaving the memoization intact. Guards, sibling
244
438
  # reads, and method shapes stay untouched.
245
439
  def self.freeze_edits(file, memos)
246
- classifier = Static::LiteralClassifier.new(
247
- frozen_string_literal: file.frozen_string_literal?
248
- )
440
+ kinds = classifier(file)
249
441
  memos.filter_map do |memo|
250
442
  value = memo[:op][:node].value
251
- freeze_value(value, classifier.classify(value))
443
+ freeze_value(value, kinds.classify(value))
252
444
  end
253
445
  end
254
446
 
@@ -424,21 +616,20 @@ module Audition
424
616
  @namespace = []
425
617
  @sclass_depth = 0
426
618
  @def_stack = []
619
+ @defined_depth = 0
427
620
  super
428
621
  end
429
622
 
430
623
  def visit_class_node(node)
431
- @namespace.push(node.constant_path.location.slice)
432
- super
433
- ensure
434
- @namespace.pop
624
+ scoped(node.constant_path.location.slice, :class) do
625
+ super(node)
626
+ end
435
627
  end
436
628
 
437
629
  def visit_module_node(node)
438
- @namespace.push(node.constant_path.location.slice)
439
- super
440
- ensure
441
- @namespace.pop
630
+ scoped(node.constant_path.location.slice, :module) do
631
+ super(node)
632
+ end
442
633
  end
443
634
 
444
635
  def visit_singleton_class_node(node)
@@ -458,13 +649,26 @@ module Audition
458
649
  kind =
459
650
  node.receiver.is_a?(Prism::SelfNode) ? :self : :plain
460
651
  @def_stack.push(
461
- {kind: kind, id: node.object_id, name: node.name}
652
+ {kind: kind, id: node.object_id,
653
+ name: node.name, node: node}
462
654
  )
463
655
  super
464
656
  ensure
465
657
  @def_stack.pop
466
658
  end
467
659
 
660
+ # A defined?(@x) that is not the recognized guard idiom
661
+ # probes ivar presence; rewriting the read to Ractor
662
+ # storage would make the probe unconditionally true, so
663
+ # every op inside disqualifies its group (recorded as
664
+ # :other).
665
+ def visit_defined_node(node)
666
+ @defined_depth += 1
667
+ super
668
+ ensure
669
+ @defined_depth -= 1
670
+ end
671
+
468
672
  # Matches the guard statement of the second memoization
469
673
  # idiom: `return @x if defined?(@x)`. On a match the inner
470
674
  # reads are not visited (they belong to the guard, not to
@@ -516,9 +720,27 @@ module Audition
516
720
  checked.name
517
721
  end
518
722
 
723
+ # class/module bodies open a fresh method scope: a def
724
+ # inside a module nested under `class << self` defines an
725
+ # instance method of that module, so the surrounding
726
+ # singleton context and def stack must not leak in.
727
+ def scoped(name, kind)
728
+ saved_depth = @sclass_depth
729
+ saved_stack = @def_stack
730
+ @namespace.push({name: name, kind: kind})
731
+ @sclass_depth = 0
732
+ @def_stack = []
733
+ yield
734
+ ensure
735
+ @sclass_depth = saved_depth
736
+ @def_stack = saved_stack
737
+ @namespace.pop
738
+ end
739
+
519
740
  def record(node, kind, name)
520
741
  return if @namespace.empty?
521
742
 
743
+ kind = :other if @defined_depth.positive?
522
744
  current_def = @def_stack.last
523
745
  singleton_method =
524
746
  current_def && (
@@ -534,11 +756,14 @@ module Audition
534
756
  body = true
535
757
  end
536
758
 
537
- key = [@namespace.join("::"), name.to_s]
759
+ names = @namespace.map { |entry| entry[:name] }
760
+ key = [names.join("::"), name.to_s]
538
761
  @groups[key] << {
539
762
  node: node, kind: kind, body: body,
540
763
  def_id: current_def && current_def[:id],
541
- def_name: current_def && current_def[:name]
764
+ def_name: current_def && current_def[:name],
765
+ def_node: current_def && current_def[:node],
766
+ class_owner: @namespace.last[:kind] == :class
542
767
  }
543
768
  end
544
769
  end
@@ -552,14 +777,14 @@ module Audition
552
777
  # Unsafe because cross-file readers are invisible.
553
778
  module WriteOnce
554
779
  def self.plan(file, findings)
555
- edits = []
780
+ bundles = []
556
781
  if findings.any? { |f| f.check == "global-variables" }
557
- edits += globals(file)
782
+ bundles += globals(file)
558
783
  end
559
784
  if findings.any? { |f| f.check == "class-variables" }
560
- edits += class_variables(file)
785
+ bundles += class_variables(file)
561
786
  end
562
- edits
787
+ bundles
563
788
  end
564
789
 
565
790
  def self.globals(file)
@@ -582,8 +807,18 @@ module Audition
582
807
  classifier = Static::LiteralClassifier.new(
583
808
  frozen_string_literal: file.frozen_string_literal?
584
809
  )
585
- edits = []
810
+ # The same bare @@name under two namespaces of one file is
811
+ # usually same-file inheritance sharing one runtime
812
+ # variable; converting either copy strands the other's
813
+ # readers with a NameError.
814
+ bare = collector.groups.keys
815
+ .map { |key| key.split("/").last }
816
+ .tally
817
+ planned = []
818
+ bundles = []
586
819
  collector.groups.each do |name, ops|
820
+ next if bare[name.split("/").last] > 1
821
+
587
822
  writes = ops.select { |op| op[:kind] == :write }
588
823
  next unless writes.size == 1 && writes[0][:assignable]
589
824
  next unless (ops - writes).all? { |op| op[:kind] == :read }
@@ -591,32 +826,51 @@ module Audition
591
826
  write = writes[0][:node]
592
827
  kind = classifier.classify(write.value)
593
828
  next if kind == :sync_primitive
829
+ # A constructed object may gain singleton methods or be
830
+ # mutated later (sinatra's @@eats_errors); wrapping it
831
+ # in make_shareable freezes it and those break.
832
+ next if Memoization.constructor?(write.value, classifier)
594
833
 
595
834
  shareable = kind == :shareable
596
835
  # A mutable value that would get deep-frozen must never be
597
836
  # the receiver of a call afterwards: `X[k] = v`, `X << v`
598
- # and friends would raise FrozenError at runtime. Reads of
599
- # immutable values are safe anywhere.
837
+ # and friends would raise FrozenError at runtime. The same
838
+ # goes for reads that escape into a local (`list = $x;
839
+ # list << h`) or a call argument, where an alias can be
840
+ # mutated out of sight. Reads of immutable values are
841
+ # safe anywhere.
600
842
  unless shareable
601
- mutated = (ops - writes).any? do |op|
602
- collector.receiver_reads.include?(op[:node].object_id)
843
+ escapes = (ops - writes).any? do |op|
844
+ id = op[:node].object_id
845
+ collector.receiver_reads.include?(id) ||
846
+ collector.escaping_reads.include?(id)
603
847
  end
604
- next if mutated
848
+ next if escapes
605
849
  end
606
850
 
607
851
  constant = yield(name.split("/").last)
608
852
  next unless constant.match?(/\A[A-Z][A-Z0-9_]*\z/)
609
853
  next if collector.taken_constants.include?(constant)
610
854
 
611
- edits << Autofix.new(
855
+ # `$max` and `$MAX` both map to MAX; only the first may
856
+ # take the name, the second stays a variable.
857
+ target = [name.rpartition("/").first, constant]
858
+ next if planned.include?(target)
859
+
860
+ planned << target
861
+ edits = [Autofix.new(
612
862
  start_offset: write.name_loc.start_offset,
613
863
  end_offset: write.name_loc.end_offset,
614
864
  replacement: constant,
615
865
  safety: :unsafe
616
- )
866
+ )]
617
867
  unless shareable
618
868
  value = write.value
619
869
  source = value.location.slice
870
+ if value.is_a?(Prism::ArrayNode) &&
871
+ value.opening_loc.nil?
872
+ source = "[#{source}]"
873
+ end
620
874
  edits << Autofix.new(
621
875
  start_offset: value.location.start_offset,
622
876
  end_offset: value.location.end_offset,
@@ -633,8 +887,9 @@ module Audition
633
887
  safety: :unsafe
634
888
  )
635
889
  end
890
+ bundles << Rewriters.bundle(ops, edits)
636
891
  end
637
- edits
892
+ bundles.compact
638
893
  end
639
894
 
640
895
  # Collects either global or class variable operations.
@@ -649,16 +904,19 @@ module Audition
649
904
  Static::Checks::GlobalVariables::LOAD_PATH_GLOBALS
650
905
  ).freeze
651
906
 
652
- attr_reader :groups, :taken_constants, :receiver_reads
907
+ attr_reader :groups, :taken_constants, :receiver_reads,
908
+ :escaping_reads
653
909
 
654
910
  def initialize(mode)
655
911
  @mode = mode
656
912
  @groups = Hash.new { |h, k| h[k] = [] }
657
913
  @taken_constants = []
658
914
  @receiver_reads = {}
915
+ @escaping_reads = {}
659
916
  @namespace = []
660
917
  @def_depth = 0
661
918
  @block_depth = 0
919
+ @conditional_depth = 0
662
920
  super()
663
921
  end
664
922
 
@@ -668,9 +926,67 @@ module Audition
668
926
  receiver.is_a?(Prism::ClassVariableReadNode)
669
927
  @receiver_reads[receiver.object_id] = true
670
928
  end
929
+ arguments = node.arguments&.arguments || []
930
+ arguments.each { |argument| mark_escape(argument) }
671
931
  super
672
932
  end
673
933
 
934
+ # `list = $handlers; list << h` mutates the value through
935
+ # an alias the receiver check cannot see; reads that flow
936
+ # into a local or a call argument are recorded so mutable
937
+ # values never get deep-frozen under them.
938
+ %i[
939
+ visit_local_variable_write_node
940
+ visit_local_variable_or_write_node
941
+ visit_local_variable_and_write_node
942
+ visit_local_variable_operator_write_node
943
+ ].each do |method|
944
+ define_method(method) do |node| # audition:disable unsafe-calls
945
+ mark_escape(node.value)
946
+ super(node)
947
+ end
948
+ end
949
+
950
+ # A write that only happens on some paths (`$verbose =
951
+ # true if ENV[...]`) must stay a variable: readers get nil
952
+ # on the untaken path, where a constant would raise
953
+ # NameError.
954
+ %i[
955
+ visit_if_node
956
+ visit_unless_node
957
+ visit_case_node
958
+ visit_case_match_node
959
+ visit_while_node
960
+ visit_until_node
961
+ visit_and_node
962
+ visit_or_node
963
+ visit_rescue_modifier_node
964
+ ].each do |method|
965
+ define_method(method) do |node| # audition:disable unsafe-calls
966
+ conditionally { super(node) }
967
+ end
968
+ end
969
+
970
+ def conditionally
971
+ @conditional_depth += 1
972
+ yield
973
+ ensure
974
+ @conditional_depth -= 1
975
+ end
976
+
977
+ def visit_begin_node(node)
978
+ if node.rescue_clause
979
+ @conditional_depth += 1
980
+ begin
981
+ super
982
+ ensure
983
+ @conditional_depth -= 1
984
+ end
985
+ else
986
+ super
987
+ end
988
+ end
989
+
674
990
  def visit_class_node(node)
675
991
  @namespace.push(node.constant_path.location.slice)
676
992
  super
@@ -749,6 +1065,13 @@ module Audition
749
1065
 
750
1066
  private
751
1067
 
1068
+ def mark_escape(node)
1069
+ return unless node.is_a?(Prism::GlobalVariableReadNode) ||
1070
+ node.is_a?(Prism::ClassVariableReadNode)
1071
+
1072
+ @escaping_reads[node.object_id] = true
1073
+ end
1074
+
752
1075
  def record_gvar(node, kind)
753
1076
  name = node.name.to_s
754
1077
  return if SKIP_GLOBALS.include?(name)
@@ -756,7 +1079,7 @@ module Audition
756
1079
  @groups[name] << {
757
1080
  node: node, kind: kind,
758
1081
  assignable: @def_depth.zero? && @block_depth.zero? &&
759
- @namespace.empty?
1082
+ @namespace.empty? && @conditional_depth.zero?
760
1083
  }
761
1084
  end
762
1085
 
@@ -766,7 +1089,8 @@ module Audition
766
1089
  key = "#{@namespace.join("::")}/#{node.name}"
767
1090
  @groups[key] << {
768
1091
  node: node, kind: kind,
769
- assignable: @def_depth.zero? && @block_depth.zero?
1092
+ assignable: @def_depth.zero? && @block_depth.zero? &&
1093
+ @conditional_depth.zero?
770
1094
  }
771
1095
  end
772
1096
  end
@@ -46,7 +46,13 @@ module Audition
46
46
  end
47
47
 
48
48
  parallel_analyze(paths, workers)
49
- rescue Ractor::Error
49
+ rescue Ractor::Error => e
50
+ # The silent fallback would otherwise mask a check that is
51
+ # itself Ractor-hostile; surface it under -w.
52
+ if $VERBOSE
53
+ warn "audition: parallel scan fell back to serial: " \
54
+ "#{e.class}: #{e.message}"
55
+ end
50
56
  paths.flat_map { |path| analyze_path(path) }
51
57
  end
52
58