ui_guardrails 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 200bad03eb94e193c93e5965c52743f8bc9c4d232003149d482cf8b4bc72d77c
4
- data.tar.gz: cd365fa2244f58b1c294f317119be08e03da868f050cc1aae268d6ff524237a0
3
+ metadata.gz: 9f34f9ff613f4b10edd128972a3ee8b268e82fee79abc499d7554d48bf834eec
4
+ data.tar.gz: 937199143fb681851d6493dc666ce9a381d4db83b247059e00e9b0dd91b8a901
5
5
  SHA512:
6
- metadata.gz: 1c23e135b24dcbf76c3d98983ee339a22800fdf4a40fbc4b13dcbebaecfbf8b7160a5aa4472f01440fb20bac8bf6585ada295207189aae11f8809f894ef70003
7
- data.tar.gz: 56572b2788586158520982bf9d3d0f18e4920136aab255dec5ef88b08d7c7ef0347ba653e71564f0b439c2c94db53c7eee30a241f9a5c516fc72722900769074
6
+ metadata.gz: 8e6d87b9e1bac27c0f79f941343855806c0fb3a3c55b0191cfeb1e184a0b4b5cc6b09eb88ce4df2512ac646c1572f528da269dedacc56fd269f8a4b3ac7630eb
7
+ data.tar.gz: d50217306f64e883988ad9184adde4d02e274fc8262560aeab3b8a0c9eecd573bae847006c0a3383222020ab3d9281ba96d1720ea9019f17c3491bf21fcf08e1
data/README.md CHANGED
@@ -1,10 +1,10 @@
1
- # Guardrails
1
+ # UI-Guardrails
2
2
 
3
3
  A Rails toolset that prevents UI drift in AI-assisted applications. Static audits over your views, components, stylesheets, JS controllers, and tokens — surfacing the kinds of inconsistencies that compound silently as an AI assistant ships code faster than design-system discipline can keep up.
4
4
 
5
5
  Built and maintained by [Meticulous](https://meticulous.com).
6
6
 
7
- **Current release:** 1.0.0 — V0 + V1 + V2 complete, published on [RubyGems.org as `ui_guardrails`](https://rubygems.org/gems/ui_guardrails). The Ruby module stays `Guardrails` (so `require "guardrails"` is unchanged) — only the gem package name on rubygems carries the `ui_` prefix, to clear RubyGems' similarity rule against the unrelated [`guard-rails`](https://rubygems.org/gems/guard-rails) gem. See [`doc/ROADMAP.md`](doc/ROADMAP.md) for status, [`CHANGELOG.md`](CHANGELOG.md) for the full naming rationale.
7
+ **Current release:** 1.2.0 — V0 + V1 + V2 complete, published on [RubyGems.org as `ui_guardrails`](https://rubygems.org/gems/ui_guardrails). The Ruby module stays `Guardrails` (so `require "guardrails"` is unchanged) — only the gem package name on rubygems carries the `ui_` prefix, to clear RubyGems' similarity rule against the unrelated [`guard-rails`](https://rubygems.org/gems/guard-rails) gem. See [`doc/ROADMAP.md`](doc/ROADMAP.md) for status, [`CHANGELOG.md`](CHANGELOG.md) for the full naming rationale.
8
8
 
9
9
  ---
10
10
 
@@ -0,0 +1,226 @@
1
+ # Visual-diff integration — research memo
2
+
3
+ > Status: research, not implementation. Closes the V2 visual-diff item's "evaluate a stable visual-regression target" prerequisite before any code lands.
4
+ >
5
+ > Last updated: 2026-05-10
6
+
7
+ ## Executive summary
8
+
9
+ **Recommendation: ship a `Guardrails::VisualDiff` parser that consumes screenshot-diff tool output, the same way `Guardrails::A11yDeep` consumes axe-core JSON.** Don't bundle Chromium / Playwright / Capybara as runtime deps — that was the constraint that originally parked this item, and it hasn't changed.
10
+
11
+ The cleanest external producers to support, in priority order:
12
+
13
+ 1. **`snap_diff-capybara`** (formerly `capybara-screenshot-diff`) — the Rails-native default. Active gem (1.12.0 released April 2026), commits baselines to git, no cloud dep. Highest fit-for-purpose.
14
+ 2. **BackstopJS** — fully OSS, clean JSON output. Worth a second adapter for non-Rails-monolith projects (the Avo dogfood pattern).
15
+ 3. **Percy / Chromatic** — SaaS, vendor-locked. Defer until a user actually asks; webhook ingestion is the integration shape if they do.
16
+
17
+ The talk narrative writes itself: **static analysis catches the source, visual diff catches what the rendered DOM does next.** Pair this with `ClassItis` — AI-generated 8-class soup looks plausible in the source but pixel-shifts the baseline once a single utility is wrong.
18
+
19
+ ---
20
+
21
+ ## The constraint that shaped A11yDeep applies here too
22
+
23
+ When deferring deep a11y in the original roadmap, the call was:
24
+
25
+ > axe-core wrapper with `--deep` mode NOT shipped — would require Capybara + headless Chrome runtime deps.
26
+
27
+ Then in 0.6.0 we shipped `A11yDeep` as a **parser** for axe JSON output: zero new runtime deps, user runs axe however they already do, Guardrails provides the merge + report + exit-code contract. The same calculus applies to visual diff. Bundling Playwright/Chromium would balloon `bundle install` for users who don't run system tests; bundling Selenium would tie us to Ruby browser-driver decay. The right move is to consume what existing tools emit.
28
+
29
+ **Acceptance criteria for any candidate tool**, in priority order:
30
+
31
+ 1. **Emits a machine-readable diff summary** — JSON, JUnit XML, or a parseable HTML/filesystem layout. We need per-scenario: name, baseline path, current path, diff path (if any), mismatch % or pass/fail, optionally the URL/component selector.
32
+ 2. **Runs without Guardrails being involved.** The user's existing test toolchain runs the tool; we ingest the output. No shelling out to `npx`, no requiring the tool be present at audit time.
33
+ 3. **OSS-friendly licensing** *or* a stable public API for SaaS tools.
34
+ 4. **Active maintenance.** Sub-12-month release cadence and >50 GitHub stars as a rough proxy.
35
+
36
+ ---
37
+
38
+ ## Per-tool brief
39
+
40
+ ### snap_diff-capybara (Rails-native) — **PRIMARY RECOMMENDATION**
41
+
42
+ - **What it is:** Ruby gem (formerly `capybara-screenshot-diff`, now maintained at `github.com/snap-diff/snap_diff-capybara` by `donv` and `jetthoughts`). Latest 1.12.0, April 2026. MIT-licensed. ~770k total downloads.
43
+ - **How it works:** `screenshot "name"` inside Capybara system tests. Baselines live at `doc/screenshots/` and are **committed to git** — UI changes get reviewed in PR like code. No external auth, no cloud.
44
+ - **Diff artifacts:**
45
+ - `*.png` baseline
46
+ - `*.diff.png` (changed pixels highlighted red)
47
+ - `*.heatmap.diff.png` (pixel-variance density)
48
+ - **`snap_diff_report.html`** — interactive multi-view dashboard
49
+ - **CI flow:** Tests fail on baseline mismatch. There's a reusable GitHub Actions workflow that uploads the HTML report and posts PR comments. Ships its own baseline-update flow.
50
+ - **Runtime:** Ruby 3.2+, any Capybara-compatible driver (Selenium / Playwright / Cuprite). libvips 8.9+ for fast comparisons, falls back to ChunkyPNG.
51
+ - **For Guardrails to consume:** the HTML report has a fixed structure, but the gem doesn't (yet) emit a top-level JSON summary. **Either:**
52
+ - **(a)** ask upstream for a `snap_diff_report.json` companion to the HTML — small ask, this is the maintained gem
53
+ - **(b)** walk `doc/screenshots/` ourselves: pair `name.png` with `name.diff.png`, treat presence of the diff file as a failing finding
54
+ - **Fit:** ★★★★★. Same ecosystem (Ruby/Rails/Capybara), no new deps for users already running system tests, baselines-in-git matches the Guardrails ethos of "the source is the source of truth."
55
+
56
+ ### BackstopJS
57
+
58
+ - **What it is:** Node CLI, MIT-licensed, actively maintained, the dominant OSS visual-regression tool outside the Storybook ecosystem.
59
+ - **How it works:** `backstop init`, `backstop test`, `backstop approve`. Configured via `backstop.json` (scenarios = URL × viewport × selector). Headless Chrome via Puppeteer or Firefox/WebKit via Playwright. All artifacts local under `backstop_data/`.
60
+ - **Output:** HTML interactive report, **JSON report**, JUnit XML, CLI text. Exit 0/1.
61
+ - **For Guardrails to consume:** The JSON report already exists and is the canonical machine-readable output. Per-scenario: name, label, mismatch percentage, pass/fail, paths to reference + test + diff images.
62
+ - **Fit:** ★★★★☆. Native machine-readable output, no SaaS dep, but Node-only — Ruby/Rails shops would have a parallel toolchain. Justifiable for the "Rails frontend that already runs JS tooling" subset (any non-trivial Rails app).
63
+
64
+ ### Playwright `toHaveScreenshot` / `toMatchSnapshot`
65
+
66
+ - **What it is:** Built-in Playwright assertion. The team that maintains Playwright also maintains Chromatic (Microsoft → Storybook acquisition), so it's becoming the de facto cross-tool snapshot mechanism.
67
+ - **How it works:** `await expect(page).toHaveScreenshot()`. Stores baselines in `__screenshots__/`. Updates via `--update-snapshots`.
68
+ - **Output:** JUnit / JSON via Playwright's reporter API; HTML report; per-test pass/fail in CI.
69
+ - **For Guardrails to consume:** Playwright JSON reporter emits a structured test-result document; each failure includes `attachments` with the diff image path. Parseable, but the schema is "Playwright test results" not "visual diff" — we'd be extracting the visual subset.
70
+ - **Fit:** ★★★☆☆. Future-proof and increasingly canonical, but the integration is heavier (have to filter Playwright results for screenshot-specific failures). Worth supporting in v2 of the adapter.
71
+
72
+ ### Percy (BrowserStack)
73
+
74
+ - **What it is:** SaaS, all builds go through Percy's cloud. Originally a standalone product, BrowserStack-acquired in 2020.
75
+ - **Ruby story:** `percy-capybara` gem exists. Latest release v5.0.0 — July 2021 — i.e. **maintenance-stale**. 4+ years without a meaningful release; the active surface is the JS SDKs.
76
+ - **Output:** Dashboard URL + per-build webhook. No local artifacts beyond the snapshot upload.
77
+ - **Pricing:** Pricing page is gated behind BrowserStack's product navigation; no clear OSS-free tier surfaced via the public site. Assume paid-only for non-trial use.
78
+ - **For Guardrails to consume:** `build-finished` webhook payload is the integration shape — but that requires a receiver, which Guardrails-the-gem isn't. Could ship a parser for the webhook JSON that users POST to their CI.
79
+ - **Fit:** ★★☆☆☆. Stale Ruby SDK, vendor-locked, no clear OSS pricing. Defer until a user asks specifically.
80
+
81
+ ### Chromatic
82
+
83
+ - **What it is:** SaaS, made by the Storybook team. The standard visual-regression for Storybook-based component libraries; expanded to Playwright + Cypress in 2024–25.
84
+ - **Ruby story:** None. JS-ecosystem only. No Lookbook integration despite Lookbook being the Rails-shaped Storybook equivalent.
85
+ - **Output:** Cloud dashboard, web API for build status, no local artifacts.
86
+ - **Pricing:** Free tier (5,000 snapshots/month, Chrome-only) — generous. Paid starts at $179/mo. "Building in the open?" OSS plan available on application.
87
+ - **Fit:** ★★☆☆☆ for Rails apps without Storybook. ★★★★☆ for ViewComponent shops who use Storybook for non-Rails components alongside. Pair-of-tools story is workable but indirect; Lookbook integration would be the missing link, and that's a Lookbook upstream ask, not a Guardrails one.
88
+
89
+ ### Honorable mentions
90
+
91
+ - **Loki** — Storybook-only, narrow audience for Rails-flavored Guardrails. Skip.
92
+ - **`reg-suit`** — Cloud or self-hosted, language-agnostic, JSON report. Reasonable BackstopJS-alternative; not common enough in Rails shops to justify a dedicated adapter at v1.
93
+
94
+ ---
95
+
96
+ ## Fit table
97
+
98
+ | Tool | OSS | Rails-native | Machine-readable output | Cloud dep | Active | Fit score |
99
+ |---|---|---|---|---|---|---|
100
+ | snap_diff-capybara | ✅ MIT | ✅ Ruby gem | 🟡 HTML report (no JSON yet) | ❌ baselines in git | ✅ active | ★★★★★ |
101
+ | BackstopJS | ✅ MIT | ❌ Node | ✅ JSON report | ❌ local FS | ✅ active | ★★★★☆ |
102
+ | Playwright snapshots | ✅ Apache | ❌ Node | ✅ JSON via reporter | ❌ local FS | ✅ active | ★★★☆☆ |
103
+ | Percy | 🔒 SaaS | 🟡 stale gem (2021) | ✅ webhook | ✅ required | 🟡 webhooks active, SDK stale | ★★☆☆☆ |
104
+ | Chromatic | 🔒 SaaS | ❌ JS-only | ✅ API | ✅ required | ✅ active | ★★☆☆☆ |
105
+
106
+ ---
107
+
108
+ ## Proposed integration shape (mirrors A11yDeep)
109
+
110
+ ```
111
+ lib/guardrails/visual_diff.rb — parser + report
112
+ lib/guardrails/visual_diff/snap_diff.rb — adapter for snap_diff-capybara
113
+ lib/guardrails/visual_diff/backstop.rb — adapter for BackstopJS JSON
114
+ spec/guardrails/visual_diff_spec.rb
115
+ spec/guardrails/visual_diff/snap_diff_spec.rb
116
+ spec/guardrails/visual_diff/backstop_spec.rb
117
+ ```
118
+
119
+ Normalized `Finding` shape (across both adapters):
120
+
121
+ ```ruby
122
+ Finding = Struct.new(
123
+ :scenario, # "homepage", "checkout_cart"
124
+ :viewport, # "desktop"; nil if not applicable
125
+ :mismatch_ratio, # Float 0.0..1.0 (or nil for snap_diff which is pass/fail)
126
+ :baseline_path, # relative path on disk
127
+ :current_path, # relative path on disk
128
+ :diff_path, # relative path to .diff.png, nil if pass
129
+ :url, # optional, BackstopJS scenarios have URLs
130
+ :selector, # optional
131
+ keyword_init: true
132
+ )
133
+ ```
134
+
135
+ Rake task (mirrors `guardrails:a11y:deep`):
136
+
137
+ ```bash
138
+ # Either auto-detect from `doc/screenshots/` (snap_diff convention) ...
139
+ bundle exec rake guardrails:visual:deep
140
+
141
+ # ... or point at an explicit producer:
142
+ SNAP_DIFF_DIR=doc/screenshots bundle exec rake guardrails:visual:deep
143
+ BACKSTOP_JSON=backstop_data/html_report/jsonReport.json bundle exec rake guardrails:visual:deep
144
+ ```
145
+
146
+ Folded into `guardrails:audit` via `VISUAL_DIFF=...` env var, same shape as `AXE_JSON`.
147
+
148
+ JSON output extends the existing audit payload:
149
+
150
+ ```json
151
+ {
152
+ "summary": { "...": "...", "visual_diff": 3 },
153
+ "visual_diff": [
154
+ { "scenario": "checkout/cart", "mismatch_ratio": 0.082,
155
+ "baseline_path": "doc/screenshots/checkout_cart.png",
156
+ "diff_path": "doc/screenshots/checkout_cart.diff.png" }
157
+ ]
158
+ }
159
+ ```
160
+
161
+ Exit-code contract: a finding's `mismatch_ratio > threshold` (configurable, default `0.0` — any diff fails) bumps the audit to exit 1. For snap_diff (pass/fail), any failure is a fail.
162
+
163
+ ---
164
+
165
+ ## What this buys the gem (and the talk)
166
+
167
+ 1. **Closes V2.** Roadmap is fully shipped on the three core items.
168
+ 2. **Talk narrative is now four layers deep:**
169
+ - *Static AST drift* (V0): the source-level mistakes.
170
+ - *Structural drift* (V2 cross-codebase + class-itis): the patterns the source shouldn't have.
171
+ - *Runtime a11y drift* (V1.6 deep a11y): what the static checks can't see.
172
+ - *Visual drift* (V2 visual-diff): what the rendered DOM does next.
173
+
174
+ AI assistants fail at each layer differently. Each Guardrails detector maps to a specific failure mode.
175
+
176
+ 3. **Same install/run story as everything else.** No new install footprint. Users keep their preferred screenshot tool; Guardrails provides the unified report.
177
+
178
+ ---
179
+
180
+ ## Open decisions for the morning
181
+
182
+ 1. **Primary adapter: snap_diff or BackstopJS?**
183
+ - My read: snap_diff-capybara, since (a) Rails-native, (b) we'd be working with the maintainers of a small focused gem who'd likely accept a JSON-output PR upstream, and (c) baselines-in-git matches Guardrails' "source is canonical" ethos.
184
+ - The risk: snap_diff doesn't currently emit JSON, so v0.8.0 might need an upstream contribution. We could ship the filesystem-walking adapter first (look at `doc/screenshots/*.diff.png`) and contribute the JSON emitter separately.
185
+
186
+ 2. **Scope: ship one adapter or two?**
187
+ - One (snap_diff) keeps the PR small and proves the pattern.
188
+ - Two (snap_diff + BackstopJS) covers the Avo / non-Rails-monolith case from day one.
189
+ - I'd ship one and tag, then add BackstopJS in a follow-up if there's signal.
190
+
191
+ 3. **Threshold default: `0.0` (any diff fails) or something more lenient?**
192
+ - A11yDeep defaulted to "any non-nil impact fails". The visual-diff equivalent is "any pixel mismatch fails" — strict, but visual diff is opt-in (you set up baselines deliberately), so strict is the right default.
193
+ - Per-call override via `VISUAL_DIFF_THRESHOLD=0.01` env or `visual_diff.threshold` in `guardrails.yml`.
194
+
195
+ 4. **Where does this surface in the Lookbook panel?**
196
+ - `ComponentReport#for` could add `visual_diff: [...]` showing per-component baseline mismatches. Pulls together: "this component drifted statically (raw_color) AND visually (3% mismatch on the desktop viewport)."
197
+ - Probably out of scope for v0.8.0 — first land the audit/CI integration, layer Lookbook in v0.9.0.
198
+
199
+ 5. **Is this 0.8.0 or 1.0.0?**
200
+ - Argument for 0.8.0: matches the pattern of every other increment.
201
+ - Argument for 1.0.0: with this shipped, all three V0–V2 column-headers are done. We could draw the "ready for serious use" line here and tag a 1.0.0.
202
+ - My read: 0.8.0 for the visual-diff feature, then a separate 1.0.0 release after dogfooding the full V2 surface against Patchvault/Talos/Forem/Avo. The 0.2.x→0.7.x cadence already moved through dogfood-driven patches; another round before 1.0.0 fits.
203
+
204
+ ---
205
+
206
+ ## Out of scope
207
+
208
+ - **Running the screenshot tool ourselves.** Not now, possibly not ever — the gem's ethos is parse-only.
209
+ - **Hosted baseline storage.** Baselines stay in the user's repo (snap_diff convention) or wherever their existing tool puts them.
210
+ - **Image-diff algorithm work.** Pixel comparison is a solved problem with multiple good libraries; we don't add value by reinventing it.
211
+ - **Real-browser rendering.** Same as the headless Chrome reasoning in A11yDeep.
212
+
213
+ ---
214
+
215
+ ## What's needed to start
216
+
217
+ If we go with snap_diff-capybara as the primary adapter:
218
+
219
+ 1. Decide if we file an upstream PR to add `snap_diff_report.json` emission, or build the filesystem-walking adapter first.
220
+ 2. Add `snap_diff-capybara` to `examples/demo/Gemfile` and seed a couple of system tests so the integration spec has something to verify against.
221
+ 3. Roughly 3 slices, mirroring the A11yDeep PR:
222
+ - Slice 1: `VisualDiff` parser + `Finding` struct + snap_diff adapter + spec
223
+ - Slice 2: rake task wiring (standalone + `guardrails:audit` fold-in) + JSON output extension
224
+ - Slice 3: docs (new `doc/VISUAL-DIFF.md` mirroring `doc/A11Y.md`) + ROADMAP + CHANGELOG → 0.8.0
225
+
226
+ Estimated scope: a single PR, comparable to 0.6.0's `A11yDeep` (~17 specs, ~400 LOC). Could be shipped same day as kick-off if no upstream blocker.
@@ -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.