audition 0.3.0 → 0.4.0

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