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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rubydex"
4
4
  require_relative "../rewriters"
5
+ require_relative "work_split"
5
6
 
6
7
  module Audition
7
8
  module Static
@@ -34,11 +35,25 @@ module Audition
34
35
  "per-subclass values compute in the inherited hook " \
35
36
  "(guard on subclass.name for anonymous classes). For " \
36
37
  "collections, rebuild and refreeze on write, " \
37
- "copy-on-write: self.list = " \
38
- "(list + [item]).freeze; never mutate in place. As a " \
39
- "last resort use Ractor.store_if_absent for " \
40
- "per-Ractor state, or read the ivar first and proxy " \
41
- "the write to the main Ractor."
38
+ "copy-on-write: self.list = (list + [item]).freeze " \
39
+ "when every element is shareable, self.list = " \
40
+ "Ractor.make_shareable(list + [item]) when the " \
41
+ "additions may be unfrozen, since a plain freeze is " \
42
+ "shallow; never mutate in place. As a last resort use " \
43
+ "Ractor.store_if_absent for per-Ractor state, or read " \
44
+ "the ivar first and proxy the write to the main Ractor."
45
+ PROXIED_WHY =
46
+ "The read is the lock-free fast path and the write runs " \
47
+ "on the main Ractor through on_main, so no non-main " \
48
+ "Ractor ever writes class state. Every worker waiting " \
49
+ "on main serializes there, and a worker's next read " \
50
+ "raises Ractor::IsolationError unless the memoized " \
51
+ "value is shareable."
52
+ PROXIED_FIX =
53
+ "Keep the hatch rare: delete the memo or warm it at " \
54
+ "boot where possible, and make the computed value " \
55
+ "deeply frozen inside the block (.freeze or " \
56
+ "Ractor.make_shareable) so workers can read it."
42
57
  FROZEN_MEMO_WHY =
43
58
  "Every write memoizes a shareable (frozen) value, so " \
44
59
  "non-main Ractors can read it once it has been " \
@@ -65,40 +80,172 @@ module Audition
65
80
  "containers) before spawning Ractors. Configuration " \
66
81
  "that cannot be shareable needs per-Ractor state or a " \
67
82
  "main-Ractor proxy instead."
83
+ DERIVED_WHY =
84
+ "The value comes from another constant whose own " \
85
+ "definition is already flagged: freezing this one is " \
86
+ "shallow and does not change what the referent holds, " \
87
+ "so a non-main Ractor reading it can raise the same " \
88
+ "Ractor::IsolationError."
89
+ DERIVED_FIX =
90
+ "Make the referenced constant deeply shareable first; " \
91
+ "this finding follows its verdict."
92
+ CONTAINED_WHY =
93
+ "The assigned expression is flagged on this same line: " \
94
+ "the constant holds whatever unshareable value that " \
95
+ "expression produces, so a non-main Ractor reading the " \
96
+ "constant hits the same problem."
97
+ SINGLETON_SCAN_WHY =
98
+ "The receiver is a runtime value, so the graph cannot " \
99
+ "attribute the state this body writes to any class. If " \
100
+ "the receiver is one, these are class-level instance " \
101
+ "variables and a non-main Ractor raises " \
102
+ "Ractor::IsolationError writing them."
103
+ SINGLETON_SCAN_FIX =
104
+ "Open the singleton on the constant itself so the state " \
105
+ "resolves, or confirm the receiver with the dynamic " \
106
+ "probe, which reads the live object graph."
107
+ ANCESTOR_SCAN_WHY =
108
+ "The ancestor is a runtime value, so whatever it " \
109
+ "contributes—class-level state, class variables, " \
110
+ "hostile APIs—is invisible here. A clean report for " \
111
+ "this class covers only what the class itself declares."
112
+ ANCESTOR_SCAN_FIX =
113
+ "Name the superclass or module directly where the " \
114
+ "hierarchy is static. Where it cannot be, audit the " \
115
+ "candidates separately; the dynamic probe sweeps the " \
116
+ "ancestors that are actually in play."
117
+ CONSTANT_SCAN_WHY =
118
+ "The path starts from a runtime value, so the constant " \
119
+ "this assignment defines holds something the shareability " \
120
+ "checks never saw."
121
+ CONSTANT_SCAN_FIX =
122
+ "Reference the constant by its static path, or let the " \
123
+ "dynamic probe check the value with Ractor.shareable?."
124
+
125
+ # Findings from these per-file checks seed the derived-
126
+ # constant propagation.
127
+ PROPAGATED_CHECKS = [
128
+ "mutable-constants", "unshareable-reads", "native-gem-calls"
129
+ ].freeze
130
+
131
+ # Checks that flag an expression rather than the constant
132
+ # it may be assigned to; when such a finding sits inside a
133
+ # constant assignment, the constant inherits it by name.
134
+ EXPRESSION_CHECKS =
135
+ ["unshareable-reads", "native-gem-calls"].freeze
136
+
137
+ # The expressions rubydex reports as unresolvable. Each is a
138
+ # hole in the walks above, so where the shape could hide
139
+ # something they look for, the hole is reported. rubydex's
140
+ # parse rules are left out—the per-file syntax check already
141
+ # reports those—as are its visibility rules, which say
142
+ # nothing about Ractors.
143
+ SCAN_RULES = {
144
+ "DynamicSingletonDefinition" => :singleton,
145
+ "DynamicAncestor" => :ancestor,
146
+ "DynamicConstantReference" => :constant
147
+ }.freeze
148
+
149
+ MIXINS = ["include", "extend", "prepend"].freeze
150
+
151
+ STATE_WRITES = [
152
+ Prism::InstanceVariableWriteNode,
153
+ Prism::InstanceVariableOrWriteNode,
154
+ Prism::InstanceVariableAndWriteNode,
155
+ Prism::InstanceVariableOperatorWriteNode,
156
+ Prism::ClassVariableWriteNode,
157
+ Prism::ClassVariableOrWriteNode,
158
+ Prism::ClassVariableAndWriteNode,
159
+ Prism::ClassVariableOperatorWriteNode
160
+ ].freeze
68
161
 
69
162
  # @param sources [Hash{String => String}] path => source
163
+ # @param constant_findings [Array<Finding>] per-file findings
164
+ # whose definition sites seed derived-constant propagation
165
+ # @param progress [Progress]
70
166
  # @return [Array<Finding>]
71
- def analyze_sources(sources)
167
+ def analyze_sources(sources, constant_findings: [],
168
+ workers: nil, progress: Progress::SILENT)
72
169
  graph = Rubydex::Graph.new
170
+ progress.stage("indexing", total: sources.size)
73
171
  sources.each do |path, code|
172
+ progress.tick
74
173
  graph.index_source(path, code, "ruby")
75
174
  end
76
175
  @sources = sources
176
+ @workers = workers
77
177
  @frozen_memos = frozen_memo_map(sources)
78
- audit(graph)
178
+ @reported_lines = constant_findings
179
+ .map { |f| [f.path, f.line] }.to_set
180
+ @constant_findings = constant_findings.select do |f|
181
+ PROPAGATED_CHECKS.include?(f.check)
182
+ end
183
+ audit(graph, progress)
79
184
  end
80
185
 
81
186
  # rubydex's index_all descends directories but skips bare file
82
187
  # lists, so files are fed through index_source individually.
83
188
  #
84
189
  # @param paths [Array<String>] files to index and audit
190
+ # @param constant_findings [Array<Finding>] see
191
+ # {#analyze_sources}
192
+ # @param progress [Progress]
85
193
  # @return [Array<Finding>]
86
- def analyze_paths(paths)
194
+ def analyze_paths(paths, constant_findings: [], workers: nil,
195
+ progress: Progress::SILENT)
87
196
  sources = {}
197
+ progress.stage("reading", total: paths.size)
88
198
  paths.each do |path|
199
+ progress.tick
89
200
  sources[path] = File.read(path)
90
201
  rescue SystemCallError
91
202
  next
92
203
  end
93
- analyze_sources(sources)
204
+ analyze_sources(sources, constant_findings: constant_findings,
205
+ workers: workers, progress: progress)
206
+ end
207
+
208
+ # Names one slice of a scan declares, for a parallel audit to
209
+ # merge before any walk emits.
210
+ #
211
+ # @api private
212
+ # @param sources [Hash{String => String}] path => source
213
+ # @return [Hash{Symbol => Object}]
214
+ def gathered_names(sources)
215
+ @sources = sources
216
+ {writers: declared_writers, declared: declared_names,
217
+ extended: extended_names}
218
+ end
219
+
220
+ # The class-state walks over one slice, against names
221
+ # gathered from the whole scan.
222
+ #
223
+ # @api private
224
+ # @param sources [Hash{String => String}] path => source
225
+ # @param seen [Set] path/line pairs a declaration claimed
226
+ # @param names [Hash] from {#gathered_names}, merged
227
+ # @return [Array<Array<Finding>>] one batch per walk
228
+ def walk_batches(sources, seen, names)
229
+ @sources = sources
230
+ [singleton_attr_findings,
231
+ extended_module_findings(seen, names[:extended]),
232
+ dynamic_ivar_findings(seen, names[:declared]),
233
+ attribute_write_findings(seen, names[:writers])]
94
234
  end
95
235
 
96
236
  private
97
237
 
98
- def audit(graph)
238
+ # Resolution is one opaque call inside rubydex; everything
239
+ # after it walks declarations rather than files, so the stage
240
+ # names say which pass a waiting reader is watching.
241
+ def audit(graph, progress = Progress::SILENT)
242
+ progress.stage("resolving")
99
243
  graph.resolve
100
244
  findings = []
101
- graph.declarations.each do |decl|
245
+ declarations = graph.declarations
246
+ progress.stage("declarations", total: declarations.size)
247
+ declarations.each do |decl|
248
+ progress.tick
102
249
  case decl
103
250
  when Rubydex::ClassVariable
104
251
  findings.concat(class_variable_findings(decl))
@@ -106,9 +253,501 @@ module Audition
106
253
  findings.concat(class_state_findings(decl))
107
254
  end
108
255
  end
256
+ seen = findings.map { |f| [f.path, f.line] }.to_set
257
+ class_state_batches(seen, progress).each do |batch|
258
+ findings.concat(batch)
259
+ batch.each { |f| seen << [f.path, f.line] }
260
+ end
261
+ progress.stage("constants")
262
+ findings.concat(derived_constant_findings(graph))
263
+ progress.stage("diagnostics")
264
+ findings.concat(static_scan_findings(graph, findings))
109
265
  findings.sort_by { |f| [f.path, f.line] }
110
266
  end
111
267
 
268
+ # rubydex says which expressions it could not resolve; this
269
+ # turns the ones that matter into findings, so a blind spot
270
+ # reads as a blind spot rather than as a clean line. A
271
+ # diagnostic is dropped where something else already reported
272
+ # that line, and where the shape cannot hide what the walks
273
+ # look for.
274
+ def static_scan_findings(graph, found)
275
+ reported = @reported_lines +
276
+ found.map { |f| [f.path, f.line] }
277
+ seen = Set.new
278
+ graph.diagnostics.filter_map do |diagnostic|
279
+ kind = SCAN_RULES[diagnostic.rule.rule_name]
280
+ next unless kind
281
+
282
+ path = path_from_uri(diagnostic.location.uri)
283
+ line = diagnostic.location.start_line + 1
284
+ next unless seen.add?([path, line, kind])
285
+ next if reported.include?([path, line])
286
+
287
+ scan_finding(kind, path, line)
288
+ end
289
+ end
290
+
291
+ def scan_finding(kind, path, line)
292
+ root = diagnostic_root(path)
293
+ return nil unless root
294
+
295
+ case kind
296
+ when :singleton then singleton_scan(root, path, line)
297
+ when :ancestor then ancestor_scan(root, path, line)
298
+ else constant_scan(root, path, line)
299
+ end
300
+ end
301
+
302
+ # Only the files a diagnostic points at are parsed here. The
303
+ # walks may have run in Ractors, whose trees never reach this
304
+ # one, and the shapes are rare enough that re-reading the
305
+ # whole tree would cost more than the checks on it.
306
+ def diagnostic_root(path)
307
+ @diagnostic_roots ||= {}
308
+ return @diagnostic_roots[path] if @diagnostic_roots.key?(path)
309
+
310
+ code = @sources[path]
311
+ file = code && SourceFile.new(source: code, path: path)
312
+ @diagnostic_roots[path] = file&.valid_syntax? ? file.root : nil
313
+ end
314
+
315
+ # A runtime singleton target only matters when the body it
316
+ # opens writes state: that is what the graph could not
317
+ # attribute to a class.
318
+ def singleton_scan(root, path, line)
319
+ node = node_at(root, line) do |candidate|
320
+ candidate.is_a?(Prism::SingletonClassNode) ||
321
+ (candidate.is_a?(Prism::DefNode) && candidate.receiver)
322
+ end
323
+ return nil unless node && state_writes?(node)
324
+
325
+ scan(path, line, severity: :warning,
326
+ message: "class-level state behind an unresolved " \
327
+ "singleton receiver",
328
+ why: SINGLETON_SCAN_WHY, fix: SINGLETON_SCAN_FIX)
329
+ end
330
+
331
+ # A runtime superclass always defines a class. A runtime
332
+ # mixin argument only names an ancestor inside a class or
333
+ # module body: a matcher called `include` in a test reads the
334
+ # same way to the graph and has to stay silent.
335
+ def ancestor_scan(root, path, line)
336
+ klass = node_at(root, line) do |candidate|
337
+ candidate.is_a?(Prism::ClassNode) && candidate.superclass &&
338
+ constant_slice(candidate.superclass).nil?
339
+ end
340
+ mixin = klass ? nil : body_mixins(path, root)[line]
341
+ return nil unless klass || mixin
342
+
343
+ subject =
344
+ if klass
345
+ "superclass of #{klass.constant_path.slice}"
346
+ else
347
+ "#{mixin[0]} argument in #{mixin[1]}"
348
+ end
349
+ scan(path, line, severity: :warning,
350
+ message: "unresolved #{subject}",
351
+ why: ANCESTOR_SCAN_WHY, fix: ANCESTOR_SCAN_FIX)
352
+ end
353
+
354
+ # A runtime constant path only matters where it defines a
355
+ # constant: elsewhere there is no name whose shareability
356
+ # anything would have checked.
357
+ def constant_scan(root, path, line)
358
+ name = constant_assigned_at(root, line)
359
+ return nil unless name
360
+
361
+ scan(path, line, severity: :info,
362
+ message: "unresolved constant path in #{name}",
363
+ why: CONSTANT_SCAN_WHY, fix: CONSTANT_SCAN_FIX)
364
+ end
365
+
366
+ def scan(path, line, severity:, message:, why:, fix:)
367
+ Finding.new(
368
+ check: "static-scan",
369
+ severity: severity,
370
+ message: message,
371
+ why: why,
372
+ fix: fix,
373
+ path: path,
374
+ line: line,
375
+ source: source_line(path, line)
376
+ )
377
+ end
378
+
379
+ def node_at(root, line)
380
+ queue = [root]
381
+ until queue.empty?
382
+ node = queue.shift
383
+ if node.location.start_line == line && yield(node)
384
+ return node
385
+ end
386
+
387
+ queue.concat(node.compact_child_nodes)
388
+ end
389
+ nil
390
+ end
391
+
392
+ # Nested classes and modules are skipped: their state is
393
+ # their own, and the graph resolves it.
394
+ def state_writes?(node)
395
+ queue = node.compact_child_nodes
396
+ until queue.empty?
397
+ child = queue.shift
398
+ return true if STATE_WRITES.include?(child.class)
399
+ next if child.is_a?(Prism::ClassNode) ||
400
+ child.is_a?(Prism::ModuleNode)
401
+
402
+ queue.concat(child.compact_child_nodes)
403
+ end
404
+ false
405
+ end
406
+
407
+ def body_mixins(path, root)
408
+ @body_mixins ||= {}
409
+ @body_mixins[path] ||= collect_body_mixins(root)
410
+ end
411
+
412
+ # Method and block bodies are skipped: a receiverless call in
413
+ # one is not a mixin into the enclosing class.
414
+ def collect_body_mixins(root)
415
+ found = {}
416
+ queue = root.compact_child_nodes.map { |node| [node, nil] }
417
+ until queue.empty?
418
+ node, owner = queue.shift
419
+ case node
420
+ when Prism::ClassNode, Prism::ModuleNode
421
+ owner = node.constant_path.slice
422
+ when Prism::DefNode, Prism::BlockNode, Prism::LambdaNode
423
+ next
424
+ when Prism::CallNode
425
+ record_mixin(found, node, owner)
426
+ end
427
+ queue.concat(
428
+ node.compact_child_nodes.map { |child| [child, owner] }
429
+ )
430
+ end
431
+ found
432
+ end
433
+
434
+ # Keyed by every line the call spans, since the diagnostic
435
+ # points at the argument rather than the call.
436
+ def record_mixin(found, node, owner)
437
+ return unless owner && node.receiver.nil? &&
438
+ MIXINS.include?(node.name.to_s)
439
+ return if resolved_mixin?(node)
440
+
441
+ location = node.location
442
+ (location.start_line..location.end_line).each do |line|
443
+ found[line] ||= [node.name.to_s, owner]
444
+ end
445
+ end
446
+
447
+ # The graph gave up on the line, but if every argument
448
+ # names a constant the walks read it anyway, and saying
449
+ # otherwise would contradict the finding they emit.
450
+ def resolved_mixin?(node)
451
+ arguments = Array(node.arguments&.arguments)
452
+ arguments.any? &&
453
+ arguments.all? { |arg| constant_slice(arg) }
454
+ end
455
+
456
+ def constant_assigned_at(root, line)
457
+ queue = [root]
458
+ until queue.empty?
459
+ node = queue.shift
460
+ location = node.location
461
+ next unless line
462
+ .between?(location.start_line, location.end_line)
463
+
464
+ name = constant_target_name(node)
465
+ return name if name
466
+
467
+ queue.concat(node.compact_child_nodes)
468
+ end
469
+ nil
470
+ end
471
+
472
+ # The constant a node assigns, nil for anything else.
473
+ def constant_target_name(node)
474
+ case node
475
+ when Prism::ConstantWriteNode, Prism::ConstantOrWriteNode
476
+ node.name.to_s
477
+ when Prism::ConstantPathWriteNode,
478
+ Prism::ConstantPathOrWriteNode
479
+ node.target.location.slice
480
+ end
481
+ end
482
+
483
+ # Below this many files the Ractor spawn and the copy of the
484
+ # sources cost more than the walks they divide.
485
+ PARALLEL_THRESHOLD = 100
486
+
487
+ # Every batch is computed against the declaration findings
488
+ # alone, so the dedup set only suppresses lines a declaration
489
+ # already claimed. Each walks every parsed source once, which
490
+ # is why they are named separately.
491
+ def class_state_batches(seen, progress)
492
+ workers = @workers || WorkSplit.workers
493
+ if @sources.size < PARALLEL_THRESHOLD || workers <= 1
494
+ serial_batches(seen, progress)
495
+ else
496
+ parallel_batches(seen, progress, workers)
497
+ end
498
+ rescue Ractor::Error, Ractor::ClosedError => e
499
+ # The silent fallback would otherwise mask a walk that is
500
+ # itself Ractor-hostile; surface it under -w. ClosedError
501
+ # is not a Ractor::Error: it is what sending to a worker
502
+ # that already died raises.
503
+ if $VERBOSE
504
+ warn "Audition: parallel graph audit fell back to " \
505
+ "serial: #{e.class}: #{e.message}"
506
+ end
507
+ progress.ractors = nil
508
+ serial_batches(seen, progress)
509
+ end
510
+
511
+ def serial_batches(seen, progress)
512
+ progress.stage("parsing", total: @sources.size)
513
+ source_roots(progress)
514
+ progress.stage("singletons")
515
+ singletons = singleton_attr_findings
516
+ progress.stage("extends")
517
+ extended = extended_module_findings(seen)
518
+ progress.stage("ivars")
519
+ ivars = dynamic_ivar_findings(seen)
520
+ progress.stage("writers")
521
+ [singletons, extended, ivars,
522
+ attribute_write_findings(seen)]
523
+ end
524
+
525
+ # A walk may only emit once every name the whole scan
526
+ # declares is known, so the workers run in two rounds: gather
527
+ # the names of a slice, then walk it against the merged set.
528
+ # Parsed trees cannot cross a Ractor boundary, so the workers
529
+ # stay alive between the rounds and keep theirs.
530
+ def parallel_batches(seen, progress, workers)
531
+ experimental = Warning[:experimental]
532
+ Warning[:experimental] = false
533
+ port = Ractor::Port.new
534
+ chunks = chunks_for(workers)
535
+ progress.ractors = chunks.size
536
+ progress.stage("parsing")
537
+ ractors = chunks.map { |paths| worker(paths, port) }
538
+ names = merge_names(gather(port, ractors))
539
+ progress.stage("state")
540
+ claimed = seen.to_a
541
+ ractors.each { |ractor| ractor.send([claimed, names]) }
542
+ collect_batches(ractors)
543
+ ensure
544
+ Warning[:experimental] = experimental
545
+ end
546
+
547
+ def chunks_for(workers)
548
+ WorkSplit.chunks(
549
+ @sources.map { |path, code| [path, code.bytesize] }, workers
550
+ )
551
+ end
552
+
553
+ def worker(paths, port)
554
+ Ractor.new(slice(paths), port) do |sources, out|
555
+ audit = GraphAudit.new
556
+ begin
557
+ names = audit.gathered_names(sources)
558
+ ensure
559
+ # Sent from `ensure` so a gather that raises still
560
+ # releases the main Ractor; `Ractor#value` reports why.
561
+ out.send(names)
562
+ end
563
+ # Ruby cannot copy a Set whose elements are compound, so
564
+ # the claimed lines travel as pairs.
565
+ claimed, gathered = Ractor.receive
566
+ audit.walk_batches(sources, claimed.to_set, gathered)
567
+ end
568
+ end
569
+
570
+ def gather(port, ractors)
571
+ parts = ractors.map { port.receive }
572
+ # nil stands in for a worker that died gathering; asking
573
+ # for its value raises what killed it.
574
+ ractors.each(&:value) if parts.any?(&:nil?)
575
+ parts
576
+ end
577
+
578
+ # Each worker returns one batch per walk, in the order
579
+ # {#serial_batches} runs them; the batches concatenate walk
580
+ # by walk so the dedup a batch carries still holds.
581
+ def collect_batches(ractors)
582
+ ractors.map(&:value).transpose.map { |batch| batch.flatten(1) }
583
+ end
584
+
585
+ def slice(paths)
586
+ paths.to_h { |path| [path, @sources[path]] }
587
+ end
588
+
589
+ def merge_names(parts)
590
+ merged = {writers: {}, declared: Set.new, extended: Set.new}
591
+ parts.each do |part|
592
+ part[:writers].each do |owner, ivars|
593
+ (merged[:writers][owner] ||= Set.new).merge(ivars)
594
+ end
595
+ merged[:declared].merge(part[:declared])
596
+ merged[:extended].merge(part[:extended])
597
+ end
598
+ merged
599
+ end
600
+
601
+ # A constant defined from another constant—an alias
602
+ # (`DEFAULT = PRIMARY`) or a frozen container of references
603
+ # (`ALL = [A, B].freeze`)—inherits the referent's problem,
604
+ # which the per-file classifier cannot see. The graph knows
605
+ # every reference, so flagged definitions propagate to any
606
+ # constant assignment that references them, transitively.
607
+ def derived_constant_findings(graph)
608
+ flagged = {}
609
+ @constant_findings.each do |f|
610
+ flagged[[f.path, f.line]] ||= f
611
+ end
612
+ return [] if flagged.empty?
613
+
614
+ spans = assignment_spans
615
+ by_site = constant_declarations_by_site(graph)
616
+ results = []
617
+ queue = flagged.keys.flat_map { |site| by_site[site] }.uniq
618
+ contained_findings(spans, flagged, results) do |site|
619
+ queue.concat(by_site[site])
620
+ end
621
+ until queue.empty?
622
+ decl = queue.shift
623
+ source = flagged_source(decl, flagged)
624
+ next unless source
625
+
626
+ decl.references.each do |ref|
627
+ path = path_from_uri(ref.location.uri)
628
+ line = ref.location.start_line + 1
629
+ span = spans[path].find { |s| s[:lines].cover?(line) }
630
+ next unless span
631
+
632
+ site = [path, span[:line]]
633
+ next if flagged.key?(site)
634
+
635
+ finding = Finding.new(
636
+ check: "derived-constants",
637
+ severity: source.severity,
638
+ message: "constant #{span[:name]} references " \
639
+ "#{decl.name}, itself flagged",
640
+ why: DERIVED_WHY,
641
+ fix: DERIVED_FIX,
642
+ path: path,
643
+ line: span[:line],
644
+ source: source_line(path, span[:line])
645
+ )
646
+ flagged[site] = finding
647
+ results << finding
648
+ queue.concat(by_site[site])
649
+ end
650
+ end
651
+ results
652
+ end
653
+
654
+ # An expression-check finding inside a constant assignment
655
+ # marks the constant itself: the assignment captures the
656
+ # flagged value under a name the graph can then follow.
657
+ def contained_findings(spans, flagged, results)
658
+ emitted = Set.new
659
+ flagged.to_a.each do |(path, line), seed|
660
+ next unless EXPRESSION_CHECKS.include?(seed.check)
661
+
662
+ span = spans[path].find { |s| s[:lines].cover?(line) }
663
+ next unless span
664
+
665
+ site = [path, span[:line]]
666
+ next unless emitted.add?(site)
667
+
668
+ already = flagged[site]
669
+ next if already && !EXPRESSION_CHECKS.include?(already.check)
670
+
671
+ finding = Finding.new(
672
+ check: "derived-constants",
673
+ severity: seed.severity,
674
+ message: "constant #{span[:name]} is assigned a " \
675
+ "value flagged on this line",
676
+ why: CONTAINED_WHY,
677
+ fix: DERIVED_FIX,
678
+ path: path,
679
+ line: span[:line],
680
+ source: source_line(path, span[:line])
681
+ )
682
+ flagged[site] ||= finding
683
+ results << finding
684
+ yield site
685
+ end
686
+ end
687
+
688
+ def constant_declarations_by_site(graph)
689
+ by_site = Hash.new { |h, k| h[k] = [] }
690
+ graph.declarations.each do |decl|
691
+ next unless constant_declaration?(decl)
692
+
693
+ each_local_definition(decl).each do |defn|
694
+ site = [path_from_uri(defn.location.uri),
695
+ defn.location.start_line + 1]
696
+ by_site[site] << decl
697
+ end
698
+ end
699
+ by_site
700
+ end
701
+
702
+ # rubydex leaves a declaration as a Todo when some reference
703
+ # keeps it from settling on a type. One with a constant name
704
+ # still carries definitions and references, and propagation
705
+ # already requires a flagged definition site plus references
706
+ # inside constant assignments, so it qualifies.
707
+ def constant_declaration?(decl)
708
+ case decl
709
+ when Rubydex::Constant, Rubydex::ConstantAlias
710
+ true
711
+ when Rubydex::Todo
712
+ decl.name.split("::").last&.match?(/\A[A-Z]/) || false
713
+ else
714
+ false
715
+ end
716
+ end
717
+
718
+ def flagged_source(decl, flagged)
719
+ each_local_definition(decl).filter_map do |defn|
720
+ flagged[[path_from_uri(defn.location.uri),
721
+ defn.location.start_line + 1]]
722
+ end.first
723
+ end
724
+
725
+ # Line spans of every constant assignment, so a reference
726
+ # landing inside one attributes to the constant it defines.
727
+ def assignment_spans
728
+ spans = Hash.new { |h, k| h[k] = [] }
729
+ @sources.each do |path, code|
730
+ file = SourceFile.new(source: code, path: path)
731
+ next unless file.valid_syntax?
732
+
733
+ queue = [file.root]
734
+ until queue.empty?
735
+ node = queue.shift
736
+ queue.concat(node.child_nodes.compact)
737
+ name = constant_target_name(node)
738
+ next unless name
739
+
740
+ location = node.location
741
+ spans[path] << {
742
+ lines: (location.start_line..location.end_line),
743
+ line: location.start_line,
744
+ name: name
745
+ }
746
+ end
747
+ end
748
+ spans
749
+ end
750
+
112
751
  def class_variable_findings(decl)
113
752
  variable = decl.name.split("#").last
114
753
  owner = display_owner(decl.owner)
@@ -135,6 +774,7 @@ module Audition
135
774
  finding_at(
136
775
  defn,
137
776
  check: "class-level-state",
777
+ subject: "#{owner}/#{variable}",
138
778
  severity: :info,
139
779
  message: "frozen memoization #{variable} on " \
140
780
  "#{owner}; warm it on the main Ractor",
@@ -145,16 +785,29 @@ module Audition
145
785
  finding_at(
146
786
  defn,
147
787
  check: "class-level-state",
788
+ subject: "#{owner}/#{variable}",
148
789
  severity: :warning,
149
790
  message: "best-effort frozen state #{variable} " \
150
791
  "on #{owner}",
151
792
  why: BEST_EFFORT_WHY,
152
793
  fix: BEST_EFFORT_FIX
153
794
  )
795
+ when :proxied, :proxied_frozen
796
+ finding_at(
797
+ defn,
798
+ check: "class-level-state",
799
+ subject: "#{owner}/#{variable}",
800
+ severity: (verdict == :proxied) ? :warning : :info,
801
+ message: "memoization #{variable} on #{owner} " \
802
+ "proxied to the main Ractor",
803
+ why: PROXIED_WHY,
804
+ fix: PROXIED_FIX
805
+ )
154
806
  else
155
807
  finding_at(
156
808
  defn,
157
809
  check: "class-level-state",
810
+ subject: "#{owner}/#{variable}",
158
811
  message: "class-level instance variable " \
159
812
  "#{variable} on #{owner}",
160
813
  why: STATE_WHY,
@@ -164,6 +817,428 @@ module Audition
164
817
  end
165
818
  end
166
819
 
820
+ # Parsed once: the passes below all walk every file.
821
+ def source_roots(progress = Progress::SILENT)
822
+ @source_roots ||= @sources.filter_map do |path, code|
823
+ progress.tick
824
+ file = SourceFile.new(source: code, path: path)
825
+ [path, file.root] if file.valid_syntax?
826
+ end.to_h
827
+ end
828
+
829
+ # An attribute writer on a singleton class declares
830
+ # class-level state the same way an assignment does, but
831
+ # no line assigns the ivar, so the graph never sees it.
832
+ # A reader alone is left to the graph: whatever writes
833
+ # the ivar is already a declaration.
834
+ SINGLETON_ATTRS = ["attr_accessor", "attr_writer"].freeze
835
+
836
+ def singleton_attr_findings
837
+ singleton_attrs.flat_map do |path, attrs|
838
+ attrs.map do |owner, ivar, line|
839
+ Finding.new(
840
+ check: "class-level-state",
841
+ severity: :error,
842
+ message: "class-level instance variable " \
843
+ "@#{ivar} on #{owner}",
844
+ why: STATE_WHY,
845
+ fix: STATE_FIX,
846
+ path: path,
847
+ line: line,
848
+ source: source_line(path, line),
849
+ subject: "#{owner}/@#{ivar}"
850
+ )
851
+ end
852
+ end
853
+ end
854
+
855
+ def singleton_attrs
856
+ @singleton_attrs ||= source_roots.to_h do |path, root|
857
+ attrs = []
858
+ walk_singletons(root, [], attrs)
859
+ [path, attrs]
860
+ end
861
+ end
862
+
863
+ # Assigning a declared singleton attribute writes the
864
+ # class-level instance variable behind it, which a non-main
865
+ # Ractor cannot do at all. The declaration is flagged where
866
+ # it sits; this is the assignment, which is a line of its
867
+ # own. Readers are left out: a read raises only on an
868
+ # unshareable value, and the declaration already says so.
869
+ def attribute_write_findings(seen, writers = declared_writers)
870
+ return [] if writers.empty?
871
+
872
+ source_roots.flat_map do |path, root|
873
+ calls = []
874
+ collect_writes(root, writers, calls)
875
+ calls.filter_map do |owner, name, line|
876
+ next if seen.include?([path, line])
877
+
878
+ Finding.new(
879
+ check: "class-level-state",
880
+ severity: :error,
881
+ message: "class-level instance variable " \
882
+ "@#{name} on #{owner}",
883
+ why: STATE_WHY,
884
+ fix: STATE_FIX,
885
+ path: path,
886
+ line: line,
887
+ source: source_line(path, line),
888
+ subject: "#{owner}/@#{name}"
889
+ )
890
+ end
891
+ end
892
+ end
893
+
894
+ # Attribute names by their owner's last segment: a write
895
+ # site and the declaration rarely spell the path the same
896
+ # way.
897
+ def declared_writers
898
+ writers = {}
899
+ singleton_attrs.each_value do |attrs|
900
+ attrs.each do |owner, ivar, _|
901
+ key = owner.split("::").last
902
+ (writers[key] ||= Set.new) << ivar
903
+ end
904
+ end
905
+ writers
906
+ end
907
+
908
+ def collect_writes(node, writers, out)
909
+ if node.is_a?(Prism::CallNode)
910
+ name = node.name.to_s
911
+ owner = name.end_with?("=") &&
912
+ constant_slice(node.receiver)
913
+ if owner && writers[owner.split("::").last]
914
+ &.include?(name.chomp("="))
915
+ out << [owner, name.chomp("="),
916
+ node.location.start_line]
917
+ end
918
+ end
919
+ node.compact_child_nodes.each do |child|
920
+ collect_writes(child, writers, out)
921
+ end
922
+ end
923
+
924
+ # Extending a module makes its instance methods run with
925
+ # a class as self, so the ivars they assign live on that
926
+ # class. Which class is not knowable from the module, so
927
+ # the finding lands on the assignment.
928
+ IVAR_WRITES = [
929
+ Prism::InstanceVariableWriteNode,
930
+ Prism::InstanceVariableOrWriteNode,
931
+ Prism::InstanceVariableAndWriteNode,
932
+ Prism::InstanceVariableOperatorWriteNode
933
+ ].freeze
934
+
935
+ def extended_module_findings(seen, names = extended_names)
936
+ return [] if names.empty?
937
+
938
+ source_roots.flat_map do |path, root|
939
+ writes = []
940
+ walk_extended(root, [], names, nil, writes)
941
+ writes.filter_map do |owner, ivar, line|
942
+ next if seen.include?([path, line])
943
+
944
+ Finding.new(
945
+ check: "class-level-state",
946
+ severity: :error,
947
+ message: "class-level instance variable " \
948
+ "#{ivar} on #{owner}",
949
+ why: STATE_WHY,
950
+ fix: STATE_FIX,
951
+ path: path,
952
+ line: line,
953
+ source: source_line(path, line),
954
+ subject: "#{owner}/#{ivar}"
955
+ )
956
+ end
957
+ end
958
+ end
959
+
960
+ # Writing an instance variable through its name reaches
961
+ # the same class-level state an assignment does, and the
962
+ # graph indexes assignments only. Verified on Ruby 4.0:
963
+ # set and remove always raise in a non-main Ractor, while
964
+ # get raises only on an unshareable value, so it rides on
965
+ # the declaration the way a plain read does.
966
+ DYNAMIC_WRITES = Ractor.make_shareable(
967
+ Set.new(%i[instance_variable_set remove_instance_variable])
968
+ )
969
+
970
+ # Ruby hands these hooks the class that triggered them.
971
+ CLASS_HOOKS = Ractor.make_shareable(
972
+ Set.new(%i[inherited included extended prepended])
973
+ )
974
+
975
+ def dynamic_ivar_findings(seen, declared = declared_names)
976
+ source_roots.flat_map do |path, root|
977
+ writes = []
978
+ walk_dynamic(root, Context.new([], false, false, {}),
979
+ declared, writes)
980
+ writes.filter_map do |owner, ivar, line|
981
+ next if seen.include?([path, line])
982
+
983
+ Finding.new(
984
+ check: "class-level-state",
985
+ severity: :error,
986
+ message: "class-level instance variable " \
987
+ "#{ivar} on #{owner}",
988
+ why: STATE_WHY,
989
+ fix: STATE_FIX,
990
+ path: path,
991
+ line: line,
992
+ source: source_line(path, line),
993
+ subject: "#{owner}/#{ivar}"
994
+ )
995
+ end
996
+ end
997
+ end
998
+
999
+ # Every class and module the target declares, by last name
1000
+ # segment: a receiver spelled one way at the call site and
1001
+ # another at the definition still names the same thing.
1002
+ def declared_names
1003
+ names = Set.new
1004
+ source_roots.each_value do |root|
1005
+ collect_declared(root, names)
1006
+ end
1007
+ names
1008
+ end
1009
+
1010
+ def collect_declared(node, names)
1011
+ case node
1012
+ when Prism::ClassNode, Prism::ModuleNode
1013
+ names << node.constant_path.slice.split("::").last
1014
+ end
1015
+ node.compact_child_nodes.each do |child|
1016
+ collect_declared(child, names)
1017
+ end
1018
+ end
1019
+
1020
+ # nesting: enclosing class and module names.
1021
+ # singleton: whether self is a class or module here.
1022
+ # sclass: whether a bare def here defines a class method.
1023
+ # hooks: locals a class hook bound to a class.
1024
+ Context = Struct.new(:nesting, :singleton, :sclass, :hooks)
1025
+
1026
+ def walk_dynamic(node, context, declared, out)
1027
+ context = descend_dynamic(node, context)
1028
+ if node.is_a?(Prism::CallNode)
1029
+ record_dynamic(node, context, declared, out)
1030
+ end
1031
+ node.compact_child_nodes.each do |child|
1032
+ walk_dynamic(child, context, declared, out)
1033
+ end
1034
+ end
1035
+
1036
+ # A class body has the class as self, but a bare def in
1037
+ # one defines an instance method, where self is not. Only
1038
+ # a receiver or an enclosing singleton class makes it a
1039
+ # class method again.
1040
+ def descend_dynamic(node, context)
1041
+ case node
1042
+ when Prism::ClassNode, Prism::ModuleNode
1043
+ Context.new(
1044
+ context.nesting + [node.constant_path.slice],
1045
+ true, false, {}
1046
+ )
1047
+ when Prism::SingletonClassNode
1048
+ Context.new(context.nesting, true, true, context.hooks)
1049
+ when Prism::DefNode
1050
+ singleton = !node.receiver.nil? || context.sclass
1051
+ Context.new(context.nesting, singleton, false,
1052
+ singleton ? hook_locals(node) : {})
1053
+ else
1054
+ context
1055
+ end
1056
+ end
1057
+
1058
+ def hook_locals(node)
1059
+ return {} unless CLASS_HOOKS.include?(node.name)
1060
+
1061
+ first = node.parameters&.requireds&.first
1062
+ return {} unless first.is_a?(Prism::RequiredParameterNode)
1063
+
1064
+ {first.name => true}
1065
+ end
1066
+
1067
+ def record_dynamic(node, context, declared, out)
1068
+ return unless DYNAMIC_WRITES.include?(node.name)
1069
+
1070
+ name = node.arguments&.arguments&.first
1071
+ return unless name.is_a?(Prism::SymbolNode) &&
1072
+ name.unescaped.start_with?("@")
1073
+
1074
+ owner = dynamic_owner(node.receiver, context, declared)
1075
+ return unless owner
1076
+
1077
+ out << [owner, name.unescaped, node.location.start_line]
1078
+ end
1079
+
1080
+ # Only receivers that are a class or module for certain:
1081
+ # self in a singleton, a constant the target declares, a
1082
+ # hook's class argument, or anything's own class.
1083
+ def dynamic_owner(receiver, context, declared)
1084
+ case receiver
1085
+ when nil, Prism::SelfNode
1086
+ context.nesting.last if context.singleton
1087
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1088
+ name = constant_slice(receiver)
1089
+ name if declared.include?(name.split("::").last)
1090
+ when Prism::LocalVariableReadNode
1091
+ receiver.slice if context.hooks[receiver.name]
1092
+ when Prism::CallNode
1093
+ receiver.slice if receiver.name == :class
1094
+ end
1095
+ end
1096
+
1097
+ # The name a concern gives the module it puts on the class.
1098
+ # Nothing in the concern's own source extends it: the mixin
1099
+ # that does lives in whatever library defines the pattern,
1100
+ # so within the target the name is the only evidence.
1101
+ CLASS_METHODS = "ClassMethods"
1102
+
1103
+ # The same methods declared as a block, with no module in
1104
+ # the source to hang the name on: the concern synthesizes
1105
+ # one under the conventional name at load time.
1106
+ CLASS_METHODS_BLOCK = :class_methods
1107
+
1108
+ # Mixing into a singleton class lands a module's instance
1109
+ # methods on the class, the way extend does.
1110
+ SINGLETON_MIXINS = Ractor.make_shareable(
1111
+ Set.new(%i[prepend include])
1112
+ )
1113
+
1114
+ # A module reached through its last name segment: the
1115
+ # extend site and the definition rarely spell the path
1116
+ # the same way.
1117
+ def extended_names
1118
+ names = Set.new([CLASS_METHODS])
1119
+ source_roots.each_value { |root| collect_extends(root, names) }
1120
+ names
1121
+ end
1122
+
1123
+ def collect_extends(node, names)
1124
+ if node.is_a?(Prism::CallNode) && extend_call?(node)
1125
+ Array(node.arguments&.arguments).each do |arg|
1126
+ name = constant_slice(arg)
1127
+ names << name.split("::").last if name
1128
+ end
1129
+ end
1130
+ node.compact_child_nodes.each { |c| collect_extends(c, names) }
1131
+ end
1132
+
1133
+ def extend_call?(node)
1134
+ return true if node.name == :extend
1135
+ return false unless SINGLETON_MIXINS.include?(node.name)
1136
+
1137
+ node.receiver.is_a?(Prism::CallNode) &&
1138
+ node.receiver.name == :singleton_class
1139
+ end
1140
+
1141
+ def constant_slice(node)
1142
+ case node
1143
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1144
+ node.slice.delete_prefix("::")
1145
+ when Prism::CallNode
1146
+ const_get_slice(node)
1147
+ end
1148
+ end
1149
+
1150
+ # A constant fetched through `const_get` names it as
1151
+ # plainly as the constant does, and a concern resolving
1152
+ # its own companion module writes the extend that way.
1153
+ # Only on self: an explicit receiver picks the scope at
1154
+ # runtime, which is the blind spot the scan reports.
1155
+ def const_get_slice(node)
1156
+ return unless node.name == :const_get
1157
+ return unless node.receiver.nil? ||
1158
+ node.receiver.is_a?(Prism::SelfNode)
1159
+
1160
+ argument = Array(node.arguments&.arguments).first
1161
+ case argument
1162
+ when Prism::SymbolNode, Prism::StringNode
1163
+ argument.unescaped
1164
+ end
1165
+ end
1166
+
1167
+ # A def with a receiver writes the module's own state,
1168
+ # which the graph already owns; a nested class starts
1169
+ # its own instance side.
1170
+ def walk_extended(node, nesting, names, owner, out)
1171
+ case node
1172
+ when Prism::ModuleNode
1173
+ name = node.constant_path.slice
1174
+ nesting += [name]
1175
+ owner = nesting.join("::") if
1176
+ names.include?(name.split("::").last)
1177
+ when Prism::ClassNode
1178
+ nesting += [node.constant_path.slice]
1179
+ owner = nil
1180
+ when Prism::CallNode
1181
+ if node.block && node.name == CLASS_METHODS_BLOCK
1182
+ owner = (nesting + [CLASS_METHODS]).join("::")
1183
+ end
1184
+ when Prism::DefNode
1185
+ return if node.receiver
1186
+ else
1187
+ if owner && IVAR_WRITES.any? { |k| node.is_a?(k) }
1188
+ out << [owner, node.name.to_s, node.location.start_line]
1189
+ end
1190
+ end
1191
+ node.compact_child_nodes.each do |child|
1192
+ walk_extended(child, nesting, names, owner, out)
1193
+ end
1194
+ end
1195
+
1196
+ # Tracks the lexical nesting so `class << self` resolves
1197
+ # to the class it sits in, and `class << Name` to that name.
1198
+ def walk_singletons(node, nesting, out)
1199
+ case node
1200
+ when Prism::ClassNode, Prism::ModuleNode
1201
+ nesting += [node.constant_path.slice]
1202
+ when Prism::SingletonClassNode
1203
+ owner = singleton_owner(node, nesting)
1204
+ collect_attrs(node.body, owner, out) if owner
1205
+ end
1206
+ node.compact_child_nodes.each do |child|
1207
+ walk_singletons(child, nesting, out)
1208
+ end
1209
+ end
1210
+
1211
+ def singleton_owner(node, nesting)
1212
+ case node.expression
1213
+ when Prism::SelfNode then nesting.last
1214
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1215
+ node.expression.slice
1216
+ end
1217
+ end
1218
+
1219
+ # Only the body's own statements count: a nested def or
1220
+ # block calling attr_accessor defines something else.
1221
+ def collect_attrs(body, owner, out)
1222
+ return unless body.is_a?(Prism::StatementsNode)
1223
+
1224
+ body.body.each do |stmt|
1225
+ next unless stmt.is_a?(Prism::CallNode) &&
1226
+ stmt.receiver.nil? &&
1227
+ SINGLETON_ATTRS.include?(stmt.name.to_s)
1228
+
1229
+ Array(stmt.arguments&.arguments).each do |arg|
1230
+ name = attr_name(arg)
1231
+ out << [owner, name, stmt.location.start_line] if name
1232
+ end
1233
+ end
1234
+ end
1235
+
1236
+ def attr_name(node)
1237
+ case node
1238
+ when Prism::SymbolNode, Prism::StringNode then node.unescaped
1239
+ end
1240
+ end
1241
+
167
1242
  def each_local_definition(decl)
168
1243
  decl.definitions.reject do |defn|
169
1244
  defn.location.uri.start_with?("rubydex:")
@@ -171,7 +1246,7 @@ module Audition
171
1246
  end
172
1247
 
173
1248
  def finding_at(defn, check:, message:, why:, fix:,
174
- severity: :error)
1249
+ severity: :error, subject: nil)
175
1250
  path = path_from_uri(defn.location.uri)
176
1251
  line = defn.location.start_line + 1
177
1252
  Finding.new(
@@ -182,7 +1257,8 @@ module Audition
182
1257
  fix: fix,
183
1258
  path: path,
184
1259
  line: line,
185
- source: source_line(path, line)
1260
+ source: source_line(path, line),
1261
+ subject: subject
186
1262
  )
187
1263
  end
188
1264
 
@@ -265,7 +1341,10 @@ module Audition
265
1341
 
266
1342
  # Cross-file merge keeps the weakest promise: any dirty file
267
1343
  # taints the group, and best-effort beats fully frozen.
268
- VERDICT_RANK = {dirty: 0, best_effort: 1, frozen: 2}.freeze
1344
+ VERDICT_RANK = {
1345
+ dirty: 0, best_effort: 1, proxied: 2, proxied_frozen: 3,
1346
+ frozen: 4
1347
+ }.freeze
269
1348
 
270
1349
  def weaker_verdict(existing, verdict)
271
1350
  return verdict unless existing
@@ -278,6 +1357,9 @@ module Audition
278
1357
  return :dirty if ops.any? { |op| op[:kind] == :other }
279
1358
 
280
1359
  memos = Rewriters::Memoization.memo_sites(ops)
1360
+ if memos.any? && memos.all? { |m| m[:op][:proxied] }
1361
+ return proxied_verdict(ops, memos, classifier)
1362
+ end
281
1363
  if memos.any?
282
1364
  return group_frozen?(ops, classifier) ? :frozen : :dirty
283
1365
  end
@@ -297,6 +1379,22 @@ module Audition
297
1379
  (all_safe && wrapped) ? :best_effort : :dirty
298
1380
  end
299
1381
 
1382
+ # The read-then-proxy hatch, `@x || on_main(self) { @x ||= v }`:
1383
+ # every write is a memo site inside the block, so the main
1384
+ # Ractor performs it by construction, and the value decides
1385
+ # between a note and a warning. A plain write elsewhere
1386
+ # (a reset) is a worker-side write again.
1387
+ def proxied_verdict(ops, memos, classifier)
1388
+ memo_ops = memos.map { |memo| memo[:op] }
1389
+ writes = ops.select { |op| op[:kind] == :write }
1390
+ return :dirty unless (writes - memo_ops).empty?
1391
+
1392
+ frozen = memos.all? do |memo|
1393
+ frozen_memo_value?(memo[:op][:node].value, classifier)
1394
+ end
1395
+ frozen ? :proxied_frozen : :proxied
1396
+ end
1397
+
300
1398
  # Matches the emitted setter recipe:
301
1399
  # (Ractor.make_shareable(value) rescue value)
302
1400
  def best_effort_value?(node)
@@ -353,7 +1451,7 @@ module Audition
353
1451
  end
354
1452
  end
355
1453
 
356
- # "Payments::<Payments>" reads as noise; show "Payments".
1454
+ # "Widget::<Widget>" reads as noise; show "Widget".
357
1455
  # Nested singleton owners produce nested angle brackets, so
358
1456
  # the strip repeats until the tail is gone.
359
1457
  def display_owner(owner)