ui_guardrails 1.0.0 → 1.1.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/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 +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 792f503cf353ceaa71b9e45e39a4c3f9ae9255b40f4ecf361a207048074857e8
|
|
4
|
+
data.tar.gz: ff563d0927467149ddc42011500457c7c637db11fc9770566db64e3a0a4427e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3eb224a5d378469571b727813ac68adcacd95576c63bade906a29e4119f9c0b28e972dbbbcf3710acbb6d7647684b14250a28b61c0a52ca65fd2c96e4f435d3a
|
|
7
|
+
data.tar.gz: 62c132501303b6cc6bcd5ffa8bf566955fc82cf6c4ed30ea3d1ad678ff4270841a57b636daf635035a38681a2e8797ab1e0018ceb62685a144aa840dc3aed238
|
|
@@ -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
|
# Static a11y checks that don't require a browser — element-level rules
|
|
@@ -23,9 +24,17 @@ module Guardrails
|
|
|
23
24
|
|
|
24
25
|
NON_INTERACTIVE_INPUT_TYPES = %w[hidden submit button reset image].freeze
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
SUGGESTION_FOR_RULE = {
|
|
28
|
+
"image_alt" => "add an alt attribute (or alt=\"\" if decorative)",
|
|
29
|
+
"button_name" => "add text, aria-label, or aria-labelledby",
|
|
30
|
+
"link_name" => "add link text, aria-label, or aria-labelledby",
|
|
31
|
+
"input_label" => "add aria-label, aria-labelledby, or a matching <label for=...>"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def initialize(root:, output: $stdout, style: nil)
|
|
27
35
|
@root = Pathname(root)
|
|
28
36
|
@output = output
|
|
37
|
+
@style = style || Report::Style.new(io: output)
|
|
29
38
|
end
|
|
30
39
|
|
|
31
40
|
def run
|
|
@@ -237,12 +246,19 @@ module Guardrails
|
|
|
237
246
|
def print_report(findings)
|
|
238
247
|
return if findings.empty?
|
|
239
248
|
|
|
240
|
-
@output.puts ""
|
|
241
249
|
noun = findings.length == 1 ? "issue" : "issues"
|
|
242
|
-
@output.puts "
|
|
250
|
+
@output.puts ""
|
|
251
|
+
@output.puts @style.section_heading(:error, "a11y (#{findings.length} static #{noun})")
|
|
252
|
+
@output.puts " Element-level a11y rules answerable from view source — missing alt text,"
|
|
253
|
+
@output.puts " unnamed buttons, unlabeled inputs, link without name. Full WCAG coverage"
|
|
254
|
+
@output.puts " needs runtime checks; layer axe-core via AXE_JSON= for that."
|
|
255
|
+
|
|
243
256
|
findings.each do |f|
|
|
244
|
-
@output.puts "
|
|
245
|
-
@output.puts "
|
|
257
|
+
@output.puts ""
|
|
258
|
+
@output.puts " #{@style.severity(:error, "#{f.rule}: #{f.snippet.to_s[0, 60]}")}"
|
|
259
|
+
suggestion = SUGGESTION_FOR_RULE[f.rule.to_s]
|
|
260
|
+
@output.puts " #{@style.suggestion(suggestion)}" if suggestion
|
|
261
|
+
@output.puts " #{@style.location("#{f.file}:#{f.line}:#{f.column}")}"
|
|
246
262
|
end
|
|
247
263
|
end
|
|
248
264
|
end
|
data/lib/guardrails/a11y_deep.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "pathname"
|
|
5
5
|
require "set"
|
|
6
|
+
require_relative "report/style"
|
|
6
7
|
|
|
7
8
|
module Guardrails
|
|
8
9
|
# Consumes axe-core JSON output and folds the findings into Guardrails'
|
|
@@ -37,10 +38,11 @@ module Guardrails
|
|
|
37
38
|
# `failing_impacts:` if your rule pack emits custom severities.
|
|
38
39
|
DEFAULT_FAILING_IMPACTS = %w[minor moderate serious critical].freeze
|
|
39
40
|
|
|
40
|
-
def initialize(input:, output: $stdout, failing_impacts: DEFAULT_FAILING_IMPACTS)
|
|
41
|
+
def initialize(input:, output: $stdout, failing_impacts: DEFAULT_FAILING_IMPACTS, style: nil)
|
|
41
42
|
@input = input
|
|
42
43
|
@output = output
|
|
43
44
|
@failing_impacts = Set.new(failing_impacts.map(&:to_s))
|
|
45
|
+
@style = style || Report::Style.new(io: output)
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
def run
|
|
@@ -101,19 +103,38 @@ module Guardrails
|
|
|
101
103
|
return if findings.empty?
|
|
102
104
|
|
|
103
105
|
grouped = findings.group_by(&:url)
|
|
106
|
+
noun = findings.length == 1 ? "finding" : "findings"
|
|
107
|
+
|
|
104
108
|
@output.puts ""
|
|
105
|
-
@output.puts
|
|
109
|
+
@output.puts @style.section_heading(
|
|
110
|
+
:error,
|
|
111
|
+
"a11y deep (#{findings.length} #{noun} from axe-core)"
|
|
112
|
+
)
|
|
113
|
+
@output.puts " Runtime accessibility issues axe-core caught against your live pages."
|
|
114
|
+
@output.puts " Each links to dequeuniversity.com for the canonical remediation."
|
|
106
115
|
|
|
107
116
|
grouped.each do |url, page_findings|
|
|
108
117
|
@output.puts ""
|
|
109
|
-
@output.puts " #{url || '(no url)'}"
|
|
118
|
+
@output.puts " #{@style.location(url || '(no url)')}"
|
|
110
119
|
page_findings.each do |f|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
@output.puts "
|
|
120
|
+
severity = impact_to_severity(f.impact)
|
|
121
|
+
impact_label = f.impact ? f.impact.to_s : "unknown"
|
|
122
|
+
selector_part = f.selector ? " (#{f.selector})" : ""
|
|
123
|
+
@output.puts " #{@style.severity(severity, "[#{impact_label}] #{f.rule}: #{f.description}#{selector_part}")}"
|
|
124
|
+
@output.puts " #{@style.suggestion("see #{f.help_url}")}" if f.help_url
|
|
115
125
|
end
|
|
116
126
|
end
|
|
117
127
|
end
|
|
128
|
+
|
|
129
|
+
# Map axe-core's impact levels onto the report's three severities
|
|
130
|
+
# so they color-code consistently with static a11y findings.
|
|
131
|
+
def impact_to_severity(impact)
|
|
132
|
+
case impact.to_s
|
|
133
|
+
when "critical", "serious" then :error
|
|
134
|
+
when "moderate" then :warning
|
|
135
|
+
when "minor" then :suggestion
|
|
136
|
+
else :warning
|
|
137
|
+
end
|
|
138
|
+
end
|
|
118
139
|
end
|
|
119
140
|
end
|
data/lib/guardrails/audit.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "set"
|
|
|
5
5
|
require "stringio"
|
|
6
6
|
require "yaml"
|
|
7
7
|
require_relative "erb_parser"
|
|
8
|
+
require_relative "report/style"
|
|
8
9
|
|
|
9
10
|
module Guardrails
|
|
10
11
|
class Audit
|
|
@@ -69,13 +70,14 @@ module Guardrails
|
|
|
69
70
|
flood-color lighting-color stop-color
|
|
70
71
|
].freeze
|
|
71
72
|
|
|
72
|
-
def initialize(root:, output: $stdout, suggest: false, format: :text, apply: false)
|
|
73
|
+
def initialize(root:, output: $stdout, suggest: false, format: :text, apply: false, style: nil)
|
|
73
74
|
@root = Pathname(root)
|
|
74
75
|
@output = output
|
|
75
76
|
@suggest = suggest
|
|
76
77
|
@format = format
|
|
77
78
|
@apply = apply
|
|
78
79
|
@config = load_audit_config
|
|
80
|
+
@style = style
|
|
79
81
|
end
|
|
80
82
|
|
|
81
83
|
def run
|
|
@@ -445,18 +447,170 @@ module Guardrails
|
|
|
445
447
|
|
|
446
448
|
def print_text(violations)
|
|
447
449
|
if violations.empty?
|
|
448
|
-
@output.puts "
|
|
450
|
+
@output.puts ""
|
|
451
|
+
@output.puts "#{style.colorize("✓", :green)} Guardrails audit: no violations found."
|
|
449
452
|
return
|
|
450
453
|
end
|
|
451
454
|
|
|
452
|
-
|
|
453
|
-
|
|
455
|
+
# Group by type so each rule gets its own section with framing
|
|
456
|
+
# intro + tagged findings + inline suggestion arrows. The order
|
|
457
|
+
# mirrors what users care about: hex literals + inline styles +
|
|
458
|
+
# arbitrary Tailwind (real violations) before helper_recommended
|
|
459
|
+
# (a suggestion-shaped warning) and a11y (errors but grouped
|
|
460
|
+
# separately because A11yAudit owns them — the audit rake task
|
|
461
|
+
# threads them in via this same method).
|
|
462
|
+
type_order = %i[inline_style raw_color tailwind_arbitrary helper_recommended
|
|
463
|
+
image_alt button_name link_name input_label]
|
|
464
|
+
by_type = violations.group_by(&:type)
|
|
465
|
+
ordered = type_order + (by_type.keys - type_order)
|
|
466
|
+
|
|
467
|
+
ordered.each do |type|
|
|
468
|
+
list = by_type[type] || []
|
|
469
|
+
next if list.empty?
|
|
470
|
+
|
|
471
|
+
print_violation_type(type, list)
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
SEVERITY_FOR_TYPE = {
|
|
476
|
+
inline_style: :warning,
|
|
477
|
+
raw_color: :error,
|
|
478
|
+
tailwind_arbitrary: :error,
|
|
479
|
+
helper_recommended: :warning,
|
|
480
|
+
image_alt: :error,
|
|
481
|
+
button_name: :error,
|
|
482
|
+
link_name: :error,
|
|
483
|
+
input_label: :error
|
|
484
|
+
}.freeze
|
|
485
|
+
|
|
486
|
+
FRAMING_FOR_TYPE = {
|
|
487
|
+
inline_style: "Inline style attributes bypass your design tokens. Extract these to a CSS class or component stylesheet that references defined tokens.",
|
|
488
|
+
raw_color: "Hex/rgb literals in color attributes bypass your design tokens. Run APPLY=1 to auto-fix where a token matches; SUGGEST=1 writes a markdown checklist.",
|
|
489
|
+
tailwind_arbitrary: "Arbitrary Tailwind values (bg-[#fa3] etc.) bypass your theme. Add the value to theme.colors / theme.fontSize and use the named utility, or APPLY=1 to auto-fix where a token matches.",
|
|
490
|
+
helper_recommended: "Literal <button>/<a> wrapping ERB output hides intent from static analysis and a11y tooling. Switch to tag.button / link_to / button_to so attributes flow through one place.",
|
|
491
|
+
image_alt: "Images need accessible alt text. Use alt=\"\" for purely decorative images.",
|
|
492
|
+
button_name: "Buttons need an accessible name — text content, aria-label, or aria-labelledby.",
|
|
493
|
+
link_name: "Links need an accessible name — text content, aria-label, or aria-labelledby.",
|
|
494
|
+
input_label: "Interactive inputs need a programmatic label — aria-label, aria-labelledby, or a matching <label for=...>."
|
|
495
|
+
}.freeze
|
|
496
|
+
|
|
497
|
+
AUTO_FIXABLE_TYPES = %i[raw_color tailwind_arbitrary].freeze
|
|
498
|
+
|
|
499
|
+
def print_violation_type(type, violations)
|
|
500
|
+
severity = SEVERITY_FOR_TYPE.fetch(type, :warning)
|
|
501
|
+
auto_fix_marker = AUTO_FIXABLE_TYPES.include?(type) ? ", auto-fix available" : ""
|
|
502
|
+
|
|
503
|
+
@output.puts ""
|
|
504
|
+
@output.puts style.section_heading(
|
|
505
|
+
severity,
|
|
506
|
+
"#{type} (#{violations.length} #{violations.length == 1 ? "finding" : "findings"}#{auto_fix_marker})"
|
|
507
|
+
)
|
|
508
|
+
framing = FRAMING_FOR_TYPE[type]
|
|
509
|
+
wrap_framing(framing).each { |line| @output.puts " #{line}" } if framing
|
|
510
|
+
|
|
454
511
|
violations.each do |v|
|
|
455
|
-
@output.puts "
|
|
456
|
-
|
|
512
|
+
@output.puts ""
|
|
513
|
+
header = "#{type}: #{format_value(v)}"
|
|
514
|
+
@output.puts " #{style.severity(severity, header)}"
|
|
515
|
+
suggestion = suggestion_for_violation(v)
|
|
516
|
+
@output.puts " #{style.suggestion(suggestion)}" if suggestion
|
|
517
|
+
@output.puts " #{style.location("#{v.file}:#{v.line}:#{v.column}")}"
|
|
518
|
+
@output.puts " #{v.snippet}" if v.snippet
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def format_value(violation)
|
|
523
|
+
case violation.type
|
|
524
|
+
when :raw_color, :tailwind_arbitrary
|
|
525
|
+
violation.value
|
|
526
|
+
when :inline_style
|
|
527
|
+
violation.value || "<inline style>"
|
|
528
|
+
else
|
|
529
|
+
violation.snippet.to_s[0, 60]
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Hard-wrap the framing-intro paragraph at ~72 chars so it doesn't
|
|
534
|
+
# run off the side of an 80-col terminal. Cheap word-wrap; nothing
|
|
535
|
+
# fancy needed for a 1-2 sentence paragraph.
|
|
536
|
+
def wrap_framing(text, width: 72)
|
|
537
|
+
lines = []
|
|
538
|
+
current = +""
|
|
539
|
+
text.split(/\s+/).each do |word|
|
|
540
|
+
if current.empty?
|
|
541
|
+
current << word
|
|
542
|
+
elsif current.length + 1 + word.length <= width
|
|
543
|
+
current << " " << word
|
|
544
|
+
else
|
|
545
|
+
lines << current
|
|
546
|
+
current = +word
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
lines << current unless current.empty?
|
|
550
|
+
lines
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# Per-violation inline suggestion. For token-aware types
|
|
554
|
+
# (raw_color, tailwind_arbitrary), reuse load_tokens + TokenMatcher
|
|
555
|
+
# to surface exact matches inline. Anything more elaborate
|
|
556
|
+
# (near-match, replacement string, etc.) belongs in SUGGEST=1's
|
|
557
|
+
# markdown checklist.
|
|
558
|
+
def suggestion_for_violation(violation)
|
|
559
|
+
case violation.type
|
|
560
|
+
when :raw_color
|
|
561
|
+
matched_token_suggestion(violation, [:css_var])
|
|
562
|
+
when :tailwind_arbitrary
|
|
563
|
+
matched_token_suggestion(violation, [:tailwind]) ||
|
|
564
|
+
matched_token_suggestion(violation, [:css_var])
|
|
565
|
+
when :inline_style
|
|
566
|
+
"extract to a CSS class or component stylesheet"
|
|
567
|
+
when :helper_recommended
|
|
568
|
+
helper_recommended_suggestion(violation)
|
|
569
|
+
when :image_alt
|
|
570
|
+
"add an alt attribute (or alt=\"\" if decorative)"
|
|
571
|
+
when :button_name
|
|
572
|
+
"add text, aria-label, or aria-labelledby"
|
|
573
|
+
when :link_name
|
|
574
|
+
"add link text, aria-label, or aria-labelledby"
|
|
575
|
+
when :input_label
|
|
576
|
+
"add aria-label, aria-labelledby, or a matching <label for=...>"
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def matched_token_suggestion(violation, allowed_syntaxes)
|
|
581
|
+
require_relative "token_matcher"
|
|
582
|
+
tokens = load_tokens.select { |t| allowed_syntaxes.include?(t.syntax) }
|
|
583
|
+
return nil if tokens.empty?
|
|
584
|
+
|
|
585
|
+
match = TokenMatcher.new(tokens).match(violation.value)
|
|
586
|
+
return nil unless match && match.kind == :exact
|
|
587
|
+
|
|
588
|
+
token = match.token
|
|
589
|
+
"replace with #{token_reference(token)} (exact match from #{token.file})"
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def token_reference(token)
|
|
593
|
+
case token.syntax
|
|
594
|
+
when :css_var then "var(--#{token.name})"
|
|
595
|
+
when :scss_var then "$#{token.name}"
|
|
596
|
+
when :tailwind then token.name.to_s
|
|
597
|
+
else token.name.to_s
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def helper_recommended_suggestion(violation)
|
|
602
|
+
tag = violation.snippet.to_s[/<(\w+)/, 1]
|
|
603
|
+
case tag
|
|
604
|
+
when "button" then "use tag.button(label, ...) or button_to(label, path)"
|
|
605
|
+
when "a" then "use link_to(label, path, ...)"
|
|
606
|
+
else "use the Rails helper for this element"
|
|
457
607
|
end
|
|
458
608
|
end
|
|
459
609
|
|
|
610
|
+
def style
|
|
611
|
+
@style ||= Report::Style.new(io: @output)
|
|
612
|
+
end
|
|
613
|
+
|
|
460
614
|
def print_json(violations)
|
|
461
615
|
require "json"
|
|
462
616
|
payload = {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require_relative "erb_parser"
|
|
5
|
+
require_relative "report/style"
|
|
5
6
|
|
|
6
7
|
module Guardrails
|
|
7
8
|
# Finds repeating "class soup" — the same long class list applied to
|
|
@@ -52,12 +53,14 @@ module Guardrails
|
|
|
52
53
|
def initialize(root:, output: $stdout,
|
|
53
54
|
min_classes: DEFAULT_MIN_CLASSES,
|
|
54
55
|
min_occurrences: DEFAULT_MIN_OCCURRENCES,
|
|
55
|
-
max_occurrences_shown: DEFAULT_MAX_OCCURRENCES_SHOWN
|
|
56
|
+
max_occurrences_shown: DEFAULT_MAX_OCCURRENCES_SHOWN,
|
|
57
|
+
style: nil)
|
|
56
58
|
@root = Pathname(root)
|
|
57
59
|
@output = output
|
|
58
60
|
@min_classes = min_classes
|
|
59
61
|
@min_occurrences = min_occurrences
|
|
60
62
|
@max_occurrences_shown = max_occurrences_shown
|
|
63
|
+
@style = style || Report::Style.new(io: output)
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
def run
|
|
@@ -165,25 +168,49 @@ module Guardrails
|
|
|
165
168
|
|
|
166
169
|
total_occurrences = clusters.sum(&:count)
|
|
167
170
|
noun = clusters.length == 1 ? "cluster" : "clusters"
|
|
171
|
+
|
|
168
172
|
@output.puts ""
|
|
169
|
-
@output.puts
|
|
170
|
-
|
|
173
|
+
@output.puts @style.section_heading(
|
|
174
|
+
:suggestion,
|
|
175
|
+
"class-itis (#{clusters.length} #{noun}, #{total_occurrences} occurrences)"
|
|
176
|
+
)
|
|
177
|
+
@output.puts " The same multi-class list applied to the same tag in many places —"
|
|
178
|
+
@output.puts " classic AI-paste pattern. Consider extracting a shared component or"
|
|
179
|
+
@output.puts " an @apply rule. Threshold: >= #{@min_classes} classes, >= #{@min_occurrences} occurrences."
|
|
171
180
|
|
|
172
181
|
clusters.each do |cluster|
|
|
173
182
|
@output.puts ""
|
|
174
|
-
|
|
175
|
-
|
|
183
|
+
header = "<#{cluster.tag}> with #{cluster.class_count} classes, #{cluster.count} occurrences"
|
|
184
|
+
@output.puts " #{@style.severity(:suggestion, header)}"
|
|
185
|
+
@output.puts " #{@style.suggestion(suggestion_for(cluster))}"
|
|
176
186
|
@output.puts " class=#{format_classes(cluster.classes)}"
|
|
177
187
|
cluster.occurrences.first(@max_occurrences_shown).each do |occ|
|
|
178
|
-
@output.puts " #{occ.file}:#{occ.line}"
|
|
188
|
+
@output.puts " #{@style.location("#{occ.file}:#{occ.line}")}"
|
|
179
189
|
end
|
|
180
190
|
if cluster.occurrences.length > @max_occurrences_shown
|
|
181
191
|
remaining = cluster.occurrences.length - @max_occurrences_shown
|
|
182
|
-
@output.puts " … and #{remaining} more"
|
|
192
|
+
@output.puts " #{@style.location("… and #{remaining} more")}"
|
|
183
193
|
end
|
|
184
194
|
end
|
|
185
195
|
end
|
|
186
196
|
|
|
197
|
+
# Suggestion shape varies by cluster size + count. Big class lists
|
|
198
|
+
# repeated many places want a component; smaller lists repeated
|
|
199
|
+
# often might just be a shared CSS rule.
|
|
200
|
+
def suggestion_for(cluster)
|
|
201
|
+
if cluster.class_count >= 8
|
|
202
|
+
"extract a #{component_name(cluster)} component — too many classes to keep inlined"
|
|
203
|
+
elsif cluster.count >= 6
|
|
204
|
+
"repeated this often, an @apply rule or shared class would dry it up"
|
|
205
|
+
else
|
|
206
|
+
"consider a shared component or @apply rule for this class list"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def component_name(cluster)
|
|
211
|
+
"#{cluster.tag.capitalize}Component"
|
|
212
|
+
end
|
|
213
|
+
|
|
187
214
|
# Cap the displayed class string so a 30-utility soup doesn't blow
|
|
188
215
|
# out the terminal. Fingerprint matching is on the full sorted list,
|
|
189
216
|
# so display truncation is purely cosmetic.
|
|
@@ -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
|
data/lib/tasks/guardrails.rake
CHANGED
|
@@ -107,19 +107,106 @@ namespace :guardrails do
|
|
|
107
107
|
}
|
|
108
108
|
$stdout.puts JSON.pretty_generate(payload)
|
|
109
109
|
else
|
|
110
|
+
# Run each sub-audit against an in-memory sink first so we can
|
|
111
|
+
# render the top-of-report summary before the per-category
|
|
112
|
+
# details (the summary needs every detector's count). Then
|
|
113
|
+
# write the sink + per-category sections out together.
|
|
114
|
+
#
|
|
115
|
+
# Important: detectors write into `sink` (StringIO) but make
|
|
116
|
+
# ANSI-color decisions against `$stdout`. If we let each
|
|
117
|
+
# detector instantiate its own Style bound to the sink, every
|
|
118
|
+
# color() call would be false (StringIO isn't a TTY) and the
|
|
119
|
+
# body of the report would print plain even on a real terminal,
|
|
120
|
+
# while the top/bottom summary printed straight to $stdout
|
|
121
|
+
# would still be colored. Pre-build one Style here and thread
|
|
122
|
+
# it through every detector so the whole report tracks the
|
|
123
|
+
# real terminal's TTY/NO_COLOR signal consistently.
|
|
124
|
+
require "guardrails/report/summary"
|
|
125
|
+
require "guardrails/report/style"
|
|
126
|
+
sink = StringIO.new
|
|
127
|
+
report_style = Guardrails::Report::Style.new(io: $stdout)
|
|
110
128
|
violations = Guardrails::Audit.new(
|
|
111
|
-
root: root, suggest: suggest, apply: apply, format: :text
|
|
129
|
+
root: root, output: sink, suggest: suggest, apply: apply, format: :text,
|
|
130
|
+
style: report_style
|
|
112
131
|
).run
|
|
113
|
-
stimulus = Guardrails::StimulusAudit.new(root: root).run
|
|
132
|
+
stimulus = Guardrails::StimulusAudit.new(root: root, output: sink, style: report_style).run
|
|
133
|
+
similarity_opts[:output] = sink
|
|
134
|
+
similarity_opts[:style] = report_style
|
|
114
135
|
similarity = Guardrails::PartialSimilarity.new(**similarity_opts).run
|
|
115
|
-
vc = Guardrails::ViewComponentAudit.new(root: root).run
|
|
116
|
-
a11y = Guardrails::A11yAudit.new(root: root).run
|
|
136
|
+
vc = Guardrails::ViewComponentAudit.new(root: root, output: sink, style: report_style).run
|
|
137
|
+
a11y = Guardrails::A11yAudit.new(root: root, output: sink, style: report_style).run
|
|
138
|
+
pattern_opts[:output] = sink
|
|
139
|
+
pattern_opts[:style] = report_style
|
|
117
140
|
patterns = Guardrails::CrossCodebasePatterns.new(**pattern_opts).run
|
|
141
|
+
classitis_opts[:output] = sink
|
|
142
|
+
classitis_opts[:style] = report_style
|
|
118
143
|
classitis = Guardrails::ClassItis.new(**classitis_opts).run
|
|
119
|
-
a11y_deep_runner = axe_json_path ? Guardrails::A11yDeep.new(input: axe_json_path) : nil
|
|
144
|
+
a11y_deep_runner = axe_json_path ? Guardrails::A11yDeep.new(input: axe_json_path, output: sink, style: report_style) : nil
|
|
120
145
|
a11y_deep = a11y_deep_runner&.run || []
|
|
121
|
-
visual_diff_runner = visual_diff_on ? Guardrails::VisualDiff.new(root: root) : nil
|
|
146
|
+
visual_diff_runner = visual_diff_on ? Guardrails::VisualDiff.new(root: root, output: sink, style: report_style) : nil
|
|
122
147
|
visual_diff = visual_diff_runner&.run || []
|
|
148
|
+
|
|
149
|
+
summary_entries = [
|
|
150
|
+
Guardrails::Report::Summary::Entry.new(
|
|
151
|
+
category: "raw_color", count: violations.count { |v| v.type == :raw_color },
|
|
152
|
+
severity: :error, auto_fix: true
|
|
153
|
+
),
|
|
154
|
+
Guardrails::Report::Summary::Entry.new(
|
|
155
|
+
category: "tailwind_arbitrary", count: violations.count { |v| v.type == :tailwind_arbitrary },
|
|
156
|
+
severity: :error, auto_fix: true
|
|
157
|
+
),
|
|
158
|
+
Guardrails::Report::Summary::Entry.new(
|
|
159
|
+
category: "inline_style", count: violations.count { |v| v.type == :inline_style },
|
|
160
|
+
severity: :warning
|
|
161
|
+
),
|
|
162
|
+
Guardrails::Report::Summary::Entry.new(
|
|
163
|
+
category: "helper_recommended", count: violations.count { |v| v.type == :helper_recommended },
|
|
164
|
+
severity: :warning
|
|
165
|
+
),
|
|
166
|
+
Guardrails::Report::Summary::Entry.new(
|
|
167
|
+
category: "a11y (static)", count: a11y.length, severity: :error
|
|
168
|
+
),
|
|
169
|
+
Guardrails::Report::Summary::Entry.new(
|
|
170
|
+
category: "a11y (deep)", count: a11y_deep.length, severity: :error
|
|
171
|
+
),
|
|
172
|
+
Guardrails::Report::Summary::Entry.new(
|
|
173
|
+
category: "stimulus orphaned", count: stimulus.orphaned.length, severity: :warning
|
|
174
|
+
),
|
|
175
|
+
Guardrails::Report::Summary::Entry.new(
|
|
176
|
+
category: "stimulus dead", count: stimulus.dead.length, severity: :warning
|
|
177
|
+
),
|
|
178
|
+
Guardrails::Report::Summary::Entry.new(
|
|
179
|
+
category: "missing previews", count: vc.missing_previews.length, severity: :warning
|
|
180
|
+
),
|
|
181
|
+
Guardrails::Report::Summary::Entry.new(
|
|
182
|
+
category: "orphan slots", count: vc.orphan_slots.length, severity: :warning
|
|
183
|
+
),
|
|
184
|
+
Guardrails::Report::Summary::Entry.new(
|
|
185
|
+
category: "visual diff", count: visual_diff.length, severity: :error
|
|
186
|
+
),
|
|
187
|
+
Guardrails::Report::Summary::Entry.new(
|
|
188
|
+
category: "similar partials", count: similarity.length, severity: :suggestion,
|
|
189
|
+
unit: "pairs", action: "consider deduplicating"
|
|
190
|
+
),
|
|
191
|
+
Guardrails::Report::Summary::Entry.new(
|
|
192
|
+
category: "cross-codebase patterns", count: patterns.length, severity: :suggestion,
|
|
193
|
+
unit: "candidates", action: "consider extracting partials"
|
|
194
|
+
),
|
|
195
|
+
Guardrails::Report::Summary::Entry.new(
|
|
196
|
+
category: "class-itis", count: classitis.length, severity: :suggestion,
|
|
197
|
+
unit: "clusters", action: "consider extracting component / @apply"
|
|
198
|
+
)
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
# Render the summary twice — once at the top so the reader
|
|
202
|
+
# knows what to expect, once at the bottom as a recap so they
|
|
203
|
+
# don't have to scroll back up after the per-detector dump.
|
|
204
|
+
# On a long output (Patchvault has 981 findings) the bottom
|
|
205
|
+
# recap is the load-bearing one.
|
|
206
|
+
summary = Guardrails::Report::Summary.new(entries: summary_entries, output: $stdout, style: report_style)
|
|
207
|
+
summary.render
|
|
208
|
+
$stdout.write sink.string
|
|
209
|
+
summary.render(recap: true)
|
|
123
210
|
end
|
|
124
211
|
|
|
125
212
|
# Deep a11y findings only fail the audit when their impact crosses
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ui_guardrails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- John Athayde
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: railties
|
|
@@ -105,6 +105,8 @@ files:
|
|
|
105
105
|
- lib/guardrails/lookbook/views/lookbook_panels/_guardrails.html.erb
|
|
106
106
|
- lib/guardrails/partial_similarity.rb
|
|
107
107
|
- lib/guardrails/railtie.rb
|
|
108
|
+
- lib/guardrails/report/style.rb
|
|
109
|
+
- lib/guardrails/report/summary.rb
|
|
108
110
|
- lib/guardrails/stimulus_audit.rb
|
|
109
111
|
- lib/guardrails/token_matcher.rb
|
|
110
112
|
- lib/guardrails/tokens.rb
|