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,418 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ # Narrates a scan phase by phase on stderr. This class tracks
5
+ # only where the scan has got to; what the narration looks
6
+ # like—a line rewritten in place, a line per phase, or nothing
7
+ # at all—belongs to the renderer.
8
+ class Progress
9
+ # Below this many files a scan ends before a reader could read
10
+ # the first redraw.
11
+ AUTO_THRESHOLD = 200
12
+
13
+ # Shared by the renderers: both write to a stream that may go
14
+ # away, both spell out counts and durations the same way, and
15
+ # both paint with the palette the report uses.
16
+ class Renderer
17
+ def self.terminal?(io)
18
+ io.respond_to?(:tty?) && io.tty?
19
+ rescue IOError
20
+ false
21
+ end
22
+
23
+ # @param io [IO]
24
+ # @param style [Report::Style] defaults to whatever the
25
+ # stream supports, so a redirected run comes out plain
26
+ def initialize(io, style: nil)
27
+ @io = io
28
+ @style = style || Report::Style.detect(io: io)
29
+ @live = true
30
+ end
31
+
32
+ def update(progress) = nil
33
+
34
+ # A named unit is still just progress unless the renderer
35
+ # has a reason to treat it differently.
36
+ def item(progress) = update(progress)
37
+
38
+ def phase_done(progress) = nil
39
+
40
+ def clear = nil
41
+
42
+ private
43
+
44
+ def fraction(progress)
45
+ count = commas(progress.count)
46
+ total = progress.total
47
+ total ? "#{count}/#{commas(total)}" : count
48
+ end
49
+
50
+ # Narration is cosmetic: a stream that has gone away ends it,
51
+ # never the scan.
52
+ def write(text)
53
+ return unless @live
54
+
55
+ @io.write(text)
56
+ @io.flush if @io.respond_to?(:flush)
57
+ rescue IOError, SystemCallError
58
+ @live = false
59
+ end
60
+
61
+ def seconds(value)
62
+ format("%.1fs", value)
63
+ end
64
+
65
+ def commas(value)
66
+ value.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
67
+ end
68
+
69
+ # Named rather than dynamic dispatch, so the check for unsafe
70
+ # sends needs no exception here.
71
+ def paint(text, color)
72
+ case color
73
+ when :bold then @style.bold(text)
74
+ when :dim then @style.dim(text)
75
+ when :cyan then @style.cyan(text)
76
+ when :green then @style.green(text)
77
+ when :magenta then @style.magenta(text)
78
+ else text
79
+ end
80
+ end
81
+ end
82
+
83
+ # One status line, rewritten in place for the whole scan and
84
+ # erased when it ends.
85
+ class Line < Renderer
86
+ # Redraws closer together than this are invisible and cost a
87
+ # syscall on every file.
88
+ REDRAW_INTERVAL = 0.1
89
+
90
+ # Stands in for the counter while a stage runs work that
91
+ # cannot be counted, so the line still moves.
92
+ SPINNER = %w[| / - \\].freeze
93
+
94
+ DEFAULT_WIDTH = 80
95
+ MIN_WIDTH = 24
96
+
97
+ def self.width
98
+ columns = ENV["COLUMNS"].to_i
99
+ (columns >= MIN_WIDTH) ? columns : DEFAULT_WIDTH
100
+ end
101
+
102
+ # @param interval [Float] seconds between redraws within one
103
+ # stage; zero draws every update
104
+ def initialize(io, style: nil, interval: REDRAW_INTERVAL)
105
+ super(io, style: style)
106
+ @interval = interval
107
+ @width = self.class.width
108
+ @drawn = 0
109
+ @heading = nil
110
+ @redrawn_at = 0.0
111
+ @spin = 0
112
+ @mutex = Mutex.new
113
+ @heartbeat = nil
114
+ end
115
+
116
+ def update(progress)
117
+ if progress.countable?
118
+ stop_heartbeat
119
+ else
120
+ start_heartbeat(progress)
121
+ end
122
+ draw(progress)
123
+ end
124
+
125
+ def clear
126
+ stop_heartbeat
127
+ @mutex.synchronize { erase }
128
+ end
129
+
130
+ private
131
+
132
+ # Work with nothing to count would leave the line frozen,
133
+ # which reads as a hang, so a thread keeps the clock and the
134
+ # spinner moving until something countable starts.
135
+ def start_heartbeat(progress)
136
+ return if @heartbeat
137
+
138
+ @heartbeat = Thread.new do
139
+ loop do
140
+ sleep(REDRAW_INTERVAL)
141
+ @spin += 1
142
+ draw(progress)
143
+ end
144
+ end
145
+ nil
146
+ end
147
+
148
+ # `Mutex#synchronize` releases through `ensure`, so killing a
149
+ # drawing thread cannot leave the lock held.
150
+ def stop_heartbeat
151
+ @heartbeat&.kill
152
+ @heartbeat = nil
153
+ end
154
+
155
+ # A new phase or stage is drawn at once; redraws within one
156
+ # are throttled. The scan clock doubles as the throttle
157
+ # clock, so nothing here keeps time of its own.
158
+ def draw(progress)
159
+ @mutex.synchronize do
160
+ heading = heading(progress)
161
+ now = progress.elapsed
162
+ fresh = heading != @heading
163
+ next if !fresh && now - @redrawn_at < @interval
164
+
165
+ @heading = heading
166
+ @redrawn_at = now
167
+ render(status(progress, now))
168
+ end
169
+ end
170
+
171
+ def heading(progress)
172
+ [progress.label, progress.stage_label].compact.join(" ")
173
+ end
174
+
175
+ # The phase carries the weight, the stage and the clock are
176
+ # secondary, and the one moving number gets the accent color.
177
+ def status(progress, now)
178
+ [
179
+ [@style.glyph(:section), :dim],
180
+ ["Audition", :bold],
181
+ [progress.label, :bold],
182
+ [progress.stage_label, :dim],
183
+ *measure(progress),
184
+ [aside(progress, now), :dim]
185
+ ].reject { |text, _| text.nil? || text.empty? }
186
+ end
187
+
188
+ def measure(progress)
189
+ return [[spinner, :magenta]] unless progress.countable?
190
+
191
+ cells = [[fraction(progress), :cyan]]
192
+ percent = progress.percent
193
+ cells << ["#{percent}%", :green] if percent
194
+ cells
195
+ end
196
+
197
+ # How the run is going rather than what it is scanning, kept
198
+ # apart from the counts so neither reads as the other.
199
+ def aside(progress, now)
200
+ count = progress.ractors
201
+ on = count ? ", on #{count} ractors" : ""
202
+ "(#{seconds(now)}#{on})"
203
+ end
204
+
205
+ def spinner
206
+ SPINNER[@spin % SPINNER.size]
207
+ end
208
+
209
+ # Escape sequences make a painted string's own length useless,
210
+ # so the plain text is what gets measured: padded to the
211
+ # previous width so a shorter status leaves no tail behind,
212
+ # and one column short of the edge so the cursor never wraps.
213
+ # A line too long to fit is trimmed unpainted, since a cut
214
+ # through an escape sequence would corrupt the terminal.
215
+ def render(cells)
216
+ plain = cells.map(&:first).join(" ")
217
+ if plain.length > @width - 1
218
+ plain = plain[0, @width - 1]
219
+ text = plain
220
+ else
221
+ text = cells.map { |cell| paint(*cell) }.join(" ")
222
+ end
223
+ write("\r#{text}#{" " * [@drawn - plain.length, 0].max}\r")
224
+ @drawn = plain.length
225
+ end
226
+
227
+ def erase
228
+ return unless @drawn.positive?
229
+
230
+ write("\r#{" " * @drawn}\r")
231
+ @drawn = 0
232
+ end
233
+ end
234
+
235
+ # One completion line per phase. A log is the only trace a
236
+ # non-interactive run leaves, and a rewritten line would fill
237
+ # it with control characters.
238
+ class Log < Renderer
239
+ # A unit worth naming is worth a line of its own, since
240
+ # nothing here rewrites what came before.
241
+ def item(progress)
242
+ write(
243
+ "#{paint("Audition", :bold)}: #{progress.label} " \
244
+ "#{paint(progress.stage_label.to_s, :bold)} " \
245
+ "#{paint("(#{fraction(progress)})", :dim)}\n"
246
+ )
247
+ end
248
+
249
+ # Phases that walk the tree more than once report a duration
250
+ # and no count: each of their stage counts is a fraction of
251
+ # the work.
252
+ def phase_done(progress)
253
+ total = progress.phase_total
254
+ scanned = total ? ": #{commas(total)} #{progress.unit}" : ""
255
+ count = progress.ractors
256
+ on = count ? " on #{count} ractors" : ""
257
+ write(
258
+ "#{paint("Audition", :bold)}: " \
259
+ "#{paint(progress.label, :bold)}#{scanned} in " \
260
+ "#{paint(seconds(progress.phase_elapsed), :dim)}#{on}\n"
261
+ )
262
+ end
263
+ end
264
+
265
+ class << self
266
+ # The one clock in play: the renderers throttle and animate
267
+ # against the scan's own elapsed time rather than keeping
268
+ # time of their own.
269
+ def now
270
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
271
+ end
272
+
273
+ # @param units [Integer, nil] work items ahead; nil for work
274
+ # slow enough to narrate whatever its size, such as a
275
+ # sweep, where every unit is a scan of its own
276
+ # @param wanted [Boolean, nil] --progress or --no-progress;
277
+ # nil defers to the unit count, the format and the stream
278
+ # @param format [Symbol] machine formats are never narrated
279
+ # @param io [IO] never the stream carrying the report
280
+ # @param style [Report::Style, nil] nil detects from the
281
+ # stream; --plain passes a plain one
282
+ # @return [Progress]
283
+ def for(units: nil, wanted: nil, format: :text, io: $stderr,
284
+ style: nil)
285
+ renderer = renderer_for(units, wanted, format, io, style)
286
+ renderer ? new(renderer: renderer) : SILENT
287
+ end
288
+
289
+ private
290
+
291
+ def renderer_for(units, wanted, format, io, style)
292
+ return nil if wanted == false
293
+
294
+ terminal = Renderer.terminal?(io)
295
+ auto = terminal && format == :text &&
296
+ (units.nil? || units >= AUTO_THRESHOLD)
297
+ return nil unless wanted || auto
298
+
299
+ klass = terminal ? Line : Log
300
+ klass.new(io, style: style)
301
+ end
302
+ end
303
+
304
+ attr_reader :label, :stage_label, :count, :total, :renderer
305
+
306
+ # What the phase as a whole covers; its stages divide that up.
307
+ attr_reader :phase_total, :unit
308
+
309
+ # How many Ractors the phase is running on, nil while it is
310
+ # serial.
311
+ attr_reader :ractors
312
+
313
+ # @param renderer [Renderer, nil] nil narrates nothing
314
+ def initialize(renderer:)
315
+ @renderer = renderer
316
+ @count = 0
317
+ @started = self.class.now
318
+ @phase_started = @started
319
+ end
320
+
321
+ # Handed to analysis entry points as their default, so none of
322
+ # them has to ask whether narration is wanted. Shareable, so a
323
+ # worker can hold it, and the guards below keep it that way.
324
+ SILENT = Ractor.make_shareable(new(renderer: nil))
325
+
326
+ def enabled?
327
+ !@renderer.nil?
328
+ end
329
+
330
+ # Narrates one named phase for the duration of the block.
331
+ #
332
+ # @param label [String] phase name shown to the reader
333
+ # @param total [Integer, nil] units expected, nil when unknown
334
+ # @param unit [String] what the total counts
335
+ def phase(label, total: nil, unit: "files")
336
+ return yield self unless @renderer
337
+
338
+ @label = label
339
+ @phase_total = total
340
+ @unit = unit
341
+ @phase_started = self.class.now
342
+ @ractors = nil
343
+ restart(nil, total)
344
+ yield self
345
+ ensure
346
+ @renderer&.phase_done(self)
347
+ end
348
+
349
+ # Set by whoever spawns the workers, so the narration can say
350
+ # how much of the machine is at work. The guard keeps {SILENT}
351
+ # frozen and so shareable.
352
+ #
353
+ # @param count [Integer, nil] nil for serial work
354
+ def ractors=(count)
355
+ @ractors = count if @renderer
356
+ end
357
+
358
+ # Renames the work within the current phase and restarts its
359
+ # count. A nil total marks a step whose length is not known
360
+ # until it ends, which the renderer is then free to animate.
361
+ def stage(label, total: nil)
362
+ restart(label, total) if @renderer
363
+ end
364
+
365
+ def tick(count = 1)
366
+ return unless @renderer
367
+
368
+ @count += count
369
+ @renderer.update(self)
370
+ end
371
+
372
+ # Counts one unit and names it, for phases whose units are few
373
+ # enough to name. The name takes the stage slot, so nothing in
374
+ # the renderers has to make room for it.
375
+ def item(label)
376
+ return unless @renderer
377
+
378
+ @stage_label = label
379
+ @count += 1
380
+ @renderer.item(self)
381
+ end
382
+
383
+ # Clears whatever the narration left behind.
384
+ def finish
385
+ @renderer&.clear
386
+ end
387
+
388
+ # Seconds since the scan began.
389
+ def elapsed
390
+ self.class.now - @started
391
+ end
392
+
393
+ # Seconds since the current phase began.
394
+ def phase_elapsed
395
+ self.class.now - @phase_started
396
+ end
397
+
398
+ # @return [Integer, nil] 0..100, nil when nothing bounds it
399
+ def percent
400
+ return nil unless @total&.positive?
401
+
402
+ (100 * @count / @total).clamp(0, 100)
403
+ end
404
+
405
+ def countable?
406
+ !@total.nil? || @count.positive?
407
+ end
408
+
409
+ private
410
+
411
+ def restart(stage_label, total)
412
+ @stage_label = stage_label
413
+ @total = total
414
+ @count = 0
415
+ @renderer.update(self)
416
+ end
417
+ end
418
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ class Report
5
+ # GitHub Actions renderer: workflow-command annotations that
6
+ # land on the PR diff, plus a markdown table for the job
7
+ # summary page ($GITHUB_STEP_SUMMARY).
8
+ class Github
9
+ LEVELS = {
10
+ error: "error", warning: "warning", info: "notice"
11
+ }.freeze
12
+
13
+ def initialize(report)
14
+ @report = report
15
+ end
16
+
17
+ # @return [String] one `::error`/`::warning`/`::notice` line
18
+ # per finding plus a verdict line
19
+ def render
20
+ lines = @report.findings.map { |f| annotation(f) }
21
+ lines <<
22
+ "Audition verdict: #{VERDICTS.fetch(@report.verdict)}"
23
+ lines.join("\n")
24
+ end
25
+
26
+ # @return [String] verdict heading plus a counts table
27
+ def summary
28
+ c = @report.counts
29
+ <<~MARKDOWN
30
+ ## Audition: #{VERDICTS.fetch(@report.verdict)}
31
+
32
+ | findings | count |
33
+ | --- | --- |
34
+ | errors | #{c[:error]} |
35
+ | dependency errors | #{c[:dep_error]} |
36
+ | warnings | #{c[:warning]} |
37
+ | info | #{c[:info]} |
38
+ | test findings | #{c[:test_error] + c[:test_warning] + c[:test_info]} |
39
+ | fixable | #{c[:fixable]} |
40
+ MARKDOWN
41
+ end
42
+
43
+ private
44
+
45
+ def annotation(f)
46
+ level = LEVELS.fetch(f.severity)
47
+ location = f.line ? ",line=#{f.line}" : ""
48
+ body = workflow_escape("#{f.message}. #{f.why}")
49
+ # Annotations anchor to workspace-relative paths; a `./`
50
+ # prefix (from `audition .`) keeps them off the diff.
51
+ file = property_escape(f.path.delete_prefix("./"))
52
+ title = property_escape("Audition #{f.check}")
53
+ "::#{level} file=#{file}#{location}," \
54
+ "title=#{title}::#{body}"
55
+ end
56
+
57
+ def workflow_escape(text)
58
+ text.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
59
+ end
60
+
61
+ # Workflow command properties additionally reserve `:` and
62
+ # `,`; an unescaped comma in a path would end the property
63
+ # early.
64
+ def property_escape(text)
65
+ workflow_escape(text).gsub(":", "%3A").gsub(",", "%2C")
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Audition
6
+ class Report
7
+ # Machine-readable renderer for CI pipelines and --compare.
8
+ class Json
9
+ def initialize(report)
10
+ @report = report
11
+ end
12
+
13
+ def render
14
+ JSON.pretty_generate(
15
+ "audition" => VERSION,
16
+ "ruby" => RUBY_VERSION,
17
+ "target" => {"type" => @report.target_type.to_s,
18
+ "root" => @report.target_root},
19
+ "verdict" => @report.verdict.to_s,
20
+ "summary" => summary,
21
+ "findings" => findings,
22
+ "dynamic" => dynamic
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def summary
29
+ counts = @report.counts
30
+ {
31
+ "errors" => counts[:error],
32
+ "dependency_errors" => counts[:dep_error],
33
+ "warnings" => counts[:warning],
34
+ "infos" => counts[:info],
35
+ "test_errors" => counts[:test_error],
36
+ "test_warnings" => counts[:test_warning],
37
+ "test_infos" => counts[:test_info],
38
+ "fixable" => counts[:fixable]
39
+ }
40
+ end
41
+
42
+ def findings
43
+ @report.findings.map do |f|
44
+ {
45
+ "check" => f.check,
46
+ "severity" => f.severity.to_s,
47
+ "message" => f.message,
48
+ "why" => f.why,
49
+ "fix" => f.fix,
50
+ "path" => f.path,
51
+ "line" => f.line,
52
+ "source" => f.source,
53
+ "fixable" => f.fixable?,
54
+ "dependency" => f.dependency?,
55
+ "test" => f.test?
56
+ }
57
+ end
58
+ end
59
+
60
+ def dynamic
61
+ @report.dynamic_results.map do |r|
62
+ {"mode" => r.mode.to_s, "passed" => r.passed,
63
+ "raw" => r.raw}
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tty/link"
5
+
6
+ module Audition
7
+ class Report
8
+ # ANSI + OSC 8 styling with graceful degradation. Color and
9
+ # hyperlinks are decided once, at construction; pass color: false
10
+ # for pipes, NO_COLOR, or dumb terminals.
11
+ class Style
12
+ GLYPHS = Ractor.make_shareable(
13
+ {
14
+ error: ["✖", "x"], warning: ["⚠", "!"],
15
+ info: ["ℹ", "i"], pass: ["✔", "ok"],
16
+ section: ["◆", "*"], fix: ["✎", "+"]
17
+ }
18
+ )
19
+
20
+ PAINTS = %i[red yellow green cyan magenta dim bold].freeze
21
+
22
+ def self.detect(io: $stdout)
23
+ on = io.respond_to?(:tty?) && io.tty? &&
24
+ !ENV.key?("NO_COLOR") && ENV["TERM"] != "dumb"
25
+ new(color: on, hyperlinks: on && TTY::Link.link?)
26
+ end
27
+
28
+ def initialize(color:, hyperlinks:)
29
+ @pastel = Pastel.new(enabled: color)
30
+ @color = color
31
+ @hyperlinks = hyperlinks
32
+ end
33
+
34
+ def color?
35
+ @color
36
+ end
37
+
38
+ def glyph(kind)
39
+ GLYPHS.fetch(kind)[@color ? 0 : 1]
40
+ end
41
+
42
+ PAINTS.each do |name|
43
+ define_method(name) do |text| # audition:disable unsafe-calls
44
+ @pastel.public_send(name, text)
45
+ end
46
+ end
47
+
48
+ def severity_color(severity, text)
49
+ case severity
50
+ when :error then red(text)
51
+ when :warning then yellow(text)
52
+ else cyan(text)
53
+ end
54
+ end
55
+
56
+ # OSC 8 hyperlink wrapping "path:line" display text in a
57
+ # file:// URI; supporting terminals make it clickable.
58
+ # tty-link emits when it detects support; when hyperlinks are
59
+ # forced on despite no detection (tests, --force scenarios)
60
+ # fall back to the raw OSC 8 template, since tty-link's
61
+ # fallback is "text -> url" prose.
62
+ def link(text, absolute_path)
63
+ return text unless @hyperlinks
64
+
65
+ uri = "file://#{absolute_path}"
66
+ if TTY::Link.link?
67
+ TTY::Link.link_to(text, uri)
68
+ else
69
+ "\e]8;;#{uri}\e\\#{text}\e]8;;\e\\"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end