ui_guardrails 1.2.0 → 1.3.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 +20 -2
- data/lib/guardrails/a11y_audit.rb +26 -3
- data/lib/guardrails/a11y_deep.rb +27 -2
- data/lib/guardrails/audit.rb +31 -3
- data/lib/guardrails/class_itis.rb +30 -3
- data/lib/guardrails/cross_codebase_patterns.rb +30 -3
- data/lib/guardrails/partial_similarity.rb +30 -3
- data/lib/guardrails/report/finding.rb +63 -0
- data/lib/guardrails/report/html/template.html.erb +237 -0
- data/lib/guardrails/report/html.rb +104 -0
- data/lib/guardrails/report/run.rb +224 -0
- data/lib/guardrails/stimulus_audit.rb +88 -12
- data/lib/guardrails/tui/editor.rb +71 -0
- data/lib/guardrails/tui/keys.rb +101 -0
- data/lib/guardrails/tui/screen.rb +361 -0
- data/lib/guardrails/tui/state.rb +356 -0
- data/lib/guardrails/tui.rb +154 -0
- data/lib/guardrails/version.rb +1 -1
- data/lib/guardrails/view_component_audit.rb +57 -7
- data/lib/guardrails/visual_diff.rb +36 -6
- data/lib/tasks/guardrails.rake +41 -192
- metadata +10 -1
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../report/finding"
|
|
4
|
+
require_relative "../report/summary"
|
|
5
|
+
|
|
6
|
+
module Guardrails
|
|
7
|
+
class TUI
|
|
8
|
+
# Everything the TUI knows that isn't pixels: which screen you're
|
|
9
|
+
# on, where the cursor is, the active filter and grouping. Keys go
|
|
10
|
+
# in through `handle`, which returns an action for the main loop
|
|
11
|
+
# when the key asks for something State can't do itself (open an
|
|
12
|
+
# editor, write the web report, re-run the audit, quit).
|
|
13
|
+
#
|
|
14
|
+
# No terminal I/O in here — that's what makes the navigation
|
|
15
|
+
# logic testable without a PTY.
|
|
16
|
+
class State
|
|
17
|
+
# One line in a list. `:header` rows group items and are skipped
|
|
18
|
+
# by the cursor; `:item` rows are selectable and carry a payload
|
|
19
|
+
# (category name, file path, Finding, or Location).
|
|
20
|
+
Row = Struct.new(:kind, :label, :meta, :severity, :payload, :flag, keyword_init: true) do
|
|
21
|
+
def selectable?
|
|
22
|
+
kind == :item
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A screen in the drill-down stack.
|
|
27
|
+
# :rollup — categories grouped by severity (root, by category)
|
|
28
|
+
# :files — files ranked by finding count (root, by file)
|
|
29
|
+
# :findings — findings within one category / file
|
|
30
|
+
# :detail — one finding; rows are its locations
|
|
31
|
+
Frame = Struct.new(:kind, :title, :scope, :cursor, :scroll, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
GROUPINGS = %i[category file].freeze
|
|
34
|
+
NO_FILE = "(no file)"
|
|
35
|
+
|
|
36
|
+
attr_reader :grouping, :filter, :mode
|
|
37
|
+
attr_accessor :notice, :page_size
|
|
38
|
+
|
|
39
|
+
def initialize(categories:)
|
|
40
|
+
@grouping = :category
|
|
41
|
+
@filter = +""
|
|
42
|
+
@mode = :normal
|
|
43
|
+
@help = false
|
|
44
|
+
@page_size = 10
|
|
45
|
+
replace(categories: categories)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Swap in fresh audit results (initial load and re-run). Grouping
|
|
49
|
+
# and filter survive; the drill-down stack doesn't, since the
|
|
50
|
+
# finding you were looking at may no longer exist.
|
|
51
|
+
def replace(categories:)
|
|
52
|
+
@categories = categories
|
|
53
|
+
@rows_cache = {}.compare_by_identity
|
|
54
|
+
reset_frames
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def frame
|
|
58
|
+
@frames.last
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def depth
|
|
62
|
+
@frames.length
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def help?
|
|
66
|
+
@help
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def filtering?
|
|
70
|
+
@mode == :filter
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def breadcrumb
|
|
74
|
+
@frames.map(&:title)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rows
|
|
78
|
+
@rows_cache[frame] ||= build_rows(frame)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def selected_row
|
|
82
|
+
frame.cursor && rows[frame.cursor]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The finding the preview pane should describe: the highlighted
|
|
86
|
+
# row on a findings list, or the finding a detail screen is for.
|
|
87
|
+
def selected_finding
|
|
88
|
+
case frame.kind
|
|
89
|
+
when :findings then selected_row&.payload
|
|
90
|
+
when :detail then frame.scope
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def selected_location
|
|
95
|
+
case frame.kind
|
|
96
|
+
when :files then file_location(selected_row&.payload)
|
|
97
|
+
when :findings then selected_location_in_list
|
|
98
|
+
when :detail then selected_row&.payload
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def category_for(finding)
|
|
103
|
+
@categories.find { |c| c.name == finding.category }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def visible_findings
|
|
107
|
+
all = @categories.flat_map(&:findings)
|
|
108
|
+
return all if @filter.empty?
|
|
109
|
+
|
|
110
|
+
needle = @filter.downcase
|
|
111
|
+
all.select { |f| haystack(f).include?(needle) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def totals
|
|
115
|
+
counts = visible_findings.group_by(&:severity).transform_values(&:length)
|
|
116
|
+
Report::Summary::SEVERITY_ORDER.to_h { |s| [s, counts.fetch(s, 0)] }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def handle(key)
|
|
120
|
+
@notice = nil
|
|
121
|
+
if @help
|
|
122
|
+
@help = false
|
|
123
|
+
return nil
|
|
124
|
+
end
|
|
125
|
+
return handle_filter_key(key) if filtering?
|
|
126
|
+
|
|
127
|
+
handle_normal_key(key)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def handle_normal_key(key)
|
|
133
|
+
case key
|
|
134
|
+
when :up, "k" then move(-1)
|
|
135
|
+
when :down, "j" then move(1)
|
|
136
|
+
when :page_up then move(-@page_size)
|
|
137
|
+
when :page_down, " " then move(@page_size)
|
|
138
|
+
when :home, "g" then jump(:first)
|
|
139
|
+
when :end, "G" then jump(:last)
|
|
140
|
+
when :enter, :right, "l" then return drill_in
|
|
141
|
+
when :escape, :left, "h", :backspace then back
|
|
142
|
+
when :tab then toggle_grouping
|
|
143
|
+
when "/" then @mode = :filter
|
|
144
|
+
when "o" then return edit_action
|
|
145
|
+
when "w" then return [:web]
|
|
146
|
+
when "r" then return [:rerun]
|
|
147
|
+
when "?" then @help = true
|
|
148
|
+
when "q", :ctrl_c then return [:quit]
|
|
149
|
+
end
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def handle_filter_key(key)
|
|
154
|
+
case key
|
|
155
|
+
when :enter then @mode = :normal
|
|
156
|
+
when :escape then apply_filter(+"", done: true)
|
|
157
|
+
when :ctrl_c then return [:quit]
|
|
158
|
+
when :backspace
|
|
159
|
+
@filter.empty? ? @mode = :normal : apply_filter(@filter[0...-1])
|
|
160
|
+
when String then apply_filter(@filter + key)
|
|
161
|
+
end
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Filtering changes what every screen contains, so the stack
|
|
166
|
+
# collapses to the root rather than leaving you inside a category
|
|
167
|
+
# that may have just filtered down to nothing.
|
|
168
|
+
def apply_filter(text, done: false)
|
|
169
|
+
@filter = text
|
|
170
|
+
@mode = :normal if done
|
|
171
|
+
@rows_cache = {}.compare_by_identity
|
|
172
|
+
reset_frames
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def toggle_grouping
|
|
176
|
+
@grouping = GROUPINGS[(GROUPINGS.index(@grouping) + 1) % GROUPINGS.length]
|
|
177
|
+
reset_frames
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def reset_frames
|
|
181
|
+
root = if @grouping == :category
|
|
182
|
+
Frame.new(kind: :rollup, title: "All findings")
|
|
183
|
+
else
|
|
184
|
+
Frame.new(kind: :files, title: "All files")
|
|
185
|
+
end
|
|
186
|
+
@frames = [root]
|
|
187
|
+
place_cursor(root)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def drill_in
|
|
191
|
+
row = selected_row
|
|
192
|
+
return nil unless row
|
|
193
|
+
|
|
194
|
+
case frame.kind
|
|
195
|
+
when :rollup then push(Frame.new(kind: :findings, title: row.payload, scope: [:category, row.payload]))
|
|
196
|
+
when :files then push(Frame.new(kind: :findings, title: row.payload, scope: [:file, row.payload]))
|
|
197
|
+
when :findings then push(Frame.new(kind: :detail, title: short(row.payload.title), scope: row.payload))
|
|
198
|
+
when :detail then return edit_action
|
|
199
|
+
end
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def back
|
|
204
|
+
if @frames.length > 1
|
|
205
|
+
@rows_cache.delete(@frames.pop)
|
|
206
|
+
elsif !@filter.empty?
|
|
207
|
+
apply_filter(+"")
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def push(new_frame)
|
|
212
|
+
@frames << new_frame
|
|
213
|
+
place_cursor(new_frame)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def edit_action
|
|
217
|
+
location = selected_location
|
|
218
|
+
if location
|
|
219
|
+
[:edit, location]
|
|
220
|
+
else
|
|
221
|
+
@notice = frame.kind == :rollup ? "Open a category first — o works on a file or finding." : "Nothing to open — no file location here."
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def file_location(file)
|
|
227
|
+
Report::Location.new(file: file) if file && file != NO_FILE
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# On a file-scoped list, open the occurrence in *that* file, not
|
|
231
|
+
# whichever location the finding happens to list first.
|
|
232
|
+
def selected_location_in_list
|
|
233
|
+
finding = selected_row&.payload
|
|
234
|
+
return nil unless finding
|
|
235
|
+
|
|
236
|
+
kind, value = frame.scope
|
|
237
|
+
(kind == :file && finding.locations.find { |l| l.file == value }) || finding.location
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def place_cursor(target)
|
|
241
|
+
list = @rows_cache[target] ||= build_rows(target)
|
|
242
|
+
target.cursor = list.index(&:selectable?)
|
|
243
|
+
target.scroll = 0
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def move(delta)
|
|
247
|
+
return unless frame.cursor
|
|
248
|
+
|
|
249
|
+
selectable = rows.each_index.select { |i| rows[i].selectable? }
|
|
250
|
+
position = selectable.index(frame.cursor) || 0
|
|
251
|
+
frame.cursor = selectable[(position + delta).clamp(0, selectable.length - 1)]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def jump(edge)
|
|
255
|
+
selectable = rows.each_index.select { |i| rows[i].selectable? }
|
|
256
|
+
frame.cursor = selectable.public_send(edge) unless selectable.empty?
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def build_rows(target)
|
|
260
|
+
case target.kind
|
|
261
|
+
when :rollup then rollup_rows
|
|
262
|
+
when :files then file_rows
|
|
263
|
+
when :findings then finding_rows(target.scope)
|
|
264
|
+
when :detail then location_rows(target.scope)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def rollup_rows
|
|
269
|
+
by_category = visible_findings.group_by(&:category)
|
|
270
|
+
visible = @categories.select { |c| by_category.key?(c.name) }
|
|
271
|
+
|
|
272
|
+
Report::Summary::SEVERITY_ORDER.flat_map do |severity|
|
|
273
|
+
group = visible.select { |c| c.severity == severity }
|
|
274
|
+
next [] if group.empty?
|
|
275
|
+
|
|
276
|
+
total = group.sum { |c| by_category[c.name].length }
|
|
277
|
+
header = Row.new(kind: :header, severity: severity,
|
|
278
|
+
label: "#{pluralize(group.length, 'category', 'categories')}, #{pluralize(total, 'finding')}")
|
|
279
|
+
items = group.sort_by { |c| -by_category[c.name].length }.map do |c|
|
|
280
|
+
Row.new(kind: :item, label: c.name, severity: severity, payload: c.name,
|
|
281
|
+
meta: pluralize(by_category[c.name].length, "finding"),
|
|
282
|
+
flag: c.auto_fix ? "auto-fix" : nil)
|
|
283
|
+
end
|
|
284
|
+
[header, *items]
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# A finding spanning several files counts once in each of them —
|
|
289
|
+
# from a file's point of view, it has that problem.
|
|
290
|
+
def file_rows
|
|
291
|
+
by_file = Hash.new { |h, k| h[k] = [] }
|
|
292
|
+
visible_findings.each do |finding|
|
|
293
|
+
files = finding.files
|
|
294
|
+
(files.empty? ? [NO_FILE] : files).each { |file| by_file[file] << finding }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
by_file.sort_by { |file, list| [-list.length, file] }.map do |file, list|
|
|
298
|
+
Row.new(kind: :item, label: file, payload: file, severity: worst_severity(list),
|
|
299
|
+
meta: pluralize(list.length, "finding"))
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def finding_rows(scope)
|
|
304
|
+
kind, value = scope
|
|
305
|
+
list = visible_findings.select do |f|
|
|
306
|
+
if kind == :category then f.category == value
|
|
307
|
+
elsif value == NO_FILE then f.files.empty?
|
|
308
|
+
else f.files.include?(value)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
list.map do |finding|
|
|
313
|
+
Row.new(kind: :item, label: finding.title, severity: finding.severity, payload: finding,
|
|
314
|
+
meta: kind == :file ? file_scoped_meta(finding, value) : location_meta(finding),
|
|
315
|
+
# Within a category the rollup row already said so.
|
|
316
|
+
flag: kind == :file && finding.auto_fix ? "auto-fix" : nil)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def location_rows(finding)
|
|
321
|
+
finding.locations.map do |location|
|
|
322
|
+
Row.new(kind: :item, label: location.to_s, payload: location, severity: finding.severity)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def location_meta(finding)
|
|
327
|
+
case finding.locations.length
|
|
328
|
+
when 0 then ""
|
|
329
|
+
when 1 then finding.location.to_s
|
|
330
|
+
else "#{finding.locations.length} locations"
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def file_scoped_meta(finding, file)
|
|
335
|
+
lines = finding.locations.select { |l| l.file == file }.map(&:line).compact
|
|
336
|
+
lines.empty? ? finding.category : "#{finding.category} · line #{lines.first(3).join(', ')}#{lines.length > 3 ? '…' : ''}"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def worst_severity(findings)
|
|
340
|
+
Report::Summary::SEVERITY_ORDER.find { |s| findings.any? { |f| f.severity == s } }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def haystack(finding)
|
|
344
|
+
[finding.category, finding.title, finding.suggestion, *finding.files].compact.join("\n").downcase
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def pluralize(count, singular, plural = "#{singular}s")
|
|
348
|
+
"#{count} #{count == 1 ? singular : plural}"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def short(text, limit = 40)
|
|
352
|
+
text.length <= limit ? text : "#{text[0, limit - 1]}…"
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require_relative "../guardrails"
|
|
5
|
+
require_relative "report/style"
|
|
6
|
+
require_relative "report/html"
|
|
7
|
+
require_relative "tui/state"
|
|
8
|
+
require_relative "tui/screen"
|
|
9
|
+
require_relative "tui/keys"
|
|
10
|
+
require_relative "tui/editor"
|
|
11
|
+
|
|
12
|
+
module Guardrails
|
|
13
|
+
# Interactive browser for an audit run: a severity roll-up you can
|
|
14
|
+
# drill into (category → finding → location), regroup by file,
|
|
15
|
+
# filter, and jump from into your editor or the HTML report.
|
|
16
|
+
#
|
|
17
|
+
# This class owns the terminal and nothing else — raw mode, the
|
|
18
|
+
# alternate screen, the read-key/redraw loop, and the side effects
|
|
19
|
+
# a key can ask for. Navigation lives in TUI::State, drawing in
|
|
20
|
+
# TUI::Screen, key decoding in TUI::Keys; each is testable without
|
|
21
|
+
# a terminal, which this file is not.
|
|
22
|
+
#
|
|
23
|
+
# Built on io/console alone. A list with drill-down doesn't need a
|
|
24
|
+
# widget toolkit, and the gem's dependency list stays at two.
|
|
25
|
+
class TUI
|
|
26
|
+
class NotInteractive < Guardrails::Error; end
|
|
27
|
+
|
|
28
|
+
ENTER_SCREEN = "\e[?1049h\e[?25l" # alternate screen, hide cursor
|
|
29
|
+
LEAVE_SCREEN = "\e[?25h\e[?1049l"
|
|
30
|
+
RESIZE_POLL = 0.25
|
|
31
|
+
|
|
32
|
+
# `runner` is a callable returning a finished Report::Run. It's a
|
|
33
|
+
# callable rather than a Run so `r` can re-run the audit with the
|
|
34
|
+
# same options the task was started with.
|
|
35
|
+
def initialize(runner:, root:, input: $stdin, output: $stdout, env: ENV)
|
|
36
|
+
@runner = runner
|
|
37
|
+
@root = root
|
|
38
|
+
@input = input
|
|
39
|
+
@output = output
|
|
40
|
+
@env = env
|
|
41
|
+
@sources = {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def start
|
|
45
|
+
unless @input.respond_to?(:tty?) && @input.tty? && @output.tty?
|
|
46
|
+
raise NotInteractive, "guardrails:tui needs an interactive terminal. " \
|
|
47
|
+
"Use guardrails:audit (or FORMAT=json / FORMAT=html) when piping or in CI."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@output.puts "Running Guardrails audit…"
|
|
51
|
+
@run = @runner.call
|
|
52
|
+
@state = State.new(categories: @run.categories)
|
|
53
|
+
@keys = Keys.new(@input)
|
|
54
|
+
with_terminal { event_loop }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def event_loop
|
|
60
|
+
draw
|
|
61
|
+
loop do
|
|
62
|
+
key = @keys.next(timeout: RESIZE_POLL)
|
|
63
|
+
if key.nil?
|
|
64
|
+
draw if resized?
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
break if key == :eof || perform(@state.handle(key)) == :quit
|
|
68
|
+
|
|
69
|
+
draw
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def perform(action)
|
|
74
|
+
kind, payload = action
|
|
75
|
+
case kind
|
|
76
|
+
when :quit then :quit
|
|
77
|
+
when :edit then open_editor(payload)
|
|
78
|
+
when :web then open_web_report
|
|
79
|
+
when :rerun then rerun
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def open_editor(location)
|
|
84
|
+
command = Editor.command(location, root: @root, env: @env)
|
|
85
|
+
return @state.notice = "No editor found — set $EDITOR (or $GUARDRAILS_EDITOR)." unless command
|
|
86
|
+
|
|
87
|
+
ok = Editor.terminal?(command) ? suspended { system(*command) } : system(*command, out: File::NULL, err: File::NULL)
|
|
88
|
+
@state.notice = ok ? "Opened #{location}" : "Couldn't run #{command.first} — check $EDITOR."
|
|
89
|
+
@sources.clear # the file may have just been edited
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def open_web_report
|
|
93
|
+
path = Report::Html.new(categories: @run.categories, root: @root).write
|
|
94
|
+
opener = Editor.opener(path.to_s)
|
|
95
|
+
opened = opener && system(*opener, out: File::NULL, err: File::NULL)
|
|
96
|
+
@state.notice = "#{opened ? 'Opened' : 'Wrote'} #{path.relative_path_from(Pathname(@root).expand_path)}"
|
|
97
|
+
rescue SystemCallError => e
|
|
98
|
+
@state.notice = "Couldn't write the report: #{e.message}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def rerun
|
|
102
|
+
@state.notice = "Running audit…"
|
|
103
|
+
draw
|
|
104
|
+
@run = @runner.call
|
|
105
|
+
@sources.clear
|
|
106
|
+
@state.replace(categories: @run.categories)
|
|
107
|
+
@state.notice = "Audit re-run."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# ---- terminal ---------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def draw
|
|
113
|
+
rows, columns = size
|
|
114
|
+
@drawn_size = [rows, columns]
|
|
115
|
+
lines = Screen.new(state: @state, root: @root, width: columns, height: rows,
|
|
116
|
+
color: Report::Style.new(io: @output).color?, sources: @sources).lines
|
|
117
|
+
# \e[K after each line clears leftovers from a longer previous
|
|
118
|
+
# frame; \r\n because raw mode doesn't translate newlines.
|
|
119
|
+
@output.write("\e[H#{lines.map { |line| "#{line}\e[K" }.join("\r\n")}\e[J")
|
|
120
|
+
@output.flush
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def size
|
|
124
|
+
rows, columns = @output.winsize
|
|
125
|
+
rows.to_i.positive? && columns.to_i.positive? ? [rows, columns] : [24, 80]
|
|
126
|
+
rescue SystemCallError, NotImplementedError
|
|
127
|
+
[24, 80]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def resized?
|
|
131
|
+
size != @drawn_size
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def with_terminal
|
|
135
|
+
@input.raw do
|
|
136
|
+
@output.write(ENTER_SCREEN)
|
|
137
|
+
yield
|
|
138
|
+
ensure
|
|
139
|
+
@output.write(LEAVE_SCREEN)
|
|
140
|
+
@output.flush
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Hand the terminal to a child process (a terminal editor), then
|
|
145
|
+
# take it back.
|
|
146
|
+
def suspended
|
|
147
|
+
@output.write(LEAVE_SCREEN)
|
|
148
|
+
@output.flush
|
|
149
|
+
@input.cooked { yield }
|
|
150
|
+
ensure
|
|
151
|
+
@output.write(ENTER_SCREEN)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
data/lib/guardrails/version.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require_relative "report/style"
|
|
5
|
+
require_relative "report/finding"
|
|
5
6
|
|
|
6
7
|
module Guardrails
|
|
7
8
|
class ViewComponentAudit
|
|
@@ -68,8 +69,60 @@ module Guardrails
|
|
|
68
69
|
orphans
|
|
69
70
|
end
|
|
70
71
|
|
|
72
|
+
# Detector-agnostic view of `result` (see Report::Finding). Missing
|
|
73
|
+
# previews point at the component class file so there's somewhere
|
|
74
|
+
# to jump to.
|
|
75
|
+
def categories(result)
|
|
76
|
+
[
|
|
77
|
+
Report::Category.new(
|
|
78
|
+
name: "missing previews", severity: :warning,
|
|
79
|
+
framing: missing_previews_framing(result.missing_previews.length).join(" "),
|
|
80
|
+
findings: result.missing_previews.map { |name|
|
|
81
|
+
Report::Finding.new(
|
|
82
|
+
category: "missing previews", severity: :warning,
|
|
83
|
+
title: "missing preview: #{name}_component", suggestion: missing_preview_suggestion(name),
|
|
84
|
+
locations: [Report::Location.new(file: "#{COMPONENT_DIR}/#{name}_component.rb")]
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
),
|
|
88
|
+
Report::Category.new(
|
|
89
|
+
name: "orphan slots", severity: :warning, framing: ORPHAN_SLOTS_FRAMING.join(" "),
|
|
90
|
+
findings: result.orphan_slots.map { |o|
|
|
91
|
+
Report::Finding.new(
|
|
92
|
+
category: "orphan slots", severity: :warning,
|
|
93
|
+
title: "orphan slot: #{o.component}_component##{o.slot} (#{o.slot_kind})",
|
|
94
|
+
suggestion: orphan_slot_suggestion(o),
|
|
95
|
+
locations: [Report::Location.new(file: o.file, line: o.line)]
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
].reject { |c| c.findings.empty? }
|
|
100
|
+
end
|
|
101
|
+
|
|
71
102
|
private
|
|
72
103
|
|
|
104
|
+
ORPHAN_SLOTS_FRAMING = [
|
|
105
|
+
"renders_one / renders_many declared in the component class but never",
|
|
106
|
+
"referenced in the template. Either reference the slot or remove the",
|
|
107
|
+
"declaration."
|
|
108
|
+
].freeze
|
|
109
|
+
|
|
110
|
+
def missing_previews_framing(count)
|
|
111
|
+
noun = count == 1 ? "component" : "components"
|
|
112
|
+
[
|
|
113
|
+
"Component classes without a corresponding Lookbook preview file.",
|
|
114
|
+
"Add #{noun} previews so the component is discoverable + visually testable."
|
|
115
|
+
]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def missing_preview_suggestion(name)
|
|
119
|
+
"create test/components/previews/#{name}_component_preview.rb (or lookbook/previews/...)"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def orphan_slot_suggestion(slot)
|
|
123
|
+
"reference :#{slot.slot} in the template, or remove the #{slot.slot_kind} declaration"
|
|
124
|
+
end
|
|
125
|
+
|
|
73
126
|
def component_files
|
|
74
127
|
base = @root.join(COMPONENT_DIR)
|
|
75
128
|
return [] unless base.exist?
|
|
@@ -141,12 +194,11 @@ module Guardrails
|
|
|
141
194
|
:warning,
|
|
142
195
|
"view_components missing previews (#{result.missing_previews.length} #{noun})"
|
|
143
196
|
)
|
|
144
|
-
@output.puts "
|
|
145
|
-
@output.puts " Add #{noun} previews so the component is discoverable + visually testable."
|
|
197
|
+
missing_previews_framing(result.missing_previews.length).each { |line| @output.puts " #{line}" }
|
|
146
198
|
result.missing_previews.each do |name|
|
|
147
199
|
@output.puts ""
|
|
148
200
|
@output.puts " #{@style.severity(:warning, "missing preview: #{name}_component")}"
|
|
149
|
-
@output.puts " #{@style.suggestion(
|
|
201
|
+
@output.puts " #{@style.suggestion(missing_preview_suggestion(name))}"
|
|
150
202
|
end
|
|
151
203
|
end
|
|
152
204
|
|
|
@@ -157,14 +209,12 @@ module Guardrails
|
|
|
157
209
|
:warning,
|
|
158
210
|
"view_components orphan slots (#{result.orphan_slots.length} #{noun})"
|
|
159
211
|
)
|
|
160
|
-
@output.puts "
|
|
161
|
-
@output.puts " referenced in the template. Either reference the slot or remove the"
|
|
162
|
-
@output.puts " declaration."
|
|
212
|
+
ORPHAN_SLOTS_FRAMING.each { |line| @output.puts " #{line}" }
|
|
163
213
|
result.orphan_slots.each do |o|
|
|
164
214
|
@output.puts ""
|
|
165
215
|
header = "orphan slot: #{o.component}_component##{o.slot} (#{o.slot_kind})"
|
|
166
216
|
@output.puts " #{@style.severity(:warning, header)}"
|
|
167
|
-
@output.puts " #{@style.suggestion(
|
|
217
|
+
@output.puts " #{@style.suggestion(orphan_slot_suggestion(o))}"
|
|
168
218
|
@output.puts " #{@style.location("#{o.file}:#{o.line}")}"
|
|
169
219
|
end
|
|
170
220
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require_relative "configuration"
|
|
5
5
|
require_relative "report/style"
|
|
6
|
+
require_relative "report/finding"
|
|
6
7
|
|
|
7
8
|
module Guardrails
|
|
8
9
|
# Consumes screenshot-diff tool output and folds findings into the
|
|
@@ -68,8 +69,40 @@ module Guardrails
|
|
|
68
69
|
adapter.collect
|
|
69
70
|
end
|
|
70
71
|
|
|
72
|
+
# Detector-agnostic view of `findings` (see Report::Finding). The
|
|
73
|
+
# diff image is the thing to open, so it leads the locations.
|
|
74
|
+
def categories(findings)
|
|
75
|
+
return [] if findings.empty?
|
|
76
|
+
|
|
77
|
+
[Report::Category.new(
|
|
78
|
+
name: "visual diff", severity: :error, framing: FRAMING.join(" "),
|
|
79
|
+
findings: findings.map { |f|
|
|
80
|
+
Report::Finding.new(
|
|
81
|
+
category: "visual diff", severity: :error,
|
|
82
|
+
title: "#{ratio_label(f)} #{f.scenario}#{f.viewport ? " (#{f.viewport})" : ""}",
|
|
83
|
+
suggestion: SUGGESTION,
|
|
84
|
+
locations: [f.diff_path, f.baseline_path].compact.map { |path| Report::Location.new(file: path) },
|
|
85
|
+
details: [["baseline", f.baseline_path], ["diff", f.diff_path], ["url", f.url], ["selector", f.selector]]
|
|
86
|
+
.reject { |_, v| v.nil? }
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
)]
|
|
90
|
+
end
|
|
91
|
+
|
|
71
92
|
private
|
|
72
93
|
|
|
94
|
+
FRAMING = [
|
|
95
|
+
"Screenshot-diff tool flagged these scenarios. Review each diff image",
|
|
96
|
+
"and either accept the new baseline (commit the updated screenshot) or",
|
|
97
|
+
"fix the regression that caused the visual change."
|
|
98
|
+
].freeze
|
|
99
|
+
|
|
100
|
+
SUGGESTION = "compare baseline ↔ diff; accept the new baseline or fix the regression"
|
|
101
|
+
|
|
102
|
+
def ratio_label(finding)
|
|
103
|
+
finding.mismatch_ratio.nil? ? "[diff present]" : "[#{(finding.mismatch_ratio * 100).round(2)}% mismatch]"
|
|
104
|
+
end
|
|
105
|
+
|
|
73
106
|
def adapter
|
|
74
107
|
case @adapter_name
|
|
75
108
|
when :snap_diff
|
|
@@ -104,17 +137,14 @@ module Guardrails
|
|
|
104
137
|
:error,
|
|
105
138
|
"visual diff (#{findings.length} #{noun}, adapter: #{@adapter_name}, threshold: #{@threshold})"
|
|
106
139
|
)
|
|
107
|
-
@output.puts "
|
|
108
|
-
@output.puts " and either accept the new baseline (commit the updated screenshot) or"
|
|
109
|
-
@output.puts " fix the regression that caused the visual change."
|
|
140
|
+
FRAMING.each { |line| @output.puts " #{line}" }
|
|
110
141
|
|
|
111
142
|
findings.each do |f|
|
|
112
|
-
ratio_label = f.mismatch_ratio.nil? ? "[diff present]" : "[#{(f.mismatch_ratio * 100).round(2)}% mismatch]"
|
|
113
143
|
suffix = f.viewport ? " (#{f.viewport})" : ""
|
|
114
144
|
|
|
115
145
|
@output.puts ""
|
|
116
|
-
@output.puts " #{@style.severity(:error, "#{ratio_label} #{f.scenario}#{suffix}")}"
|
|
117
|
-
@output.puts " #{@style.suggestion(
|
|
146
|
+
@output.puts " #{@style.severity(:error, "#{ratio_label(f)} #{f.scenario}#{suffix}")}"
|
|
147
|
+
@output.puts " #{@style.suggestion(SUGGESTION)}"
|
|
118
148
|
@output.puts " #{@style.location("baseline: #{f.baseline_path}")}" if f.baseline_path
|
|
119
149
|
@output.puts " #{@style.location("diff: #{f.diff_path}")}" if f.diff_path
|
|
120
150
|
@output.puts " #{@style.location("url: #{f.url}")}" if f.url
|