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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/doc/RESEARCH-visual-diff.md +226 -0
- data/lib/guardrails/a11y_audit.rb +21 -5
- data/lib/guardrails/a11y_deep.rb +28 -7
- data/lib/guardrails/audit.rb +160 -6
- data/lib/guardrails/class_itis.rb +34 -7
- data/lib/guardrails/cross_codebase_patterns.rb +38 -7
- data/lib/guardrails/partial_similarity.rb +38 -10
- data/lib/guardrails/report/style.rb +124 -0
- data/lib/guardrails/report/summary.rb +104 -0
- data/lib/guardrails/stimulus_audit.rb +30 -6
- data/lib/guardrails/version.rb +1 -1
- data/lib/guardrails/view_component_audit.rb +30 -7
- data/lib/guardrails/visual_diff.rb +18 -8
- data/lib/tasks/guardrails.rake +93 -6
- metadata +9 -9
|
@@ -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 ? "
|
|
218
|
+
noun = patterns.length == 1 ? "candidate" : "candidates"
|
|
219
|
+
|
|
216
220
|
@output.puts ""
|
|
217
|
-
@output.puts
|
|
218
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
#
|
|
213
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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 "
|
|
109
|
-
|
|
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 "
|
|
114
|
-
|
|
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
|
data/lib/guardrails/version.rb
CHANGED
|
@@ -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 "
|
|
139
|
-
|
|
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
|
|
143
|
-
@output.puts "
|
|
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 "
|
|
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
|
|
101
|
-
|
|
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 "
|
|
109
|
-
@output.puts "
|
|
110
|
-
@output.puts "
|
|
111
|
-
@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")}"
|
|
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
|