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.
- checksums.yaml +4 -4
- data/README.md +77 -33
- data/lib/audition/bundle_sweep.rb +24 -15
- data/lib/audition/cli.rb +127 -140
- data/lib/audition/config.rb +30 -4
- data/lib/audition/dynamic/harness.rb +219 -20
- data/lib/audition/dynamic/prober.rb +150 -44
- data/lib/audition/finding.rb +10 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/report/github.rb +4 -3
- data/lib/audition/report/json.rb +5 -1
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +13 -2
- data/lib/audition/report.rb +8 -1
- data/lib/audition/rewriters.rb +4 -1
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/mutable_constants.rb +71 -0
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +5 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1050 -7
- data/lib/audition/static/literal_classifier.rb +118 -18
- data/lib/audition/static/native_extensions.rb +28 -16
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +82 -13
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +2 -0
- metadata +10 -4
|
@@ -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
|
|
@@ -19,7 +19,7 @@ module Audition
|
|
|
19
19
|
def render
|
|
20
20
|
lines = @report.findings.map { |f| annotation(f) }
|
|
21
21
|
lines <<
|
|
22
|
-
"
|
|
22
|
+
"Audition verdict: #{VERDICTS.fetch(@report.verdict)}"
|
|
23
23
|
lines.join("\n")
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -27,7 +27,7 @@ module Audition
|
|
|
27
27
|
def summary
|
|
28
28
|
c = @report.counts
|
|
29
29
|
<<~MARKDOWN
|
|
30
|
-
##
|
|
30
|
+
## Audition: #{VERDICTS.fetch(@report.verdict)}
|
|
31
31
|
|
|
32
32
|
| findings | count |
|
|
33
33
|
| --- | --- |
|
|
@@ -35,6 +35,7 @@ module Audition
|
|
|
35
35
|
| dependency errors | #{c[:dep_error]} |
|
|
36
36
|
| warnings | #{c[:warning]} |
|
|
37
37
|
| info | #{c[:info]} |
|
|
38
|
+
| test findings | #{c[:test_error] + c[:test_warning] + c[:test_info]} |
|
|
38
39
|
| fixable | #{c[:fixable]} |
|
|
39
40
|
MARKDOWN
|
|
40
41
|
end
|
|
@@ -48,7 +49,7 @@ module Audition
|
|
|
48
49
|
# Annotations anchor to workspace-relative paths; a `./`
|
|
49
50
|
# prefix (from `audition .`) keeps them off the diff.
|
|
50
51
|
file = property_escape(f.path.delete_prefix("./"))
|
|
51
|
-
title = property_escape("
|
|
52
|
+
title = property_escape("Audition #{f.check}")
|
|
52
53
|
"::#{level} file=#{file}#{location}," \
|
|
53
54
|
"title=#{title}::#{body}"
|
|
54
55
|
end
|
data/lib/audition/report/json.rb
CHANGED
|
@@ -32,6 +32,9 @@ module Audition
|
|
|
32
32
|
"dependency_errors" => counts[:dep_error],
|
|
33
33
|
"warnings" => counts[:warning],
|
|
34
34
|
"infos" => counts[:info],
|
|
35
|
+
"test_errors" => counts[:test_error],
|
|
36
|
+
"test_warnings" => counts[:test_warning],
|
|
37
|
+
"test_infos" => counts[:test_info],
|
|
35
38
|
"fixable" => counts[:fixable]
|
|
36
39
|
}
|
|
37
40
|
end
|
|
@@ -48,7 +51,8 @@ module Audition
|
|
|
48
51
|
"line" => f.line,
|
|
49
52
|
"source" => f.source,
|
|
50
53
|
"fixable" => f.fixable?,
|
|
51
|
-
"dependency" => f.dependency
|
|
54
|
+
"dependency" => f.dependency?,
|
|
55
|
+
"test" => f.test?
|
|
52
56
|
}
|
|
53
57
|
end
|
|
54
58
|
end
|
|
@@ -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
|
data/lib/audition/report/text.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Audition
|
|
|
21
21
|
|
|
22
22
|
def header
|
|
23
23
|
s = @style
|
|
24
|
-
title = s.bold("
|
|
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}
|
|
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)} " \
|