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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 200bad03eb94e193c93e5965c52743f8bc9c4d232003149d482cf8b4bc72d77c
4
- data.tar.gz: cd365fa2244f58b1c294f317119be08e03da868f050cc1aae268d6ff524237a0
3
+ metadata.gz: 792f503cf353ceaa71b9e45e39a4c3f9ae9255b40f4ecf361a207048074857e8
4
+ data.tar.gz: ff563d0927467149ddc42011500457c7c637db11fc9770566db64e3a0a4427e2
5
5
  SHA512:
6
- metadata.gz: 1c23e135b24dcbf76c3d98983ee339a22800fdf4a40fbc4b13dcbebaecfbf8b7160a5aa4472f01440fb20bac8bf6585ada295207189aae11f8809f894ef70003
7
- data.tar.gz: 56572b2788586158520982bf9d3d0f18e4920136aab255dec5ef88b08d7c7ef0347ba653e71564f0b439c2c94db53c7eee30a241f9a5c516fc72722900769074
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
- def initialize(root:, output: $stdout)
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 "Guardrails a11y: #{findings.length} static #{noun} found"
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 " [#{f.rule}] #{f.file}:#{f.line}:#{f.column}"
245
- @output.puts " #{f.snippet}"
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
@@ -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 "Guardrails a11y (deep): #{findings.length} finding#{'s' if findings.length != 1} from axe-core"
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
- impact_label = f.impact ? "[#{f.impact}]" : "[unknown]"
112
- selector = f.selector ? " (#{f.selector})" : ""
113
- @output.puts " #{impact_label} #{f.rule} #{f.description}#{selector}"
114
- @output.puts " #{f.help_url}" if f.help_url
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
@@ -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 "Guardrails audit: no violations found."
450
+ @output.puts ""
451
+ @output.puts "#{style.colorize("✓", :green)} Guardrails audit: no violations found."
449
452
  return
450
453
  end
451
454
 
452
- noun = violations.length == 1 ? "violation" : "violations"
453
- @output.puts "Guardrails audit: #{violations.length} #{noun} found"
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 " [#{v.type}] #{v.file}:#{v.line}:#{v.column}"
456
- @output.puts " #{v.snippet}"
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 "Guardrails class-itis: #{clusters.length} repeating class #{noun} " \
170
- "(#{total_occurrences} occurrences; >= #{@min_classes} classes, >= #{@min_occurrences} occurrences)"
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
- @output.puts " <#{cluster.tag}> with #{cluster.class_count} classes, " \
175
- "#{cluster.count} occurrences:"
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 ? "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.1.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
@@ -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.0.0
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 00:00:00.000000000 Z
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