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.
- checksums.yaml +4 -4
- data/README.md +125 -49
- data/lib/audition/bundle_sweep.rb +25 -15
- data/lib/audition/cli.rb +128 -140
- data/lib/audition/config.rb +30 -4
- data/lib/audition/dynamic/harness.rb +415 -41
- data/lib/audition/dynamic/prober.rb +271 -54
- data/lib/audition/finding.rb +14 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/reconciliation.rb +86 -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 +29 -2
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/instance_memoization.rb +226 -0
- data/lib/audition/static/checks/mutable_constants.rb +172 -9
- data/lib/audition/static/checks/ractor_isolation.rb +214 -15
- data/lib/audition/static/checks/unsafe_calls.rb +7 -4
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +6 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1113 -15
- data/lib/audition/static/literal_classifier.rb +385 -20
- 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 +3 -0
- metadata +12 -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,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
# What the dynamic probe proved shareable retires the static
|
|
5
|
+
# pass's guesses about the same objects. A constant the probe
|
|
6
|
+
# read as shareable needs no static warning at all; class-level
|
|
7
|
+
# state whose every value was shareable after boot keeps its
|
|
8
|
+
# finding as an info note, because reads are legal from any
|
|
9
|
+
# Ractor and only a later write would raise.
|
|
10
|
+
module Reconciliation
|
|
11
|
+
PROVEN_WHY =
|
|
12
|
+
"The dynamic probe found every value this variable held " \
|
|
13
|
+
"after boot shareable, so reads from any Ractor are legal; " \
|
|
14
|
+
"only a later write would raise Ractor::IsolationError, " \
|
|
15
|
+
"and writes must stay on the main Ractor."
|
|
16
|
+
PROVEN_FIX =
|
|
17
|
+
"Keep every write at boot on the main Ractor, or warm the " \
|
|
18
|
+
"memo before spawning Ractors."
|
|
19
|
+
|
|
20
|
+
# @param findings [Array<Finding>] static findings
|
|
21
|
+
# @param results [Array<Dynamic::Result>] the probes that ran
|
|
22
|
+
# @return [Array<Finding>] findings with the disproven ones
|
|
23
|
+
# dropped or downgraded
|
|
24
|
+
def self.apply(findings, results)
|
|
25
|
+
constants, ivars = proven(results)
|
|
26
|
+
return findings if constants.empty? && ivars.empty?
|
|
27
|
+
|
|
28
|
+
findings.filter_map do |finding|
|
|
29
|
+
case finding.check
|
|
30
|
+
when "mutable-constants"
|
|
31
|
+
site = [realpath(finding.path), finding.line]
|
|
32
|
+
constants.include?(site) ? nil : finding
|
|
33
|
+
when "class-level-state"
|
|
34
|
+
if finding.severity != :info && finding.subject &&
|
|
35
|
+
ivars.include?(finding.subject)
|
|
36
|
+
finding.with(
|
|
37
|
+
severity: :info,
|
|
38
|
+
message: "#{finding.message}, shareable in the " \
|
|
39
|
+
"dynamic probe",
|
|
40
|
+
why: PROVEN_WHY,
|
|
41
|
+
fix: PROVEN_FIX
|
|
42
|
+
)
|
|
43
|
+
else
|
|
44
|
+
finding
|
|
45
|
+
end
|
|
46
|
+
else
|
|
47
|
+
finding
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Array(Set, Set)] constant sites ([path, line]) and
|
|
53
|
+
# class-level ivars ("Owner/@name") observed shareable
|
|
54
|
+
def self.proven(results)
|
|
55
|
+
constants = Set.new
|
|
56
|
+
ivars = Set.new
|
|
57
|
+
results.each do |result|
|
|
58
|
+
raw = result.raw
|
|
59
|
+
next unless raw.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
Array(raw["proven_constants"]).each do |path, line|
|
|
62
|
+
constants << [realpath(path), line]
|
|
63
|
+
end
|
|
64
|
+
Array(raw["class_state"]).each do |entry|
|
|
65
|
+
unshareable = Array(entry["unshareable"])
|
|
66
|
+
owner = entry["const"].to_s
|
|
67
|
+
Array(entry["ivars"]).each do |ivar|
|
|
68
|
+
next if unshareable.include?(ivar)
|
|
69
|
+
|
|
70
|
+
ivars << "#{owner}/#{ivar}"
|
|
71
|
+
# A write through a singleton attribute only knows
|
|
72
|
+
# its owner's last segment.
|
|
73
|
+
ivars << "#{owner.split("::").last}/#{ivar}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
[constants, ivars]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.realpath(path)
|
|
81
|
+
File.realpath(path)
|
|
82
|
+
rescue SystemCallError, TypeError
|
|
83
|
+
path
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
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
|