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.
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "table_tennis"
5
+
6
+ module Audition
7
+ class Report
8
+ # Renders a bundle sweep. Its unit is a gem rather than a
9
+ # finding, so it gets its own renderer instead of bending
10
+ # Report around a shape it does not have.
11
+ class Sweep
12
+ CELLS = {
13
+ :not_ready => "not ready", :blocked => "blocked",
14
+ :risky => "risky", :ready => "ready", nil => "-"
15
+ }.freeze
16
+
17
+ # The severity glyphs the text report uses, so a verdict
18
+ # reads the same in a cell as it does in a summary.
19
+ GLYPHS = {
20
+ not_ready: :error, blocked: :warning,
21
+ risky: :warning, ready: :pass
22
+ }.freeze
23
+
24
+ # Foreground only, and applied to the whole row: a
25
+ # background fill reads as a bar across the table, and a
26
+ # painted cell would widen its column by the invisible
27
+ # length of its own escape sequence. Ready gems keep the
28
+ # default color, so what is colored is what needs reading.
29
+ PAINTS = Ractor.make_shareable({
30
+ :not_ready => [:red], :blocked => [:magenta],
31
+ :risky => [:yellow], nil => [:faint]
32
+ })
33
+
34
+ TITLE = "Audition bundle sweep"
35
+
36
+ # @param rows [Array<BundleSweep::Row>] one per locked gem
37
+ # @param style [Style] palette for the text rendering
38
+ def initialize(rows, style)
39
+ @rows = rows
40
+ @style = style
41
+ end
42
+
43
+ # @param table_opts [Hash] what the terminal supports; see
44
+ # the caller for why color cannot be detected here
45
+ # @return [String] the table plus a summary line
46
+ def render(**table_opts)
47
+ table = TableTennis.new(cells, title: TITLE, mark: paint,
48
+ **table_opts)
49
+ "#{table}\n#{summary}"
50
+ end
51
+
52
+ def json
53
+ JSON.pretty_generate(
54
+ "audition" => VERSION,
55
+ "ruby" => RUBY_VERSION,
56
+ "bundle" => @rows.map { |r| json_row(r) }
57
+ )
58
+ end
59
+
60
+ # Sweep rows carry no file or line, so annotations land on
61
+ # the run summary rather than a diff.
62
+ # @return [String] annotation lines plus a summary line
63
+ def annotations
64
+ lines = @rows.filter_map do |row|
65
+ level = annotation_level(row)
66
+ next unless level
67
+
68
+ "::#{level} title=Audition::gem #{row.name} " \
69
+ "#{row.version}: #{row.errors} errors, " \
70
+ "#{row.dep_errors} dependency errors, " \
71
+ "#{row.warnings} warnings (#{CELLS.fetch(row.verdict)})"
72
+ end
73
+ (lines << plain_summary).join("\n")
74
+ end
75
+
76
+ # @return [String] job summary page table for Actions
77
+ def markdown
78
+ lines = [
79
+ "## #{TITLE}", "",
80
+ "| gem | version | verdict | errors | dep errors " \
81
+ "| warnings | fixable |",
82
+ "| --- | --- | --- | --- | --- | --- | --- |"
83
+ ]
84
+ @rows.each do |r|
85
+ lines << "| #{r.name} | #{r.version} | " \
86
+ "#{CELLS.fetch(r.verdict)} | #{r.errors} | " \
87
+ "#{r.dep_errors} | #{r.warnings} | #{r.fixable} |"
88
+ end
89
+ lines.push("", plain_summary).join("\n")
90
+ end
91
+
92
+ private
93
+
94
+ def cells
95
+ @rows.map do |r|
96
+ {
97
+ "gem" => r.name,
98
+ "version" => r.version,
99
+ "verdict" => verdict_cell(r.verdict),
100
+ "errors" => clean_as_blank(r.errors),
101
+ "dep errors" => clean_as_blank(r.dep_errors),
102
+ "warnings" => clean_as_blank(r.warnings),
103
+ "fixable" => clean_as_blank(r.fixable),
104
+ "status" => r.status
105
+ }
106
+ end
107
+ end
108
+
109
+ def verdict_cell(verdict)
110
+ cell = CELLS.fetch(verdict)
111
+ glyph = GLYPHS[verdict]
112
+ glyph ? "#{@style.glyph(glyph)} #{cell}" : cell
113
+ end
114
+
115
+ # A clean count is the common case across hundreds of gems;
116
+ # leaving it to the table's placeholder keeps the eye on the
117
+ # rows that carry something.
118
+ def clean_as_blank(count)
119
+ count.positive? ? count : nil
120
+ end
121
+
122
+ # The table gem hands the lambda back the row it was given,
123
+ # which carries the gem but not the verdict symbol.
124
+ def paint
125
+ paints = @rows.to_h do |r|
126
+ [[r.name, r.version], PAINTS[r.verdict]]
127
+ end
128
+ ->(row) { paints[[row["gem"], row["version"]]] }
129
+ end
130
+
131
+ def summary
132
+ glyph, paint = if blockers.positive?
133
+ [:error, :red]
134
+ elsif ready == @rows.size
135
+ [:pass, :green]
136
+ else
137
+ [:warning, :yellow]
138
+ end
139
+ head = @style.public_send(paint,
140
+ "#{@style.glyph(glyph)} #{plain_summary}")
141
+ return head if blockers.zero?
142
+
143
+ "#{head} #{@style.dim("· #{blockers} not ready")}"
144
+ end
145
+
146
+ def plain_summary
147
+ "#{ready} of #{@rows.size} gems ractor-ready"
148
+ end
149
+
150
+ def ready
151
+ @ready ||= @rows.count { |r| r.verdict == :ready }
152
+ end
153
+
154
+ def blockers
155
+ @blockers ||= @rows.count { |r| r.verdict == :not_ready }
156
+ end
157
+
158
+ def annotation_level(row)
159
+ if row.verdict == :not_ready ||
160
+ (row.errors + row.dep_errors).positive?
161
+ "error"
162
+ elsif row.warnings.positive?
163
+ "warning"
164
+ end
165
+ end
166
+
167
+ def json_row(row)
168
+ {
169
+ "gem" => row.name,
170
+ "version" => row.version,
171
+ "verdict" => row.verdict&.to_s,
172
+ "errors" => row.errors,
173
+ "dependency_errors" => row.dep_errors,
174
+ "warnings" => row.warnings,
175
+ "infos" => row.infos,
176
+ "fixable" => row.fixable,
177
+ "status" => row.status
178
+ }
179
+ end
180
+ end
181
+ end
182
+ end
@@ -21,7 +21,7 @@ module Audition
21
21
 
22
22
  def header
23
23
  s = @style
24
- title = s.bold("audition #{VERSION}")
24
+ title = s.bold("Audition #{VERSION}")
25
25
  meta = s.dim(
26
26
  "ruby #{RUBY_VERSION} · #{@report.target_type} at " \
27
27
  "#{@report.target_root}"
@@ -45,8 +45,10 @@ module Audition
45
45
  fix_mark = finding.fixable? ? " #{s.cyan(s.glyph(:fix))}" : ""
46
46
  dep_mark =
47
47
  finding.dependency? ? " #{s.dim("(dependency)")}" : ""
48
+ test_mark = finding.test? ? " #{s.dim("(tests)")}" : ""
48
49
  head = " #{glyph} #{loc}#{finding.message}" \
49
- "#{fix_mark}#{dep_mark} #{s.dim(finding.check)}"
50
+ "#{fix_mark}#{dep_mark}#{test_mark} " \
51
+ "#{s.dim(finding.check)}"
50
52
  [head,
51
53
  *annotation("why", finding.why),
52
54
  *annotation("fix", finding.fix)]
@@ -113,6 +115,15 @@ module Audition
113
115
  parts << s.yellow(pluralize(c[:warning], "warning"))
114
116
  end
115
117
  parts << s.cyan("#{c[:info]} info") if c[:info].positive?
118
+ test_total = c[:test_error] + c[:test_warning] +
119
+ c[:test_info]
120
+ if test_total.positive?
121
+ parts << s.dim(
122
+ pluralize(test_total, "test finding") +
123
+ " (#{c[:test_error]} error / " \
124
+ "#{c[:test_warning]} warning / #{c[:test_info]} info)"
125
+ )
126
+ end
116
127
  if c[:fixable].positive?
117
128
  parts << s.cyan(
118
129
  "#{c[:fixable]} fixable #{s.glyph(:fix)} " \
@@ -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) }
@@ -224,6 +227,9 @@ module Audition
224
227
  kinds = ops.map { |op| op[:kind] }
225
228
  next if kinds.include?(:other)
226
229
  next if ops.any? { |op| op[:body] }
230
+ # A memo written inside an on_main block already runs
231
+ # on the main Ractor; the audit rates the shape itself.
232
+ next if ops.any? { |op| op[:proxied] }
227
233
 
228
234
  memos = memo_sites(ops)
229
235
  if memos.empty?
@@ -617,9 +623,29 @@ module Audition
617
623
  @sclass_depth = 0
618
624
  @def_stack = []
619
625
  @defined_depth = 0
626
+ @proxy_depth = 0
620
627
  super
621
628
  end
622
629
 
630
+ # A block handed to `on_main` runs on the main Ractor by
631
+ # construction (the read-then-proxy escape hatch), so the
632
+ # ivar writes inside it are recorded as proxied.
633
+ def visit_call_node(node)
634
+ block = node.block
635
+ unless node.name == :on_main && block.is_a?(Prism::BlockNode)
636
+ return super
637
+ end
638
+
639
+ visit(node.receiver) if node.receiver
640
+ visit(node.arguments) if node.arguments
641
+ @proxy_depth += 1
642
+ begin
643
+ visit(block)
644
+ ensure
645
+ @proxy_depth -= 1
646
+ end
647
+ end
648
+
623
649
  def visit_class_node(node)
624
650
  scoped(node.constant_path.location.slice, :class) do
625
651
  super(node)
@@ -763,7 +789,8 @@ module Audition
763
789
  def_id: current_def && current_def[:id],
764
790
  def_name: current_def && current_def[:name],
765
791
  def_node: current_def && current_def[:node],
766
- class_owner: @namespace.last[:kind] == :class
792
+ class_owner: @namespace.last[:kind] == :class,
793
+ proxied: @proxy_depth.positive?
767
794
  }
768
795
  end
769
796
  end
@@ -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