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.
@@ -9,7 +9,7 @@ module Audition
9
9
  not_ready: "not ractor-ready",
10
10
  blocked: "own code is ractor-ready; blocked by dependencies",
11
11
  risky: "risky: warnings only, no hard errors",
12
- ready: "ractor-ready as far as audition can tell"
12
+ ready: "ractor-ready as far as Audition can tell"
13
13
  }.freeze
14
14
 
15
15
  attr_reader :target_type, :target_root, :findings,
@@ -60,13 +60,19 @@ module Audition
60
60
  counts[:dep_error].positive?
61
61
  end
62
62
 
63
+ # Test findings count apart at every severity: they are the
64
+ # target's code, but a production boot never loads them, so
65
+ # they never touch the verdict.
63
66
  def counts
64
67
  @counts ||= begin
65
68
  base = {error: 0, dep_error: 0, warning: 0, info: 0,
69
+ test_error: 0, test_warning: 0, test_info: 0,
66
70
  fixable: 0}
67
71
  findings.each_with_object(base) do |f, acc|
68
72
  if f.error? && f.dependency?
69
73
  acc[:dep_error] += 1
74
+ elsif f.test?
75
+ acc[:"test_#{f.severity}"] += 1
70
76
  else
71
77
  acc[f.severity] += 1
72
78
  end
@@ -86,3 +92,4 @@ require_relative "report/style"
86
92
  require_relative "report/text"
87
93
  require_relative "report/json"
88
94
  require_relative "report/github"
95
+ require_relative "report/sweep"
@@ -81,7 +81,10 @@ module Audition
81
81
  values = collector.values
82
82
  kinds = values.map { |v| classifier.classify(v) }
83
83
  flagged = kinds.reject do |k|
84
- %i[shareable unknown].include?(k)
84
+ # A magic comment cannot fix opaque values, so they
85
+ # must not veto one either.
86
+ %i[shareable unknown instance_new opaque_call
87
+ shallow_opaque].include?(k)
85
88
  end
86
89
  scv_ok = values.any? &&
87
90
  values.all? { |value| deep_literal?(value, classifier) }
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "etc"
3
+ require_relative "work_split"
4
4
 
5
5
  module Audition
6
6
  module Static
@@ -27,6 +27,10 @@ module Audition
27
27
 
28
28
  PARALLEL_THRESHOLD = 16
29
29
 
30
+ # Workers report in batches: a port message per file would
31
+ # cost more than the redraw it feeds.
32
+ TICK_STRIDE = 25
33
+
30
34
  # Scans files across Ractors when there are enough of them to
31
35
  # be worth the spawn cost. Checks are plain shareable classes
32
36
  # with deeply frozen catalogs, findings copy back through
@@ -34,47 +38,112 @@ module Audition
34
38
  # serial path.
35
39
  #
36
40
  # @param paths [Array<String>] files to analyze
37
- # @param workers [Integer] Ractor count (defaults to
38
- # processor count minus one)
41
+ # @param workers [Integer, nil] Ractor count (defaults to
42
+ # {#default_workers})
39
43
  # @param threshold [Integer] minimum file count before
40
44
  # Ractors are used at all
45
+ # @param progress [Progress] ticked per file
41
46
  # @return [Array<Finding>]
42
- def analyze_paths(paths, workers: default_workers,
43
- threshold: PARALLEL_THRESHOLD)
47
+ def analyze_paths(paths, workers: nil,
48
+ threshold: PARALLEL_THRESHOLD, progress: Progress::SILENT)
49
+ workers ||= default_workers
44
50
  if paths.size < threshold || workers <= 1
45
- return paths.flat_map { |path| analyze_path(path) }
51
+ return serial_analyze(paths, progress)
46
52
  end
47
53
 
48
- parallel_analyze(paths, workers)
54
+ parallel_analyze(paths, workers, progress)
49
55
  rescue Ractor::Error => e
50
56
  # The silent fallback would otherwise mask a check that is
51
57
  # itself Ractor-hostile; surface it under -w.
52
58
  if $VERBOSE
53
- warn "audition: parallel scan fell back to serial: " \
59
+ warn "Audition: parallel scan fell back to serial: " \
54
60
  "#{e.class}: #{e.message}"
55
61
  end
56
- paths.flat_map { |path| analyze_path(path) }
62
+ progress.ractors = nil
63
+ serial_analyze(paths, progress)
57
64
  end
58
65
 
59
66
  private
60
67
 
61
- def parallel_analyze(paths, workers)
68
+ def serial_analyze(paths, progress)
69
+ paths.flat_map do |path|
70
+ findings = analyze_path(path)
71
+ progress.tick
72
+ findings
73
+ end
74
+ end
75
+
76
+ # A port carries counts out of the workers while they run:
77
+ # the alternative is a status line frozen for the whole
78
+ # parallel phase, which is most of a large scan.
79
+ def parallel_analyze(paths, workers, progress)
62
80
  experimental = Warning[:experimental]
63
81
  Warning[:experimental] = false
64
- slice = (paths.size / workers.to_f).ceil
65
82
  checks = @checks
66
- paths.each_slice(slice).map do |chunk|
67
- Ractor.new(chunk, checks) do |files, active_checks|
68
- analyzer = Analyzer.new(checks: active_checks)
69
- files.flat_map { |file| analyzer.analyze_path(file) }
83
+ port = progress.enabled? ? Ractor::Port.new : nil
84
+ chunks = balanced_chunks(paths, workers)
85
+ progress.ractors = chunks.size
86
+ ractors = chunks.map do |chunk|
87
+ # The sentinel goes out through `ensure` so a worker that
88
+ # raises still releases the drain loop; the exception
89
+ # itself still surfaces from `Ractor#value`.
90
+ Ractor.new(chunk, checks, port) do |files, active, tap|
91
+ analyzer = Analyzer.new(checks: active)
92
+ pending = 0
93
+ begin
94
+ files.flat_map do |file|
95
+ findings = analyzer.analyze_path(file)
96
+ pending += 1
97
+ if tap && pending >= TICK_STRIDE
98
+ tap.send(pending)
99
+ pending = 0
100
+ end
101
+ findings
102
+ end
103
+ ensure
104
+ tap&.send(pending)
105
+ tap&.send(:done)
106
+ end
70
107
  end
71
- end.flat_map(&:value)
108
+ end
109
+ drain(port, ractors.size, progress) if port
110
+ ractors.flat_map(&:value)
72
111
  ensure
73
112
  Warning[:experimental] = experimental
74
113
  end
75
114
 
115
+ def drain(port, workers, progress)
116
+ done = 0
117
+ while done < workers
118
+ message = port.receive
119
+ if message == :done
120
+ done += 1
121
+ else
122
+ progress.tick(message)
123
+ end
124
+ end
125
+ rescue
126
+ # Narration is cosmetic; a closed port ends it quietly and
127
+ # `Ractor#value` still reports what went wrong.
128
+ nil
129
+ end
130
+
76
131
  def default_workers
77
- [Etc.nprocessors - 1, 1].max
132
+ WorkSplit.workers
133
+ end
134
+
135
+ # Byte size stands in for parse cost, and the stat it costs
136
+ # is nothing beside the parse it schedules.
137
+ def balanced_chunks(paths, workers)
138
+ WorkSplit.chunks(
139
+ paths.map { |path| [path, file_size(path)] }, workers
140
+ )
141
+ end
142
+
143
+ def file_size(path)
144
+ File.size(path)
145
+ rescue SystemCallError
146
+ 0
78
147
  end
79
148
 
80
149
  def analyze_file(file)
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ module Static
5
+ module Checks
6
+ # Class-level state owned by a dependency. A gem's own source
7
+ # is outside the scanned tree, so the graph audit never sees
8
+ # the ivar and the call site is the only place left to flag.
9
+ # Generated type stubs in the target's tree record it: an
10
+ # attribute reader on a singleton class is a class-level ivar
11
+ # read by definition, and the generator marks those methods
12
+ # apart from ones that compute their answer.
13
+ class DependencyClassState < Base
14
+ explain :read,
15
+ severity: :warning,
16
+ message: "%{owner}.%{name} reads class-level state " \
17
+ "owned by a dependency",
18
+ why: "The stub for %{owner} declares %{name} as an " \
19
+ "attribute on its singleton class, so the call " \
20
+ "returns a class-level instance variable. A " \
21
+ "non-main Ractor raises Ractor::IsolationError " \
22
+ "(\"can not get unshareable values from instance " \
23
+ "variables of classes/modules\") unless whatever " \
24
+ "the value happens to be is shareable.",
25
+ fix: "Read it once on the main Ractor and pass the " \
26
+ "value in, or make the dependency's assignment " \
27
+ "deeply shareable before any Ractor starts."
28
+
29
+ explain :write,
30
+ severity: :error,
31
+ message: "%{owner}.%{name}= writes class-level state " \
32
+ "owned by a dependency",
33
+ why: "Assigning an attribute on %{owner}'s singleton " \
34
+ "class writes a class-level instance variable, " \
35
+ "which a non-main Ractor cannot do at all: it " \
36
+ "raises Ractor::IsolationError regardless of what " \
37
+ "the value is.",
38
+ fix: "Configure the dependency at boot on the main " \
39
+ "Ractor and leave it alone afterwards."
40
+
41
+ # The generator documents a reader it generated from an
42
+ # attribute; a hand-written method of the same shape gets
43
+ # its own prose and stays quiet.
44
+ STUB_READER = /Returns the value of attribute (\w+)/
45
+ STUB_SINGLETON = /\A\s*class\s+<<\s+self\b/
46
+ STUB_SCOPE = /\A\s*(?:class|module)\s+([A-Za-z0-9_:]+)/
47
+ STUB_DEF = /\A\s*def ([a-z_][A-Za-z0-9_]*)[(;]/
48
+ STUB_COMMENT = /\A\s*#/
49
+
50
+ class << self
51
+ attr_reader :attributes
52
+
53
+ # Owner name => set of attribute names, kept shareable
54
+ # for parallel scanning.
55
+ def attributes=(pairs)
56
+ grouped = pairs.group_by(&:first)
57
+ .transform_values { |v| v.map(&:last).uniq }
58
+ @attributes = Ractor.make_shareable( # audition:disable
59
+ grouped
60
+ )
61
+ end
62
+
63
+ # @param paths [Array<String>] scanned files; only
64
+ # generated type stubs carry this evidence
65
+ # @return [void]
66
+ def learn(paths)
67
+ pairs = []
68
+ paths.each do |path|
69
+ next unless path.end_with?(".rbi")
70
+
71
+ read_stub(path, pairs)
72
+ end
73
+ self.attributes = pairs
74
+ end
75
+
76
+ private
77
+
78
+ # Stub indentation tracks nesting exactly, so the scope
79
+ # stack follows it rather than parsing the file.
80
+ def read_stub(path, pairs)
81
+ scope = []
82
+ singleton = []
83
+ indents = []
84
+ documented = nil
85
+ File.foreach(path) do |raw|
86
+ line = raw.rstrip
87
+ next if line.empty?
88
+
89
+ indent = line[/\A */].size
90
+ while indents.any? && indent <= indents.last
91
+ indents.pop
92
+ scope.pop
93
+ singleton.pop
94
+ end
95
+ documented =
96
+ step_stub(line, scope, singleton, indents,
97
+ documented, pairs)
98
+ end
99
+ rescue SystemCallError
100
+ nil
101
+ end
102
+
103
+ def step_stub(line, scope, singleton, indents,
104
+ documented, pairs)
105
+ case line
106
+ when STUB_COMMENT
107
+ return STUB_READER.match(line)&.[](1) || documented
108
+ when STUB_SINGLETON
109
+ scope.push(scope.last)
110
+ singleton.push(true)
111
+ indents.push(line[/\A */].size)
112
+ when STUB_SCOPE
113
+ name = Regexp.last_match(1)
114
+ scope.push(scope.empty? ? name : "#{scope.last}::#{name}")
115
+ singleton.push(false)
116
+ indents.push(line[/\A */].size)
117
+ when STUB_DEF
118
+ name = Regexp.last_match(1)
119
+ if singleton.last && scope.last && documented == name
120
+ pairs << [scope.last, name]
121
+ end
122
+ end
123
+ nil
124
+ end
125
+ end
126
+
127
+ self.attributes = []
128
+
129
+ on :call_node do |node|
130
+ examine(node)
131
+ end
132
+
133
+ private
134
+
135
+ def examine(node)
136
+ owner = constant_name(node.receiver)
137
+ return unless owner
138
+
139
+ name = node.name.to_s
140
+ writer = name.end_with?("=")
141
+ attribute = writer ? name.chomp("=") : name
142
+ names = self.class.attributes[owner]
143
+ return unless names&.include?(attribute)
144
+
145
+ flag(node, writer ? :write : :read,
146
+ owner: owner, name: attribute)
147
+ end
148
+
149
+ def constant_name(node)
150
+ case node
151
+ when Prism::ConstantReadNode
152
+ node.name.to_s
153
+ when Prism::ConstantPathNode
154
+ node.location.slice.delete_prefix("::")
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -103,6 +103,44 @@ module Audition
103
103
  "or drop the default proc and fetch with a " \
104
104
  "literal default: hash.fetch(key, [])."
105
105
 
106
+ explain :unshareable_instance,
107
+ severity: :warning,
108
+ message: "constant %{name} holds an unfrozen %{klass} " \
109
+ "instance",
110
+ why: "A fresh instance is unfrozen, so non-main " \
111
+ "Ractors raise Ractor::IsolationError reading " \
112
+ "it. A warning, not an error: .new can be " \
113
+ "overridden to return a shareable value.",
114
+ fix: "Freeze it when deeply immutable, wrap in " \
115
+ "Ractor.make_shareable, or keep a per-Ractor " \
116
+ "copy via Ractor.store_if_absent."
117
+
118
+ explain :shallow_opaque,
119
+ severity: :warning,
120
+ message: "constant %{name} is frozen at the top level " \
121
+ "only; what it holds is unproven",
122
+ why: "Freezing is shallow: elements and instance " \
123
+ "variables stay as their calls returned them, and " \
124
+ "unless those are deeply frozen a non-main Ractor " \
125
+ "reading the constant raises " \
126
+ "Ractor::IsolationError. The dynamic probe settles " \
127
+ "it when the target boots.",
128
+ fix: "Build the value with Ractor.make_shareable for a " \
129
+ "deep freeze, or silence a known-shareable value " \
130
+ "with a disable comment."
131
+
132
+ explain :opaque_constant,
133
+ severity: :warning,
134
+ message: "constant %{name} holds the result of " \
135
+ "%{method}; shareability unproven",
136
+ why: "Unless the call returns a deeply frozen value, " \
137
+ "non-main Ractors raise Ractor::IsolationError " \
138
+ "reading the constant. The dynamic probe settles " \
139
+ "it when the target boots.",
140
+ fix: "Freeze the result at definition time, or " \
141
+ "silence a known-shareable value with a disable " \
142
+ "comment."
143
+
106
144
  explain :constant_mutation,
107
145
  severity: :warning,
108
146
  message: "in-place %{method} on constant %{name}",
@@ -152,6 +190,10 @@ module Audition
152
190
  # finding stays, the autofix goes.
153
191
  fix_ok = !mutated?(name) && !customized?(name)
154
192
  kind = classifier.classify(value)
193
+ # A Sorbet cast returns its argument and a begin
194
+ # block its last statement: fixes, type names and
195
+ # depth checks target the value inside.
196
+ value = classifier.unwrap(value)
155
197
  # Build-then-freeze: a bare `NAME.freeze` later in the
156
198
  # same body makes the literal as good as frozen, so
157
199
  # only provably mutable elements remain to report.
@@ -195,6 +237,20 @@ module Audition
195
237
  autofix: wrappable ? wrap_make_shareable(value) : nil)
196
238
  when :default_proc
197
239
  flag(node, :hash_default_proc, name: name)
240
+ when :shallow_opaque
241
+ flag(node, :shallow_opaque, name: name)
242
+ # Build-then-freeze leaves depth unknown: stay silent.
243
+ when :instance_new
244
+ unless frozen_later?(name)
245
+ flag(node, :unshareable_instance, name: name,
246
+ klass:
247
+ classifier.const_name(value.receiver) || "new")
248
+ end
249
+ when :opaque_call
250
+ unless frozen_later?(name)
251
+ flag(node, :opaque_constant, name: name,
252
+ method: opaque_display(value))
253
+ end
198
254
  end
199
255
  end
200
256
 
@@ -313,6 +369,21 @@ module Audition
313
369
  owner ? "#{owner}.#{call.name}" : "String##{call.name}"
314
370
  end
315
371
 
372
+ # The receiver is arbitrary, often a chain, so the
373
+ # fallback names only the method.
374
+ def opaque_display(call)
375
+ owner = classifier.const_name(call.receiver)
376
+ method =
377
+ if owner
378
+ "#{owner}.#{call.name}"
379
+ elsif call.receiver
380
+ ".#{call.name}"
381
+ else
382
+ call.name.to_s
383
+ end
384
+ "a #{method} call"
385
+ end
386
+
316
387
  # `.freeze` binds tighter than an operator: `"a" + "b".freeze`
317
388
  # freezes only "b", so operator calls get parentheses while
318
389
  # literals and parenthesized or argument-free calls take