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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Guardrails
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -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 " Component classes without a corresponding Lookbook preview file."
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("create test/components/previews/#{name}_component_preview.rb (or lookbook/previews/...)")}"
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 " renders_one / renders_many declared in the component class but never"
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("reference :#{o.slot} in the template, or remove the #{o.slot_kind} declaration")}"
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 " Screenshot-diff tool flagged these scenarios. Review each diff image"
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("compare baseline ↔ diff; accept the new baseline or fix the regression")}"
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