audition 0.2.4 → 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.
@@ -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
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ class Report
5
+ # Terminal renderer; styles stay injectable so pipes and
6
+ # --plain degrade cleanly.
7
+ class Text
8
+ WRAP = 74
9
+
10
+ def initialize(report, style)
11
+ @report = report
12
+ @style = style
13
+ end
14
+
15
+ def render
16
+ [header, *file_sections, *dynamic_section, summary]
17
+ .join("\n")
18
+ end
19
+
20
+ private
21
+
22
+ def header
23
+ s = @style
24
+ title = s.bold("Audition #{VERSION}")
25
+ meta = s.dim(
26
+ "ruby #{RUBY_VERSION} · #{@report.target_type} at " \
27
+ "#{@report.target_root}"
28
+ )
29
+ "#{s.glyph(:section)} #{title} #{meta}\n"
30
+ end
31
+
32
+ def file_sections
33
+ @report.findings.group_by(&:path).map do |path, findings|
34
+ lines = [@style.bold(" #{path}")]
35
+ findings.each { |f| lines.concat(finding_lines(f)) }
36
+ lines.join("\n") + "\n"
37
+ end
38
+ end
39
+
40
+ def finding_lines(finding)
41
+ s = @style
42
+ glyph = s.severity_color(finding.severity,
43
+ s.glyph(finding.severity))
44
+ loc = location_label(finding)
45
+ fix_mark = finding.fixable? ? " #{s.cyan(s.glyph(:fix))}" : ""
46
+ dep_mark =
47
+ finding.dependency? ? " #{s.dim("(dependency)")}" : ""
48
+ test_mark = finding.test? ? " #{s.dim("(tests)")}" : ""
49
+ head = " #{glyph} #{loc}#{finding.message}" \
50
+ "#{fix_mark}#{dep_mark}#{test_mark} " \
51
+ "#{s.dim(finding.check)}"
52
+ [head,
53
+ *annotation("why", finding.why),
54
+ *annotation("fix", finding.fix)]
55
+ end
56
+
57
+ def location_label(finding)
58
+ return "" unless finding.line
59
+
60
+ s = @style
61
+ text = "#{finding.path}:#{finding.line}"
62
+ absolute = File.expand_path(finding.path, @report.target_root)
63
+ "#{s.cyan(s.link(text, absolute))} "
64
+ end
65
+
66
+ def annotation(label, content)
67
+ return [] if content.nil? || content.empty?
68
+
69
+ wrapped = wrap("#{label}: #{content}", WRAP - 6)
70
+ wrapped.map { |line| " #{@style.dim(line)}" }
71
+ end
72
+
73
+ # Tokens longer than the width (long URLs) cannot end before
74
+ # whitespace, so the first alternative would drop their head;
75
+ # the second hard-slices them instead.
76
+ def wrap(text, width)
77
+ text.scan(/\S.{0,#{width - 1}}(?=\s|\z)|\S{#{width}}/m)
78
+ end
79
+
80
+ def dynamic_section
81
+ return [] if @report.dynamic_results.empty?
82
+
83
+ s = @style
84
+ lines = [s.bold(" dynamic probes")]
85
+ @report.dynamic_results.each do |result|
86
+ lines << if result.passed
87
+ " #{s.green(s.glyph(:pass))} " \
88
+ "#{result.mode} probe passed inside a Ractor"
89
+ else
90
+ " #{s.red(s.glyph(:error))} " \
91
+ "#{result.mode} probe failed " \
92
+ "#{s.dim("(details above)")}"
93
+ end
94
+ end
95
+ [lines.join("\n") + "\n"]
96
+ end
97
+
98
+ def pluralize(count, noun)
99
+ (count == 1) ? "#{count} #{noun}" : "#{count} #{noun}s"
100
+ end
101
+
102
+ def summary
103
+ s = @style
104
+ c = @report.counts
105
+ parts = []
106
+ if c[:error].positive?
107
+ parts << s.red(pluralize(c[:error], "error"))
108
+ end
109
+ if c[:dep_error].positive?
110
+ parts << s.magenta(
111
+ pluralize(c[:dep_error], "dependency error")
112
+ )
113
+ end
114
+ if c[:warning].positive?
115
+ parts << s.yellow(pluralize(c[:warning], "warning"))
116
+ end
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
127
+ if c[:fixable].positive?
128
+ parts << s.cyan(
129
+ "#{c[:fixable]} fixable #{s.glyph(:fix)} " \
130
+ "(run with --fix)"
131
+ )
132
+ end
133
+ if @report.unsafe_fixes.positive?
134
+ parts << s.cyan(
135
+ pluralize(@report.unsafe_fixes, "edit") +
136
+ " with --fix-unsafe"
137
+ )
138
+ end
139
+ if @report.baselined.positive?
140
+ parts << s.dim("#{@report.baselined} baselined")
141
+ end
142
+ parts << s.green("no findings") if parts.empty?
143
+
144
+ verdict = @report.verdict
145
+ glyph, paint =
146
+ case verdict
147
+ when :not_ready then [:error, :red]
148
+ when :blocked then [:warning, :magenta]
149
+ when :risky then [:warning, :yellow]
150
+ else [:pass, :green]
151
+ end
152
+ badge = s.public_send(paint,
153
+ "#{s.glyph(glyph)} " +
154
+ VERDICTS.fetch(verdict))
155
+ " summary: #{parts.join(" · ")}\n" \
156
+ " verdict: #{s.bold(badge)}\n"
157
+ end
158
+ end
159
+ end
160
+ end
@@ -1,84 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "pastel"
5
- require "tty/link"
6
-
7
3
  module Audition
8
- # Aggregates static findings and dynamic results into a verdict and
9
- # renders them as npm-CLI-style text or JSON.
4
+ # Aggregates static findings and dynamic results into a verdict
5
+ # and the counts the renderers work from. Rendering lives in the
6
+ # Report::Text, Report::Json, and Report::Github classes.
10
7
  class Report
11
- # ANSI + OSC 8 styling with graceful degradation. Color and
12
- # hyperlinks are decided once, at construction; pass color: false
13
- # for pipes, NO_COLOR, or dumb terminals.
14
- class Style
15
- GLYPHS = Ractor.make_shareable(
16
- {
17
- error: ["✖", "x"], warning: ["⚠", "!"],
18
- info: ["ℹ", "i"], pass: ["✔", "ok"],
19
- section: ["◆", "*"], fix: ["✎", "+"]
20
- }
21
- )
22
-
23
- PAINTS = %i[red yellow green cyan magenta dim bold].freeze
24
-
25
- def self.detect(io: $stdout)
26
- on = io.respond_to?(:tty?) && io.tty? &&
27
- !ENV.key?("NO_COLOR") && ENV["TERM"] != "dumb"
28
- new(color: on, hyperlinks: on && TTY::Link.link?)
29
- end
30
-
31
- def initialize(color:, hyperlinks:)
32
- @pastel = Pastel.new(enabled: color)
33
- @color = color
34
- @hyperlinks = hyperlinks
35
- end
36
-
37
- def color?
38
- @color
39
- end
40
-
41
- def glyph(kind)
42
- GLYPHS.fetch(kind)[@color ? 0 : 1]
43
- end
44
-
45
- PAINTS.each do |name|
46
- define_method(name) do |text| # audition:disable unsafe-calls
47
- @pastel.public_send(name, text)
48
- end
49
- end
50
-
51
- def severity_color(severity, text)
52
- case severity
53
- when :error then red(text)
54
- when :warning then yellow(text)
55
- else cyan(text)
56
- end
57
- end
58
-
59
- # OSC 8 hyperlink wrapping "path:line" display text in a
60
- # file:// URI; supporting terminals make it clickable.
61
- # tty-link emits when it detects support; when hyperlinks are
62
- # forced on despite no detection (tests, --force scenarios)
63
- # fall back to the raw OSC 8 template, since tty-link's
64
- # fallback is "text -> url" prose.
65
- def link(text, absolute_path)
66
- return text unless @hyperlinks
67
-
68
- uri = "file://#{absolute_path}"
69
- if TTY::Link.link?
70
- TTY::Link.link_to(text, uri)
71
- else
72
- "\e]8;;#{uri}\e\\#{text}\e]8;;\e\\"
73
- end
74
- end
75
- end
76
-
77
8
  VERDICTS = {
78
9
  not_ready: "not ractor-ready",
79
10
  blocked: "own code is ractor-ready; blocked by dependencies",
80
11
  risky: "risky: warnings only, no hard errors",
81
- ready: "ractor-ready as far as audition can tell"
12
+ ready: "ractor-ready as far as Audition can tell"
82
13
  }.freeze
83
14
 
84
15
  attr_reader :target_type, :target_root, :findings,
@@ -129,13 +60,19 @@ module Audition
129
60
  counts[:dep_error].positive?
130
61
  end
131
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.
132
66
  def counts
133
67
  @counts ||= begin
134
68
  base = {error: 0, dep_error: 0, warning: 0, info: 0,
69
+ test_error: 0, test_warning: 0, test_info: 0,
135
70
  fixable: 0}
136
71
  findings.each_with_object(base) do |f, acc|
137
72
  if f.error? && f.dependency?
138
73
  acc[:dep_error] += 1
74
+ elsif f.test?
75
+ acc[:"test_#{f.severity}"] += 1
139
76
  else
140
77
  acc[f.severity] += 1
141
78
  end
@@ -148,227 +85,11 @@ module Audition
148
85
  end
149
86
  end
150
87
  end
151
-
152
- # @param style [Style] rendering style (auto-detected default)
153
- # @return [String] the human-facing terminal report
154
- def to_text(style: Style.detect)
155
- Text.new(self, style).render
156
- end
157
-
158
- GITHUB_LEVELS = {
159
- error: "error", warning: "warning", info: "notice"
160
- }.freeze
161
-
162
- # GitHub Actions workflow commands: findings become inline PR
163
- # annotations when this runs in CI.
164
- #
165
- # @return [String] one `::error`/`::warning`/`::notice` line
166
- # per finding plus a verdict line
167
- def to_github
168
- lines = findings.map do |f|
169
- level = GITHUB_LEVELS.fetch(f.severity)
170
- location = f.line ? ",line=#{f.line}" : ""
171
- body = workflow_escape("#{f.message}. #{f.why}")
172
- file = property_escape(f.path)
173
- title = property_escape("audition #{f.check}")
174
- "::#{level} file=#{file}#{location}," \
175
- "title=#{title}::#{body}"
176
- end
177
- lines << "audition verdict: #{VERDICTS.fetch(verdict)}"
178
- lines.join("\n")
179
- end
180
-
181
- def to_json(*)
182
- JSON.pretty_generate(
183
- "audition" => VERSION,
184
- "ruby" => RUBY_VERSION,
185
- "target" => {"type" => target_type.to_s,
186
- "root" => target_root},
187
- "verdict" => verdict.to_s,
188
- "summary" => {
189
- "errors" => counts[:error],
190
- "dependency_errors" => counts[:dep_error],
191
- "warnings" => counts[:warning],
192
- "infos" => counts[:info],
193
- "fixable" => counts[:fixable]
194
- },
195
- "findings" => findings.map do |f|
196
- {
197
- "check" => f.check,
198
- "severity" => f.severity.to_s,
199
- "message" => f.message,
200
- "why" => f.why,
201
- "fix" => f.fix,
202
- "path" => f.path,
203
- "line" => f.line,
204
- "source" => f.source,
205
- "fixable" => f.fixable?,
206
- "dependency" => f.dependency?
207
- }
208
- end,
209
- "dynamic" => dynamic_results.map do |r|
210
- {"mode" => r.mode.to_s, "passed" => r.passed,
211
- "raw" => r.raw}
212
- end
213
- )
214
- end
215
-
216
- private
217
-
218
- def workflow_escape(text)
219
- text.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
220
- end
221
-
222
- # Workflow command properties additionally reserve `:` and `,`;
223
- # an unescaped comma in a path would end the property early.
224
- def property_escape(text)
225
- workflow_escape(text).gsub(":", "%3A").gsub(",", "%2C")
226
- end
227
-
228
- public
229
-
230
- # Text renderer, kept separate from the data so styles stay
231
- # injectable.
232
- class Text
233
- WRAP = 74
234
-
235
- def initialize(report, style)
236
- @report = report
237
- @style = style
238
- end
239
-
240
- def render
241
- [header, *file_sections, *dynamic_section, summary]
242
- .join("\n")
243
- end
244
-
245
- private
246
-
247
- def header
248
- s = @style
249
- title = s.bold("audition #{VERSION}")
250
- meta = s.dim(
251
- "ruby #{RUBY_VERSION} · #{@report.target_type} at " \
252
- "#{@report.target_root}"
253
- )
254
- "#{s.glyph(:section)} #{title} #{meta}\n"
255
- end
256
-
257
- def file_sections
258
- @report.findings.group_by(&:path).map do |path, findings|
259
- lines = [@style.bold(" #{path}")]
260
- findings.each { |f| lines.concat(finding_lines(f)) }
261
- lines.join("\n") + "\n"
262
- end
263
- end
264
-
265
- def finding_lines(finding)
266
- s = @style
267
- glyph = s.severity_color(finding.severity,
268
- s.glyph(finding.severity))
269
- loc = location_label(finding)
270
- fix_mark = finding.fixable? ? " #{s.cyan(s.glyph(:fix))}" : ""
271
- dep_mark =
272
- finding.dependency? ? " #{s.dim("(dependency)")}" : ""
273
- head = " #{glyph} #{loc}#{finding.message}" \
274
- "#{fix_mark}#{dep_mark} #{s.dim(finding.check)}"
275
- [head,
276
- *annotation("why", finding.why),
277
- *annotation("fix", finding.fix)]
278
- end
279
-
280
- def location_label(finding)
281
- return "" unless finding.line
282
-
283
- s = @style
284
- text = "#{finding.path}:#{finding.line}"
285
- absolute = File.expand_path(finding.path, @report.target_root)
286
- "#{s.cyan(s.link(text, absolute))} "
287
- end
288
-
289
- def annotation(label, content)
290
- return [] if content.nil? || content.empty?
291
-
292
- wrapped = wrap("#{label}: #{content}", WRAP - 6)
293
- wrapped.map { |line| " #{@style.dim(line)}" }
294
- end
295
-
296
- # Tokens longer than the width (long URLs) cannot end before
297
- # whitespace, so the first alternative would drop their head;
298
- # the second hard-slices them instead.
299
- def wrap(text, width)
300
- text.scan(/\S.{0,#{width - 1}}(?=\s|\z)|\S{#{width}}/m)
301
- end
302
-
303
- def dynamic_section
304
- return [] if @report.dynamic_results.empty?
305
-
306
- s = @style
307
- lines = [s.bold(" dynamic probes")]
308
- @report.dynamic_results.each do |result|
309
- lines << if result.passed
310
- " #{s.green(s.glyph(:pass))} " \
311
- "#{result.mode} probe passed inside a Ractor"
312
- else
313
- " #{s.red(s.glyph(:error))} " \
314
- "#{result.mode} probe failed " \
315
- "#{s.dim("(details above)")}"
316
- end
317
- end
318
- [lines.join("\n") + "\n"]
319
- end
320
-
321
- def pluralize(count, noun)
322
- (count == 1) ? "#{count} #{noun}" : "#{count} #{noun}s"
323
- end
324
-
325
- def summary
326
- s = @style
327
- c = @report.counts
328
- parts = []
329
- if c[:error].positive?
330
- parts << s.red(pluralize(c[:error], "error"))
331
- end
332
- if c[:dep_error].positive?
333
- parts << s.magenta(
334
- pluralize(c[:dep_error], "dependency error")
335
- )
336
- end
337
- if c[:warning].positive?
338
- parts << s.yellow(pluralize(c[:warning], "warning"))
339
- end
340
- parts << s.cyan("#{c[:info]} info") if c[:info].positive?
341
- if c[:fixable].positive?
342
- parts << s.cyan(
343
- "#{c[:fixable]} fixable #{s.glyph(:fix)} " \
344
- "(run with --fix)"
345
- )
346
- end
347
- if @report.unsafe_fixes.positive?
348
- parts << s.cyan(
349
- pluralize(@report.unsafe_fixes, "edit") +
350
- " with --fix-unsafe"
351
- )
352
- end
353
- if @report.baselined.positive?
354
- parts << s.dim("#{@report.baselined} baselined")
355
- end
356
- parts << s.green("no findings") if parts.empty?
357
-
358
- verdict = @report.verdict
359
- glyph, paint =
360
- case verdict
361
- when :not_ready then [:error, :red]
362
- when :blocked then [:warning, :magenta]
363
- when :risky then [:warning, :yellow]
364
- else [:pass, :green]
365
- end
366
- badge = s.public_send(paint,
367
- "#{s.glyph(glyph)} " +
368
- VERDICTS.fetch(verdict))
369
- " summary: #{parts.join(" · ")}\n" \
370
- " verdict: #{s.bold(badge)}\n"
371
- end
372
- end
373
88
  end
374
89
  end
90
+
91
+ require_relative "report/style"
92
+ require_relative "report/text"
93
+ require_relative "report/json"
94
+ require_relative "report/github"
95
+ require_relative "report/sweep"