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.
- checksums.yaml +4 -4
- data/README.md +175 -39
- data/lib/audition/bundle_sweep.rb +32 -15
- data/lib/audition/cli.rb +181 -100
- data/lib/audition/config.rb +33 -7
- data/lib/audition/dynamic/harness.rb +323 -31
- data/lib/audition/dynamic/prober.rb +190 -43
- data/lib/audition/finding.rb +10 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/report/github.rb +69 -0
- data/lib/audition/report/json.rb +68 -0
- data/lib/audition/report/style.rb +74 -0
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +160 -0
- data/lib/audition/report.rb +16 -295
- data/lib/audition/rewriters.rb +8 -5
- 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 +162 -28
- data/lib/audition/static/checks/runtime_require.rb +4 -1
- data/lib/audition/static/checks/unsafe_calls.rb +7 -6
- 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 +1053 -12
- data/lib/audition/static/literal_classifier.rb +191 -20
- data/lib/audition/static/native_extensions.rb +175 -0
- data/lib/audition/static/source_file.rb +70 -0
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +147 -14
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +3 -0
- metadata +15 -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
|
|
@@ -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
|