ui_guardrails 1.0.0 → 1.2.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.
@@ -4,6 +4,7 @@ require "pathname"
4
4
  require "digest"
5
5
  require "set"
6
6
  require_relative "erb_parser"
7
+ require_relative "report/style"
7
8
 
8
9
  module Guardrails
9
10
  # Finds recurring structural patterns across the codebase — element
@@ -50,12 +51,14 @@ module Guardrails
50
51
  def initialize(root:, output: $stdout,
51
52
  min_size: DEFAULT_MIN_SIZE,
52
53
  min_occurrences: DEFAULT_MIN_OCCURRENCES,
53
- max_occurrences_shown: DEFAULT_MAX_OCCURRENCES_SHOWN)
54
+ max_occurrences_shown: DEFAULT_MAX_OCCURRENCES_SHOWN,
55
+ style: nil)
54
56
  @root = Pathname(root)
55
57
  @output = output
56
58
  @min_size = min_size
57
59
  @min_occurrences = min_occurrences
58
60
  @max_occurrences_shown = max_occurrences_shown
61
+ @style = style || Report::Style.new(io: output)
59
62
  end
60
63
 
61
64
  def run
@@ -212,24 +215,52 @@ module Guardrails
212
215
  return if patterns.empty?
213
216
 
214
217
  total_occurrences = patterns.sum(&:count)
215
- noun = patterns.length == 1 ? "shape" : "shapes"
218
+ noun = patterns.length == 1 ? "candidate" : "candidates"
219
+
216
220
  @output.puts ""
217
- @output.puts "Guardrails patterns: #{patterns.length} recurring #{noun} " \
218
- "(#{total_occurrences} occurrences; >= #{@min_size} elements, >= #{@min_occurrences} occurrences)"
221
+ @output.puts @style.section_heading(
222
+ :suggestion,
223
+ "cross-codebase patterns (#{patterns.length} #{noun}, #{total_occurrences} occurrences)"
224
+ )
225
+ @output.puts " These element subtrees repeat #{@min_occurrences}+ times across your views and"
226
+ @output.puts " components. Each is a candidate for extracting into a shared partial or"
227
+ @output.puts " ViewComponent. Threshold: >= #{@min_size} elements, >= #{@min_occurrences} occurrences."
219
228
 
220
229
  patterns.each do |pattern|
221
230
  @output.puts ""
222
- @output.puts " Pattern (#{pattern.size} elements, #{pattern.count} occurrences): #{truncate_shape(pattern.shape)}"
231
+ header = "shape: #{truncate_shape(pattern.shape)} (#{pattern.size} elements, #{pattern.count} occurrences)"
232
+ @output.puts " #{@style.severity(:suggestion, header)}"
233
+ @output.puts " #{@style.suggestion(suggestion_for(pattern))}"
223
234
  pattern.occurrences.first(@max_occurrences_shown).each do |occ|
224
- @output.puts " #{occ.file}:#{occ.line}"
235
+ @output.puts " #{@style.location("#{occ.file}:#{occ.line}")}"
225
236
  end
226
237
  if pattern.occurrences.length > @max_occurrences_shown
227
238
  remaining = pattern.occurrences.length - @max_occurrences_shown
228
- @output.puts " … and #{remaining} more"
239
+ @output.puts " #{@style.location("… and #{remaining} more")}"
229
240
  end
230
241
  end
231
242
  end
232
243
 
244
+ # The suggestion line varies with shape signal: small repeats want
245
+ # a generic partial; large/very-repeating shapes nudge toward a
246
+ # named component. Specific enough to be actionable without
247
+ # pretending we know the user's design system.
248
+ def suggestion_for(pattern)
249
+ if pattern.count >= 6
250
+ "repeats often enough that a named component is likely the right shape"
251
+ elsif pattern.size >= 10
252
+ "consider extracting into a ViewComponent (large enough to earn one)"
253
+ else
254
+ "consider extracting into a shared partial (e.g. _#{partial_hint(pattern)}.html.erb)"
255
+ end
256
+ end
257
+
258
+ # Quick name hint based on the root tag of the shape. Pure UX,
259
+ # not a contract — users will pick their own name.
260
+ def partial_hint(pattern)
261
+ pattern.shape[/\A(\w+)/, 1] || "shared"
262
+ end
263
+
233
264
  # Cap shape display length so deep nested patterns don't blow out
234
265
  # the terminal. The fingerprint is what we match on; the shape is
235
266
  # just for human inspection.
@@ -3,6 +3,7 @@
3
3
  require "pathname"
4
4
  require "set"
5
5
  require_relative "erb_parser"
6
+ require_relative "report/style"
6
7
 
7
8
  module Guardrails
8
9
  class PartialSimilarity
@@ -20,11 +21,13 @@ module Guardrails
20
21
  "app/components/**/*_component.html.erb"
21
22
  ].freeze
22
23
 
23
- def initialize(root:, output: $stdout, threshold: DEFAULT_THRESHOLD, ngram_size: DEFAULT_NGRAM_SIZE)
24
+ def initialize(root:, output: $stdout, threshold: DEFAULT_THRESHOLD,
25
+ ngram_size: DEFAULT_NGRAM_SIZE, style: nil)
24
26
  @root = Pathname(root)
25
27
  @output = output
26
28
  @threshold = threshold
27
29
  @ngram_size = ngram_size
30
+ @style = style || Report::Style.new(io: output)
28
31
  end
29
32
 
30
33
  def run
@@ -202,30 +205,55 @@ module Guardrails
202
205
 
203
206
  groups = group_findings(findings)
204
207
  total_files = groups.sum { |g| g[:files].size }
208
+ group_noun = groups.length == 1 ? "group" : "groups"
205
209
 
206
210
  @output.puts ""
207
- group_noun = groups.length == 1 ? "group" : "groups"
208
- @output.puts "Guardrails templates: #{groups.length} similar #{group_noun} (#{findings.length} pairs across #{total_files} files; >= #{@threshold} structural similarity)"
211
+ @output.puts @style.section_heading(
212
+ :suggestion,
213
+ "similar partials (#{groups.length} #{group_noun}, #{findings.length} pairs, #{total_files} files)"
214
+ )
215
+ @output.puts " Templates with >= #{@threshold} structural similarity. Likely duplicates;"
216
+ @output.puts " consider extracting the common shape into a partial or parameterizing"
217
+ @output.puts " one with locals to subsume the others."
209
218
 
210
219
  groups.each do |group|
220
+ @output.puts ""
211
221
  if group[:files].length == 2
212
- # Use the original Finding so we keep the tag-count suffix
213
- # (e.g. "(12 / 14 tags)") that single-pair output has always
214
- # included. The sorted file list is still authoritative for
215
- # display order.
222
+ # Pair — keep the tag-count suffix; it's a useful signal of
223
+ # how big the templates are.
216
224
  pair = group[:sample_pair]
217
225
  file_a, file_b = group[:files]
218
- @output.puts " #{format('%.2f', group[:score_max])} #{file_a} ↔ #{file_b} (#{pair.tag_count_a} / #{pair.tag_count_b} tags)"
226
+ header = "#{format('%.2f', group[:score_max])} similar: #{file_a} ↔ #{file_b}"
227
+ @output.puts " #{@style.severity(:suggestion, header)}"
228
+ @output.puts " #{@style.suggestion(suggestion_for_pair(group))}"
229
+ @output.puts " #{@style.location("#{pair.tag_count_a} / #{pair.tag_count_b} tags")}"
219
230
  else
220
231
  score_label = if group[:score_min] == group[:score_max]
221
232
  format("%.2f", group[:score_max])
222
233
  else
223
234
  "#{format('%.2f', group[:score_min])}–#{format('%.2f', group[:score_max])}"
224
235
  end
225
- @output.puts " Group of #{group[:files].length} templates (#{score_label}, #{group[:pair_count]} pairs):"
226
- group[:files].each { |f| @output.puts " #{f}" }
236
+ header = "group of #{group[:files].length} similar templates (#{score_label}, #{group[:pair_count]} pairs)"
237
+ @output.puts " #{@style.severity(:suggestion, header)}"
238
+ @output.puts " #{@style.suggestion(suggestion_for_group(group))}"
239
+ group[:files].each { |f| @output.puts " #{@style.location(f)}" }
227
240
  end
228
241
  end
229
242
  end
243
+
244
+ def suggestion_for_pair(group)
245
+ score = group[:score_max]
246
+ if score >= 0.95
247
+ "near-identical — pick one and delete the other, or merge with locals"
248
+ elsif score >= 0.85
249
+ "very similar — parameterize one with locals and render it from the other"
250
+ else
251
+ "shared structure — consider a partial that both can render"
252
+ end
253
+ end
254
+
255
+ def suggestion_for_group(group)
256
+ "#{group[:files].length} templates sharing structure — strong candidate for one shared partial"
257
+ end
230
258
  end
231
259
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Guardrails
4
+ module Report
5
+ # ANSI styling for the text audit report. Three rules:
6
+ #
7
+ # 1. Colors only when the output is a real terminal (TTY check).
8
+ # Piping to a file or `tee` produces plain text.
9
+ # 2. `NO_COLOR=1` always wins. (https://no-color.org/ convention.)
10
+ # 3. Tests pass a non-TTY StringIO and get plain output by default
11
+ # — no need to scrub ANSI sequences out of every expectation.
12
+ #
13
+ # Callers don't decide whether to colorize; they just call
14
+ # `Style.severity(:error, "raw_color")` and the style instance
15
+ # quietly emits ANSI or plain text based on those rules.
16
+ class Style
17
+ # Foreground codes, kept small. Bold + dim are modifiers, not
18
+ # colors — combined where we want emphasis without screaming.
19
+ ANSI = {
20
+ reset: "\e[0m",
21
+ bold: "\e[1m",
22
+ dim: "\e[2m",
23
+ red: "\e[31m",
24
+ yellow: "\e[33m",
25
+ green: "\e[32m",
26
+ cyan: "\e[36m",
27
+ blue: "\e[34m",
28
+ magenta: "\e[35m"
29
+ }.freeze
30
+
31
+ # Severity → (glyph, color) tuple. Glyphs are ASCII so they
32
+ # render in any terminal; we don't use emoji or box-drawing
33
+ # for the per-line tags. The summary box uses light box-drawing
34
+ # characters separately, with an ASCII fallback for terminals
35
+ # that mangle them (rare in 2026 but possible over SSH/PTY).
36
+ SEVERITY_FORMAT = {
37
+ error: { glyph: "x", color: :red, label: "ERROR" },
38
+ warning: { glyph: "!", color: :yellow, label: "WARNING" },
39
+ suggestion: { glyph: "i", color: :cyan, label: "SUGGEST" }
40
+ }.freeze
41
+
42
+ def initialize(io: $stdout, force: nil, no_color: nil)
43
+ @io = io
44
+ @force = force
45
+ @no_color = no_color
46
+ end
47
+
48
+ # True when we should emit ANSI sequences. Tests pass force:
49
+ # true/false to bypass auto-detection.
50
+ def color?
51
+ return @force unless @force.nil?
52
+ return false if no_color_env?
53
+
54
+ @io.respond_to?(:tty?) && @io.tty?
55
+ end
56
+
57
+ # Wrap text in an ANSI color code if `color?`, else return
58
+ # unchanged. `style` can be a single key or array (e.g.
59
+ # `[:bold, :red]`) — codes concatenate.
60
+ def colorize(text, style)
61
+ return text unless color?
62
+
63
+ codes = Array(style).map { |k| ANSI.fetch(k) }.join
64
+ "#{codes}#{text}#{ANSI[:reset]}"
65
+ end
66
+
67
+ # Tag a finding line with its severity. Output shape:
68
+ #
69
+ # [error] raw_color
70
+ # [warning] helper_recommended
71
+ # [suggest] pattern
72
+ #
73
+ # Padding aligns the brackets so columns line up across the
74
+ # report. Colorized form bolds the bracket+label.
75
+ def severity(level, category)
76
+ format = SEVERITY_FORMAT.fetch(level)
77
+ tag = "[#{format[:label].downcase}]".ljust(10)
78
+ "#{colorize(tag, [:bold, format[:color]])} #{category}"
79
+ end
80
+
81
+ # Section heading — bolded category label with a colored
82
+ # severity glyph in front. Used for the per-detector section
83
+ # intro: `x ERROR — raw_color (82 findings)`.
84
+ def section_heading(level, title)
85
+ format = SEVERITY_FORMAT.fetch(level)
86
+ glyph = colorize(format[:glyph], [:bold, format[:color]])
87
+ "#{glyph} #{colorize(format[:label], [:bold, format[:color]])} #{colorize("—", :dim)} #{colorize(title, :bold)}"
88
+ end
89
+
90
+ # File:line:col, in dim so it recedes when the content next to
91
+ # it is what matters. Returns plain text when colors are off.
92
+ def location(path)
93
+ colorize(path, :dim)
94
+ end
95
+
96
+ # Inline suggestion arrow. Always rendered the same way so the
97
+ # eye learns it: `→ <action>`. Cyan so it stands out from the
98
+ # finding line without competing with the severity color.
99
+ def suggestion(text)
100
+ "#{colorize("→", :cyan)} #{text}"
101
+ end
102
+
103
+ # Box-drawing characters for the top-of-report summary header.
104
+ # Falls back to ASCII (`+ - |`) when colors are off, since
105
+ # both behaviors track the same TTY/NO_COLOR signal.
106
+ def box_chars
107
+ if color?
108
+ { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", t: "├", b: "┤" }
109
+ else
110
+ { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|", t: "+", b: "+" }
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def no_color_env?
117
+ return @no_color unless @no_color.nil?
118
+
119
+ # Per https://no-color.org/: ANY value (even empty) disables color.
120
+ ENV.key?("NO_COLOR")
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "style"
4
+
5
+ module Guardrails
6
+ module Report
7
+ # Top-of-report triage view. Builds a grouped severity rollup from
8
+ # the audit's per-detector counts, so the reader sees the shape of
9
+ # the findings before scrolling through the per-section detail.
10
+ #
11
+ # Each detector contributes one Entry. The rake task assembles the
12
+ # full list after running its sub-audits and hands it to Summary.
13
+ # Detector logic isn't aware of the report; Summary doesn't know
14
+ # about specific detectors. That decoupling matters when we add
15
+ # new detectors — they only need to register an Entry.
16
+ class Summary
17
+ # Severity order in the report: errors first (urgent), warnings
18
+ # next (probably-fix), suggestions last (consider).
19
+ SEVERITY_ORDER = %i[error warning suggestion].freeze
20
+
21
+ Entry = Struct.new(:category, :count, :severity, :unit, :action, :auto_fix,
22
+ keyword_init: true) do
23
+ # `unit` is the noun shown after the count: "findings",
24
+ # "candidates", "groups", "clusters" — each detector picks
25
+ # what reads naturally. Defaults to "findings".
26
+ def unit
27
+ self[:unit] || "findings"
28
+ end
29
+ end
30
+
31
+ def initialize(entries:, output:, style: nil)
32
+ @entries = entries.reject { |e| e.count.zero? }
33
+ @output = output
34
+ @style = style || Style.new(io: output)
35
+ end
36
+
37
+ def render(recap: false)
38
+ return if @entries.empty?
39
+
40
+ @output.puts ""
41
+ @output.puts header_line(recap: recap)
42
+ @output.puts ""
43
+
44
+ SEVERITY_ORDER.each do |severity|
45
+ group = @entries.select { |e| e.severity == severity }
46
+ next if group.empty?
47
+
48
+ render_severity_group(severity, group)
49
+ end
50
+
51
+ @output.puts divider
52
+ end
53
+
54
+ private
55
+
56
+ def header_line(recap: false)
57
+ kind = recap ? "recap" : "audit"
58
+ title = "Guardrails #{kind} — #{total_findings} #{total_findings == 1 ? "finding" : "findings"}"
59
+ bar = "═" * 3
60
+ bar_plain = "=" * 3
61
+ # The divider character tracks the color setting so an
62
+ # ANSI-stripped pipe stays ASCII-only.
63
+ bar_used = @style.color? ? bar : bar_plain
64
+ rest = (@style.color? ? "═" : "=") * [70 - title.length - bar_used.length - 4, 4].max
65
+
66
+ "#{@style.colorize(bar_used + ' ', :bold)}" \
67
+ "#{@style.colorize(title, :bold)} " \
68
+ "#{@style.colorize(rest, :dim)}"
69
+ end
70
+
71
+ def render_severity_group(severity, entries)
72
+ total = entries.sum(&:count)
73
+ @output.puts " #{@style.section_heading(severity, "#{entries.length} #{entries.length == 1 ? "category" : "categories"}, #{total} #{total == 1 ? "finding" : "findings"}")}"
74
+
75
+ entries.sort_by { |e| -e.count }.each do |entry|
76
+ render_entry(entry)
77
+ end
78
+ @output.puts ""
79
+ end
80
+
81
+ def render_entry(entry)
82
+ name = entry.category.ljust(32)
83
+ unit = entry.unit
84
+ unit = unit.sub(/s\z/, "") if entry.count == 1 && unit.end_with?("s")
85
+ count_str = "#{entry.count.to_s.rjust(4)} #{unit}".ljust(22)
86
+ flags = []
87
+ flags << @style.colorize("[auto-fix available]", :green) if entry.auto_fix
88
+ flags << @style.colorize(entry.action, :dim) if entry.action && !entry.auto_fix
89
+
90
+ line = " #{name}#{count_str}#{flags.join(' ')}".rstrip
91
+ @output.puts line
92
+ end
93
+
94
+ def divider
95
+ char = @style.color? ? "═" : "="
96
+ @style.colorize(char * 75, :dim)
97
+ end
98
+
99
+ def total_findings
100
+ @entries.sum(&:count)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
+ require_relative "report/style"
4
5
 
5
6
  module Guardrails
6
7
  class StimulusAudit
@@ -36,9 +37,10 @@ module Guardrails
36
37
  RUBY_DATA_CONTROLLER_PATTERN =
37
38
  /data:?\s*(?:=>)?\s*\{[^}]*?controller:?\s*(?:=>)?\s*["']([^"']+)["']/m
38
39
 
39
- def initialize(root:, output: $stdout)
40
+ def initialize(root:, output: $stdout, style: nil)
40
41
  @root = Pathname(root)
41
42
  @output = output
43
+ @style = style || Report::Style.new(io: output)
42
44
  end
43
45
 
44
46
  def run
@@ -102,16 +104,38 @@ module Guardrails
102
104
  def print_report(result)
103
105
  return unless result.violations?
104
106
 
105
- @output.puts ""
106
107
  unless result.orphaned.empty?
107
108
  noun = result.orphaned.length == 1 ? "controller" : "controllers"
108
- @output.puts "Guardrails stimulus: #{result.orphaned.length} orphaned #{noun} (referenced in HTML, no JS file)"
109
- result.orphaned.each { |name| @output.puts " - #{name}" }
109
+ @output.puts ""
110
+ @output.puts @style.section_heading(
111
+ :warning,
112
+ "stimulus orphaned (#{result.orphaned.length} #{noun})"
113
+ )
114
+ @output.puts " data-controller=\"…\" references a Stimulus controller, but no matching"
115
+ @output.puts " *_controller.{js,ts} file exists. Either create the controller or"
116
+ @output.puts " remove the reference."
117
+ result.orphaned.each do |name|
118
+ @output.puts ""
119
+ @output.puts " #{@style.severity(:warning, "stimulus orphaned: #{name}")}"
120
+ @output.puts " #{@style.suggestion("create app/javascript/controllers/#{name}_controller.js or remove the data-controller=\"#{name}\" reference")}"
121
+ end
110
122
  end
123
+
111
124
  unless result.dead.empty?
112
125
  noun = result.dead.length == 1 ? "controller" : "controllers"
113
- @output.puts "Guardrails stimulus: #{result.dead.length} dead #{noun} (JS file, never referenced)"
114
- result.dead.each { |name| @output.puts " - #{name}" }
126
+ @output.puts ""
127
+ @output.puts @style.section_heading(
128
+ :warning,
129
+ "stimulus dead (#{result.dead.length} #{noun})"
130
+ )
131
+ @output.puts " *_controller.{js,ts} file exists, but no view references it via"
132
+ @output.puts " data-controller=\"…\". Either wire the controller into a template"
133
+ @output.puts " or delete the file."
134
+ result.dead.each do |name|
135
+ @output.puts ""
136
+ @output.puts " #{@style.severity(:warning, "stimulus dead: #{name}")}"
137
+ @output.puts " #{@style.suggestion("reference it via data-controller=\"#{name}\" in a view, or delete the JS file")}"
138
+ end
115
139
  end
116
140
  end
117
141
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Guardrails
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
+ require_relative "report/style"
4
5
 
5
6
  module Guardrails
6
7
  class ViewComponentAudit
@@ -22,9 +23,10 @@ module Guardrails
22
23
 
23
24
  SLOT_PATTERN = /^\s*(renders_one|renders_many)\s+:([a-z_][\w]*)/
24
25
 
25
- def initialize(root:, output: $stdout)
26
+ def initialize(root:, output: $stdout, style: nil)
26
27
  @root = Pathname(root)
27
28
  @output = output
29
+ @style = style || Report::Style.new(io: output)
28
30
  end
29
31
 
30
32
  def run
@@ -132,17 +134,38 @@ module Guardrails
132
134
  def print_report(result)
133
135
  return unless result.violations?
134
136
 
135
- @output.puts ""
136
137
  unless result.missing_previews.empty?
137
138
  noun = result.missing_previews.length == 1 ? "component" : "components"
138
- @output.puts "Guardrails view_components: #{result.missing_previews.length} #{noun} without a preview"
139
- result.missing_previews.each { |name| @output.puts " - #{name}_component.rb (no #{name}_component_preview.rb)" }
139
+ @output.puts ""
140
+ @output.puts @style.section_heading(
141
+ :warning,
142
+ "view_components missing previews (#{result.missing_previews.length} #{noun})"
143
+ )
144
+ @output.puts " Component classes without a corresponding Lookbook preview file."
145
+ @output.puts " Add #{noun} previews so the component is discoverable + visually testable."
146
+ result.missing_previews.each do |name|
147
+ @output.puts ""
148
+ @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/...)")}"
150
+ end
140
151
  end
152
+
141
153
  unless result.orphan_slots.empty?
142
- noun = result.orphan_slots.length == 1 ? "slot declared" : "slots declared"
143
- @output.puts "Guardrails view_components: #{result.orphan_slots.length} #{noun} but never referenced in template"
154
+ noun = result.orphan_slots.length == 1 ? "slot" : "slots"
155
+ @output.puts ""
156
+ @output.puts @style.section_heading(
157
+ :warning,
158
+ "view_components orphan slots (#{result.orphan_slots.length} #{noun})"
159
+ )
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."
144
163
  result.orphan_slots.each do |o|
145
- @output.puts " - #{o.component}_component: :#{o.slot} (#{o.slot_kind} at #{o.file}:#{o.line})"
164
+ @output.puts ""
165
+ header = "orphan slot: #{o.component}_component##{o.slot} (#{o.slot_kind})"
166
+ @output.puts " #{@style.severity(:warning, header)}"
167
+ @output.puts " #{@style.suggestion("reference :#{o.slot} in the template, or remove the #{o.slot_kind} declaration")}"
168
+ @output.puts " #{@style.location("#{o.file}:#{o.line}")}"
146
169
  end
147
170
  end
148
171
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "pathname"
4
4
  require_relative "configuration"
5
+ require_relative "report/style"
5
6
 
6
7
  module Guardrails
7
8
  # Consumes screenshot-diff tool output and folds findings into the
@@ -36,7 +37,7 @@ module Guardrails
36
37
  end
37
38
 
38
39
  def initialize(root:, output: $stdout,
39
- adapter: nil, threshold: nil)
40
+ adapter: nil, threshold: nil, style: nil)
40
41
  @root = Pathname(root)
41
42
  @output = output
42
43
  cfg = Guardrails.configuration.visual_diff
@@ -46,6 +47,7 @@ module Guardrails
46
47
  # adapter = "snap_diff"; c.visual_diff.threshold = "0.1" }`.
47
48
  @adapter_name = coerce_adapter(adapter) || cfg.adapter
48
49
  @threshold = threshold.nil? ? cfg.threshold : Float(threshold)
50
+ @style = style || Report::Style.new(io: output)
49
51
  end
50
52
 
51
53
  def run
@@ -96,19 +98,27 @@ module Guardrails
96
98
  def print_report(findings)
97
99
  return if findings.empty?
98
100
 
101
+ noun = findings.length == 1 ? "finding" : "findings"
99
102
  @output.puts ""
100
- @output.puts "Guardrails visual diff: #{findings.length} finding#{'s' if findings.length != 1} " \
101
- "(adapter: #{@adapter_name}, threshold: #{@threshold})"
103
+ @output.puts @style.section_heading(
104
+ :error,
105
+ "visual diff (#{findings.length} #{noun}, adapter: #{@adapter_name}, threshold: #{@threshold})"
106
+ )
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."
102
110
 
103
111
  findings.each do |f|
104
112
  ratio_label = f.mismatch_ratio.nil? ? "[diff present]" : "[#{(f.mismatch_ratio * 100).round(2)}% mismatch]"
105
113
  suffix = f.viewport ? " (#{f.viewport})" : ""
114
+
106
115
  @output.puts ""
107
- @output.puts " #{ratio_label} #{f.scenario}#{suffix}"
108
- @output.puts " baseline: #{f.baseline_path}" if f.baseline_path
109
- @output.puts " diff: #{f.diff_path}" if f.diff_path
110
- @output.puts " url: #{f.url}" if f.url
111
- @output.puts " selector: #{f.selector}" if f.selector
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")}"
118
+ @output.puts " #{@style.location("baseline: #{f.baseline_path}")}" if f.baseline_path
119
+ @output.puts " #{@style.location("diff: #{f.diff_path}")}" if f.diff_path
120
+ @output.puts " #{@style.location("url: #{f.url}")}" if f.url
121
+ @output.puts " #{@style.location("selector: #{f.selector}")}" if f.selector
112
122
  end
113
123
  end
114
124
  end