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.
@@ -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
- "audition verdict: #{VERDICTS.fetch(@report.verdict)}"
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
- ## audition: #{VERDICTS.fetch(@report.verdict)}
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("audition #{f.check}")
52
+ title = property_escape("Audition #{f.check}")
52
53
  "::#{level} file=#{file}#{location}," \
53
54
  "title=#{title}::#{body}"
54
55
  end
@@ -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
@@ -21,7 +21,7 @@ module Audition
21
21
 
22
22
  def header
23
23
  s = @style
24
- title = s.bold("audition #{VERSION}")
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} #{s.dim(finding.check)}"
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)} " \