Skip to content

Commit 1ca3a06

Browse files
authored
Merge pull request #20 from meticulous/claude/1.1.0-rich-audit-output
1.1.0: inline suggestions + ASCII styling across all detectors
2 parents 613250f + 636b693 commit 1ca3a06

23 files changed

Lines changed: 1119 additions & 87 deletions

CHANGELOG.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66

77
## [Unreleased]
88

9+
## [1.1.0] - 2026-05-11
10+
11+
Report-UX overhaul. The pre-1.1.0 audit output told you *what* it found but not *what to do about it* — suggestions only fired under `SUGGEST=1` (markdown file), and the inline text dump was a wall of categorical findings that took insider knowledge to triage. 1.1.0 inverts that: every finding now carries its own inline action; a top-of-report summary surfaces the shape of the work before you read details.
12+
13+
No detector logic changes. Same 461 examples preserved, plus 35 new ones for the formatting work (496 total).
14+
15+
### Added
16+
17+
- **Top-of-report triage summary.** The audit now prints a severity-grouped rollup before any per-detector detail: errors first, then warnings, then suggestions; counts per category; auto-fix availability flagged inline; action hints for suggestions. Tells you in one glance what to triage vs. what's a refactor backlog item.
18+
19+
- **Per-finding inline suggestions on every detector.** Every line in every detector's output now carries a `` arrow with the action it implies. The `cross-codebase pattern: table(thead(...),tbody)` you previously had to interpret now reads with a literal "consider extracting into a shared partial" right next to it. `raw_color #0066ff` and `tailwind_arbitrary bg-[#fa3]` additionally surface the matched token inline when one exists in your `colors_file` — no need for `SUGGEST=1` to know what to replace each with.
20+
21+
- **Framing intros per detector section.** Each category prints a 2-3 line paragraph before its findings that names what the rule catches and what action it implies. New users no longer need to learn the detector taxonomy by reverse-engineering the output.
22+
23+
- **Severity tagging.** Every finding line is prefixed with `[error]` / `[warning]` / `[suggest]`. Easy to grep, easy to skim, easy to filter visually.
24+
25+
- **ASCII styling with TTY auto-detection.** Output is colored when piped to a real terminal; plain text when piped to a file or `tee`. Respects `NO_COLOR=1` per the [no-color.org convention](https://no-color.org/). Box-drawing characters in the summary header degrade to ASCII (`+ - |`) when colors are off.
26+
27+
- **`Guardrails::Report::Style`** module (~120 lines) — colorize, severity tagging, location dimming, box-drawing. Self-contained and unit-tested (20 specs).
28+
29+
- **`Guardrails::Report::Summary`** class (~100 lines) — builds the top-of-report rollup from an `Entry` list contributed by each detector. Tested independently (13 specs) so new detectors only have to register an Entry to surface in the summary.
30+
31+
### Severity assignments
32+
33+
Each detector type carries an implicit severity that drives both summary grouping and per-line tagging:
34+
35+
| Severity | Detectors |
36+
|---|---|
37+
| `error` | `raw_color`, `tailwind_arbitrary`, all 4 static `a11y` rules (`image_alt`, `button_name`, `link_name`, `input_label`), `visual_diff` (any mismatch above threshold) |
38+
| `warning` | `inline_style`, `helper_recommended`, `stimulus orphaned`, `stimulus dead`, `missing previews`, `orphan slots` |
39+
| `suggestion` | `similar partials`, `cross-codebase patterns`, `class-itis` |
40+
41+
**`a11y_deep` is a special case:** its section heading is fixed (rendered as a single banner), but each finding is tagged at a severity derived from axe-core's `impact` field — `critical` and `serious` become `error`, `moderate` becomes `warning`, `minor` becomes `suggestion`. So a single `a11y_deep` section can contain a mix of severity tags. The summary entry for the section uses the strictest impact present (default: `:error` if any are present) so the rollup over-counts toward urgency rather than under-counts.
42+
43+
This isn't yet exposed as a configurable filter (e.g. `SEVERITY=error` to mute suggestions) — see follow-ups below.
44+
45+
### Unchanged
46+
47+
- **JSON output (`FORMAT=json`)** has no ANSI codes and no styling; the JSON payload's shape is unchanged from 1.0.0.
48+
- **`SUGGEST=1` markdown writer** still produces `doc/guardrails-suggestions-{TIMESTAMP}.md` for users who want a per-finding markdown checklist they can paste into a PR description or issue.
49+
- **Exit codes** unchanged: any error or warning bumps to exit 1; suggestions don't.
50+
51+
### Known follow-ups (not in 1.1.0)
52+
53+
- **`SEVERITY=error`** env knob to mute warning/suggestion sections + their contribution to the exit code, for CI gates that only want to fail on errors.
54+
- **File-grouped view** as an alternative to category-grouped — sometimes more actionable when you're working through one file at a time.
55+
- **Per-finding markdown export** alongside the existing checklist, so suggestions can sync into a GitHub issue or PR comment per category.
56+
- **Lookbook panel finding density** — the auto-registered panel could surface findings in the new format too, currently still uses the older.
57+
58+
[1.1.0]: https://github.com/meticulous/guardrails/releases/tag/v1.1.0
59+
960
## [1.0.0] - 2026-05-11
1061

1162
First release published to [RubyGems.org](https://rubygems.org/gems/ui_guardrails). The `1.0` jump from `0.8.0` recognizes that:

lib/guardrails/a11y_audit.rb

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require "pathname"
44
require "set"
55
require_relative "erb_parser"
6+
require_relative "report/style"
67

78
module Guardrails
89
# Static a11y checks that don't require a browser — element-level rules
@@ -23,9 +24,17 @@ def to_h
2324

2425
NON_INTERACTIVE_INPUT_TYPES = %w[hidden submit button reset image].freeze
2526

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)
2735
@root = Pathname(root)
2836
@output = output
37+
@style = style || Report::Style.new(io: output)
2938
end
3039

3140
def run
@@ -237,12 +246,19 @@ def build_finding(rule, node, file, lines)
237246
def print_report(findings)
238247
return if findings.empty?
239248

240-
@output.puts ""
241249
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+
243256
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}")}"
246262
end
247263
end
248264
end

lib/guardrails/a11y_deep.rb

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require "json"
44
require "pathname"
55
require "set"
6+
require_relative "report/style"
67

78
module Guardrails
89
# Consumes axe-core JSON output and folds the findings into Guardrails'
@@ -37,10 +38,11 @@ def to_h
3738
# `failing_impacts:` if your rule pack emits custom severities.
3839
DEFAULT_FAILING_IMPACTS = %w[minor moderate serious critical].freeze
3940

40-
def initialize(input:, output: $stdout, failing_impacts: DEFAULT_FAILING_IMPACTS)
41+
def initialize(input:, output: $stdout, failing_impacts: DEFAULT_FAILING_IMPACTS, style: nil)
4142
@input = input
4243
@output = output
4344
@failing_impacts = Set.new(failing_impacts.map(&:to_s))
45+
@style = style || Report::Style.new(io: output)
4446
end
4547

4648
def run
@@ -101,19 +103,38 @@ def print_report(findings)
101103
return if findings.empty?
102104

103105
grouped = findings.group_by(&:url)
106+
noun = findings.length == 1 ? "finding" : "findings"
107+
104108
@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."
106115

107116
grouped.each do |url, page_findings|
108117
@output.puts ""
109-
@output.puts " #{url || '(no url)'}"
118+
@output.puts " #{@style.location(url || '(no url)')}"
110119
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
115125
end
116126
end
117127
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
118139
end
119140
end

lib/guardrails/audit.rb

Lines changed: 160 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require "stringio"
66
require "yaml"
77
require_relative "erb_parser"
8+
require_relative "report/style"
89

910
module Guardrails
1011
class Audit
@@ -69,13 +70,14 @@ class Audit
6970
flood-color lighting-color stop-color
7071
].freeze
7172

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)
7374
@root = Pathname(root)
7475
@output = output
7576
@suggest = suggest
7677
@format = format
7778
@apply = apply
7879
@config = load_audit_config
80+
@style = style
7981
end
8082

8183
def run
@@ -445,18 +447,170 @@ def print_report(violations)
445447

446448
def print_text(violations)
447449
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."
449452
return
450453
end
451454

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+
454511
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"
457607
end
458608
end
459609

610+
def style
611+
@style ||= Report::Style.new(io: @output)
612+
end
613+
460614
def print_json(violations)
461615
require "json"
462616
payload = {

0 commit comments

Comments
 (0)