ui_guardrails 1.7.1 → 2.0.0.rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +253 -45
- data/doc/A11Y.md +4 -4
- data/doc/PRD.md +7 -7
- data/doc/PUBLISHING.md +4 -0
- data/doc/ROADMAP.md +30 -10
- data/doc/VISUAL-DIFF.md +8 -8
- data/exe/guardrails-hook +1 -1
- data/exe/ui_guardrails-mcp +32 -0
- data/lib/guardrails/a11y_deep.rb +1 -1
- data/lib/guardrails/audit/markdown_writer.rb +5 -0
- data/lib/guardrails/audit.rb +129 -6
- data/lib/guardrails/class_itis.rb +2 -45
- data/lib/guardrails/erb_parser.rb +53 -0
- data/lib/guardrails/feed_ramp/agent_rules.rb +309 -0
- data/lib/guardrails/feed_ramp/components_markdown.rb +135 -0
- data/lib/guardrails/feed_ramp/config.rb +113 -0
- data/lib/guardrails/feed_ramp/design_tokens.rb +112 -0
- data/lib/guardrails/feed_ramp/discovery.rb +201 -0
- data/lib/guardrails/feed_ramp/libraries.rb +27 -0
- data/lib/guardrails/feed_ramp/manifest.rb +142 -0
- data/lib/guardrails/feed_ramp/mcp.rb +325 -0
- data/lib/guardrails/feed_ramp/mcp_install.rb +150 -0
- data/lib/guardrails/feed_ramp/modern_web.rb +83 -0
- data/lib/guardrails/feed_ramp/rails_config.rb +144 -0
- data/lib/guardrails/feed_ramp/root_element.rb +361 -0
- data/lib/guardrails/feed_ramp/ruby_source.rb +230 -0
- data/lib/guardrails/feed_ramp/score.rb +256 -0
- data/lib/guardrails/feed_ramp/snippet_check.rb +221 -0
- data/lib/guardrails/feed_ramp/usages.rb +174 -0
- data/lib/guardrails/feed_ramp.rb +27 -0
- data/lib/guardrails/hooks/installer.rb +1 -1
- data/lib/guardrails/hooks.rb +24 -1
- data/lib/guardrails/init/config_writer.rb +21 -4
- data/lib/guardrails/init/prompter.rb +1 -1
- data/lib/guardrails/railtie.rb +2 -0
- data/lib/guardrails/report/detectors.rb +1 -1
- data/lib/guardrails/report/run.rb +6 -5
- data/lib/guardrails/tokens.rb +9 -9
- data/lib/guardrails/tui.rb +2 -2
- data/lib/guardrails/version.rb +1 -1
- data/lib/tasks/guardrails.rake +16 -156
- data/lib/tasks/ui_guardrails.rake +304 -0
- metadata +21 -1
data/doc/ROADMAP.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Guardrails — Roadmap
|
|
2
2
|
|
|
3
|
-
**Status:** **
|
|
4
|
-
**Last updated:** 2026-
|
|
3
|
+
**Status:** **2.0.0: Feed Ramp.** V0 ✅, V1 ✅, V2 ✅ (the originally-planned roadmap, published as 1.0.0). Post-1.0 releases were about reading findings (1.1–1.4), then new findings and agent hooks (1.5–1.7). 2.0 moves upstream of the audit: it hands coding agents the app's component library before they write markup (manifest, agent rules, MCP server), flags markup that copies a component, and scores drift for before/after comparisons.
|
|
4
|
+
**Last updated:** 2026-10-19
|
|
5
5
|
|
|
6
6
|
## Context
|
|
7
7
|
|
|
@@ -13,7 +13,7 @@ This doc tracks shipped vs. planned scope and parks remaining unknowns. V0 + mos
|
|
|
13
13
|
|
|
14
14
|
## V0 — Foundation
|
|
15
15
|
|
|
16
|
-
### Audit (`rails
|
|
16
|
+
### Audit (`rails ui_guardrails:audit`)
|
|
17
17
|
|
|
18
18
|
| Item | Status | Notes |
|
|
19
19
|
|---|---|---|
|
|
@@ -41,7 +41,7 @@ This doc tracks shipped vs. planned scope and parks remaining unknowns. V0 + mos
|
|
|
41
41
|
| `inline_style` auto-fix | ❌ deferred | Structural rewrite, not value swap |
|
|
42
42
|
| `font-size` auto-fix | ❌ deferred | Same constraint as inline_style |
|
|
43
43
|
|
|
44
|
-
### Icons (`rails
|
|
44
|
+
### Icons (`rails ui_guardrails:icons`)
|
|
45
45
|
|
|
46
46
|
| Item | Status |
|
|
47
47
|
|---|---|
|
|
@@ -49,7 +49,7 @@ This doc tracks shipped vs. planned scope and parks remaining unknowns. V0 + mos
|
|
|
49
49
|
| Inline `<svg>` detection in views | ✅ shipped |
|
|
50
50
|
| Dead-icon report | ✅ shipped |
|
|
51
51
|
|
|
52
|
-
### Init / analysis (`rails
|
|
52
|
+
### Init / analysis (`rails ui_guardrails:init`)
|
|
53
53
|
|
|
54
54
|
| Item | Status | Notes |
|
|
55
55
|
|---|---|---|
|
|
@@ -60,7 +60,7 @@ This doc tracks shipped vs. planned scope and parks remaining unknowns. V0 + mos
|
|
|
60
60
|
| Refuse-overwrite by default | ✅ shipped | |
|
|
61
61
|
| `FORCE=1` to overwrite + re-scaffold MQs | ✅ shipped | |
|
|
62
62
|
|
|
63
|
-
### Tokens (`rails
|
|
63
|
+
### Tokens (`rails ui_guardrails:tokens`)
|
|
64
64
|
|
|
65
65
|
| Item | Status | Notes |
|
|
66
66
|
|---|---|---|
|
|
@@ -99,7 +99,7 @@ This doc tracks shipped vs. planned scope and parks remaining unknowns. V0 + mos
|
|
|
99
99
|
| Component-shape similarity | ✅ shipped | n-gram Jaccard, default threshold 0.7. PartialSimilarity scans both `_*.html.erb` partials and `*_component.html.erb` VC templates |
|
|
100
100
|
| Lookbook integration | ✅ shipped (0.5.0) | `Guardrails::Lookbook::ComponentReport` data API + Railtie auto-registers the `:guardrails` panel when Lookbook is loaded; partial ships inside the gem. Host can override the partial via standard view-path precedence. |
|
|
101
101
|
| ERB-partial structural similarity | ✅ shipped | Same PartialSimilarity engine |
|
|
102
|
-
| A11y integration | ✅ shipped (0.6.0) | Static checks shipped (image_alt, button_name, link_name, input_label). button_name and link_name skip when the element body wraps ERB output — those cases get a `helper_recommended` finding instead. **Deep mode shipped via `Guardrails::A11yDeep`** — consumes axe-core JSON output (single or multi-page) and folds findings into the unified report. `AXE_JSON=path/to/axe.json bundle exec rake
|
|
102
|
+
| A11y integration | ✅ shipped (0.6.0) | Static checks shipped (image_alt, button_name, link_name, input_label). button_name and link_name skip when the element body wraps ERB output — those cases get a `helper_recommended` finding instead. **Deep mode shipped via `Guardrails::A11yDeep`** — consumes axe-core JSON output (single or multi-page) and folds findings into the unified report. `AXE_JSON=path/to/axe.json bundle exec rake ui_guardrails:audit` or `rake ui_guardrails:a11y:deep`. Stayed parse-only (no Capybara / headless Chrome runtime deps) per the original size constraint. |
|
|
103
103
|
| `helper_recommended` detector | ✅ shipped | Flags `<button>` / `<a>` wrapping ERB output and suggests `tag.button` / `button_to` / `link_to`. Pairs with the a11y skip for the same case |
|
|
104
104
|
| Targeted auto-fix | ✅ shipped | See V0 auto-fix table; tailwind_arbitrary now also auto-fixes when a `:tailwind` theme token matches |
|
|
105
105
|
| Sample app in `examples/` | ✅ shipped (0.7.0) | Bootable Rails 7.2 app. `cd examples/demo && bundle install && bin/rails server` renders the seeded views and mounts Lookbook with the Guardrails panel auto-registered. Same tree the integration spec exercises. |
|
|
@@ -125,20 +125,40 @@ The planned roadmap ended at V2. Running the gem on real codebases (Patchvault:
|
|
|
125
125
|
| Inline suggestions + triage summary | ✅ shipped (1.1.0) | Every finding carries a `→` action; severity-grouped summary at top and bottom; `[error]` / `[warning]` / `[suggest]` tags; TTY-aware ASCII styling, `NO_COLOR` honored. |
|
|
126
126
|
| Ruby 3.4 / railties 7.2 floor | ✅ shipped (1.2.0) | Compatibility-only release. Ruby 3.2/3.3 or Rails 7.1 users stay on 1.1.0. |
|
|
127
127
|
| Findings data layer | ✅ shipped (1.3.0) | `Guardrails::Report::Run` runs the audit once for every front-end; `Report::Finding` / `Location` / `Category` are detector-agnostic finding data. Every detector implements `categories(result)`, sharing its title / suggestion strings with the text report. New detectors only need to implement that to show up in the TUI and HTML report. |
|
|
128
|
-
| Interactive browser (`rake
|
|
128
|
+
| Interactive browser (`rake ui_guardrails:tui`) | ✅ shipped (1.3.0) | Roll-up → category → finding → source, regroup by file, live filter, open in `$EDITOR` at the line, re-run. `io/console` only — no new dependencies. If the hand-rolled rendering stops being enough, swapping in a TUI library means replacing `TUI::Screen` + the loop in `tui.rb`; `TUI::State` (navigation) doesn't know about terminals. |
|
|
129
129
|
| HTML report (`FORMAT=html`) | ✅ shipped (1.3.0) | One self-contained file, no server or assets — opens from disk or as a CI artifact. Generated rather than served: a mounted engine was more surface than the problem needed, and the Lookbook panel already covers in-app. |
|
|
130
130
|
| File-grouped view | ✅ shipped (1.3.0) | `tab` in the TUI. Was a 1.1.0 follow-up. |
|
|
131
131
|
| `SEVERITY=error\|warning` floor | ✅ shipped (1.4.0) | One severity floor across text report, exit code, JSON, HTML, and TUI. Skips detectors that can only produce muted findings. Filtered reports say what wasn't checked; unknown values abort. Supersedes the `--strict` flag dropped in V0. |
|
|
132
|
-
| Emoji + glyph icon detection | ✅ shipped (1.5.0) | `Guardrails::Icons::EmojiScan` catches emoji pictographs and Unicode-dingbat glyphs used as UI iconography — grapheme-cluster iteration (VS16 / ZWJ / flag / keycap count as one), range-first single-codepoint classification, per-file-type comment masking (Prism for Ruby, `<%# %>` for ERB, `//` `/* */` for JS/SCSS, `#` for YAML), `guardrails-ok: emoji` inline marker (first inline-escape convention in the gem), `allow_files` + `allow_chars` config, `SUGGEST=1` sprite-name hints. `
|
|
132
|
+
| Emoji + glyph icon detection | ✅ shipped (1.5.0) | `Guardrails::Icons::EmojiScan` catches emoji pictographs and Unicode-dingbat glyphs used as UI iconography — grapheme-cluster iteration (VS16 / ZWJ / flag / keycap count as one), range-first single-codepoint classification, per-file-type comment masking (Prism for Ruby, `<%# %>` for ERB, `//` `/* */` for JS/SCSS, `#` for YAML), `guardrails-ok: emoji` inline marker (first inline-escape convention in the gem), `allow_files` + `allow_chars` config, `SUGGEST=1` sprite-name hints. `ui_guardrails:icons` also flipped to blocking — exit 1 on inline SVGs, unknown sprite refs, or emoji icons. |
|
|
133
133
|
| Lookbook panel on the new data layer | not started | The auto-registered panel still renders from its own `ComponentReport`; it could render `Report::Finding`s and pick up suggestions for free. Icons findings could also flow through — the panel doesn't list icon findings today. |
|
|
134
134
|
| Icons on the new data layer | not started | Icons stays outside `Report::Run` — its rake task calls `Icons.new(root:).run` directly. Migrating gets it into the TUI + HTML report for free but changes the rake shape; hold until there's demand. |
|
|
135
135
|
| Visual-diff adapter registry | ✅ shipped (1.6.0) | `Guardrails::VisualDiff.register_adapter` lets other gems add adapters; the name is checked when the adapter is built. `VisualDiff::Finding` gains `status`, `comment`, `regions`, `extra_details`. First consumer: ui_guardrails-browser's `:browser` adapter. |
|
|
136
|
-
| Coding agent hooks | ✅ shipped (1.7.0) | `rake
|
|
136
|
+
| Coding agent hooks | ✅ shipped (1.7.0) | `rake ui_guardrails:hooks:install` wires Claude Code, Codex, Cursor and Gemini CLI to `guardrails-hook`: drift an agent introduces (compared with `HEAD`) goes back to it after each edit, and new errors send it back once when it tries to finish. Other gems contribute through `Guardrails::Hooks.register`; ui_guardrails-browser adds visual-review rejections. |
|
|
137
137
|
| Detector registry | ✅ shipped (1.7.0) | `Guardrails::Report::Detectors.register(name, klass)` adds a detector to `Report::Run`, so it reaches the text report, summary, JSON (under `extensions`), HTML and TUI. Each decides when it's on (`enabled?(env:)`), how serious it can get (`max_severity`, so `SEVERITY=` skips it), and when it fails. First consumer: ui_guardrails-browser's heuristic review. |
|
|
138
138
|
| CI test workflow | ✅ shipped | `.github/workflows/test.yml` runs `bundle exec rspec` (including the `examples/demo` integration spec) and `gem build` on every pull request and push to `main`, on Ruby 3.4 (the floor) and 4.0. Floor regressions and packaging mistakes surface on the PR instead of at tag time. |
|
|
139
139
|
|
|
140
140
|
---
|
|
141
141
|
|
|
142
|
+
## 2.0 — Feed Ramp
|
|
143
|
+
|
|
144
|
+
The audit and the hooks catch drift after an agent has written it. Feed Ramp gives the agent the component library first, so there's less to catch.
|
|
145
|
+
|
|
146
|
+
| Item | Status | Notes |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| Component manifest | ✅ shipped (2.0.0) | `ui_guardrails:feed_ramp:manifest` writes `.ui_guardrails/manifest.json` + `COMPONENTS.md` from ViewComponent and Phlex components, Lookbook / ViewComponent previews, call sites and tokens. Prism-parsed, deterministic, examples copied verbatim, never invented. `CHECK=1` compares content, so view edits don't make it stale. |
|
|
149
|
+
| Agent rules | ✅ shipped (2.0.0) | Marker-delimited block in `CLAUDE.md` / `AGENTS.md` (+ opt-in `GEMINI.md`, Copilot), owned `.cursor/rules/ui-guardrails.mdc`. Idempotent; broken markers stop the run; `REMOVE=1`. |
|
|
150
|
+
| Drift score | ✅ shipped (2.0.0) | `ui_guardrails:feed_ramp:score`: issues per 100 templates in three buckets, saved runs, `BASELINE=` deltas. Never fails. |
|
|
151
|
+
| `component_match` | ✅ shipped (2.0.0) | Audit rule, silent without a manifest. Matches a component's signature class on any tag, or its root tag with ≥80% of a 3+ class list. Warning by default, configurable down to suggestion. |
|
|
152
|
+
| MCP server | ✅ shipped (2.0.0) | `ui_guardrails-mcp`: `list_components`, `get_component`, `search_components`, `check_snippet` (drift + component API checks). Hand-rolled JSON-RPC over stdio, no new dependency. |
|
|
153
|
+
| Dynamic component roots | ✅ shipped (2.0.0) | Root classes built by helpers (`class_names`, `build_css`), attribute methods, `attrs[:class] =` and `tag.attributes(**…)` are read statically. Before this, `component_match` found nothing on three real ViewComponent apps. A signature class matches on any tag. |
|
|
154
|
+
| Lookbook `preview_paths` from Rails config | ✅ shipped (2.0.0) | `config.view_component.previews.paths` / `preview_paths` / `config.lookbook.preview_paths` in `config/application.rb` and the development / test environments, read with Prism. Paths computed at boot still go in `guardrails.yml`. |
|
|
155
|
+
| Phlex kit calls as examples | ✅ shipped (2.0.0) | `Alert(...)` and `Components::Alert(...)` are usage examples and are checked by `check_snippet`. |
|
|
156
|
+
| Codex MCP config | ✅ shipped (2.0.0) | `AGENT=codex` writes `[mcp_servers.ui_guardrails]` to `.codex/config.toml` (trusted projects only). |
|
|
157
|
+
| `component_match` for components with argument-dependent roots | not started | A root whose classes all come from arguments, or from an `if` with different classes per branch, has no fingerprint. Rendering the previews would give one, but that means booting the app. |
|
|
158
|
+
| Composed roots | not started | A component whose root is another component (`render DialogComponent.new(class: "nav-drawer")`) gets no root element; following the inner component's root plus the extra class would cover it. |
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
142
162
|
## Decisions Captured
|
|
143
163
|
|
|
144
164
|
| Question | Decision |
|
data/doc/VISUAL-DIFF.md
CHANGED
|
@@ -14,16 +14,16 @@ In a Rails app with `snap_diff-capybara` installed and system tests that call `s
|
|
|
14
14
|
bundle exec rspec spec/system/
|
|
15
15
|
|
|
16
16
|
# Then fold the visual-diff findings into the unified audit:
|
|
17
|
-
VISUAL_DIFF=1 bundle exec rake
|
|
17
|
+
VISUAL_DIFF=1 bundle exec rake ui_guardrails:audit
|
|
18
18
|
|
|
19
19
|
# Or run the visual check standalone:
|
|
20
|
-
bundle exec rake
|
|
20
|
+
bundle exec rake ui_guardrails:visual:deep
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
If the diffs sit somewhere other than `doc/screenshots/`:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
VISUAL_DIFF_DIR=spec/screenshots bundle exec rake
|
|
26
|
+
VISUAL_DIFF_DIR=spec/screenshots bundle exec rake ui_guardrails:visual:deep
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
To tolerate small mismatches (when the adapter emits a numeric ratio — snap_diff currently emits only a binary pass/fail, see "Adapter limits" below):
|
|
@@ -31,7 +31,7 @@ To tolerate small mismatches (when the adapter emits a numeric ratio — snap_di
|
|
|
31
31
|
```bash
|
|
32
32
|
# VISUAL_DIFF=1 still needs to be set — VISUAL_DIFF_THRESHOLD alone
|
|
33
33
|
# doesn't enable the check, just tunes it.
|
|
34
|
-
VISUAL_DIFF=1 VISUAL_DIFF_THRESHOLD=0.02 bundle exec rake
|
|
34
|
+
VISUAL_DIFF=1 VISUAL_DIFF_THRESHOLD=0.02 bundle exec rake ui_guardrails:audit
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
## Configuration
|
|
@@ -49,13 +49,13 @@ Guardrails.configure do |c|
|
|
|
49
49
|
end
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
With `enabled = true`, `rake
|
|
52
|
+
With `enabled = true`, `rake ui_guardrails:audit` always runs the visual-diff check; no need to set `VISUAL_DIFF=1` on each invocation.
|
|
53
53
|
|
|
54
54
|
### Sidecar (no Gemfile change) — environment variables
|
|
55
55
|
|
|
56
56
|
| Var | Effect |
|
|
57
57
|
|---|---|
|
|
58
|
-
| `VISUAL_DIFF=1` | Enable visual-diff on `rake
|
|
58
|
+
| `VISUAL_DIFF=1` | Enable visual-diff on `rake ui_guardrails:audit` (the standalone `ui_guardrails:visual:deep` task is always enabled). |
|
|
59
59
|
| `VISUAL_DIFF_DIR=path` | Override the snap_diff baseline directory (default `doc/screenshots`). |
|
|
60
60
|
| `VISUAL_DIFF_THRESHOLD=0.02` | Per-finding mismatch ratio above which the audit fails. Currently only meaningful for adapters that emit numeric ratios — snap_diff is binary. |
|
|
61
61
|
|
|
@@ -99,7 +99,7 @@ Each finding carries:
|
|
|
99
99
|
|
|
100
100
|
## JSON output
|
|
101
101
|
|
|
102
|
-
When `FORMAT=json` is set on `
|
|
102
|
+
When `FORMAT=json` is set on `ui_guardrails:audit`, visual-diff findings appear under `visual_diff:`:
|
|
103
103
|
|
|
104
104
|
```json
|
|
105
105
|
{
|
|
@@ -132,7 +132,7 @@ A complete CI loop, end-to-end:
|
|
|
132
132
|
continue-on-error: true # don't fail the job here — let Guardrails report
|
|
133
133
|
|
|
134
134
|
- name: Guardrails audit
|
|
135
|
-
run: VISUAL_DIFF=1 bundle exec rake
|
|
135
|
+
run: VISUAL_DIFF=1 bundle exec rake ui_guardrails:audit FORMAT=json > findings.json
|
|
136
136
|
|
|
137
137
|
- name: Upload findings + diff images
|
|
138
138
|
if: always()
|
data/exe/guardrails-hook
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
# Called by a coding agent's hooks: `guardrails-hook <agent> <event>`,
|
|
5
|
-
# with the agent's hook input on stdin. `rake
|
|
5
|
+
# with the agent's hook input on stdin. `rake ui_guardrails:hooks:install`
|
|
6
6
|
# writes the agent configuration that calls it.
|
|
7
7
|
require "guardrails"
|
|
8
8
|
require "guardrails/hooks/runner"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The Feed Ramp MCP server, over stdio: `ui_guardrails-mcp [--root DIR]
|
|
5
|
+
# [--no-write]`. `rake ui_guardrails:feed_ramp:mcp:install` writes the
|
|
6
|
+
# agent configuration that starts it.
|
|
7
|
+
require "optparse"
|
|
8
|
+
|
|
9
|
+
root = Dir.pwd
|
|
10
|
+
write = true
|
|
11
|
+
OptionParser.new do |opts|
|
|
12
|
+
opts.banner = "Usage: ui_guardrails-mcp [--root DIR] [--no-write]"
|
|
13
|
+
opts.on("--root DIR", "The Rails app (default: the current directory)") { |dir| root = dir }
|
|
14
|
+
opts.on("--[no-]write", "Rewrite .ui_guardrails/ when the components change (default: on)") { |value| write = value }
|
|
15
|
+
opts.on("-v", "--version") do
|
|
16
|
+
require "guardrails/version"
|
|
17
|
+
puts Guardrails::VERSION
|
|
18
|
+
exit
|
|
19
|
+
end
|
|
20
|
+
end.parse!
|
|
21
|
+
|
|
22
|
+
# Stdout carries the protocol and nothing else: keep it for the server,
|
|
23
|
+
# and send anything else that prints there to stderr.
|
|
24
|
+
protocol = $stdout
|
|
25
|
+
protocol.sync = true
|
|
26
|
+
$stdout = $stderr
|
|
27
|
+
|
|
28
|
+
require "guardrails"
|
|
29
|
+
require "guardrails/feed_ramp/mcp"
|
|
30
|
+
|
|
31
|
+
library = Guardrails::FeedRamp::MCP::Library.new(root: root, write: write)
|
|
32
|
+
Guardrails::FeedRamp::MCP::Server.new(library: library, input: $stdin, output: protocol).run
|
data/lib/guardrails/a11y_deep.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Guardrails
|
|
|
19
19
|
# CDP-driven script) and pass the JSON output to Guardrails:
|
|
20
20
|
#
|
|
21
21
|
# npx @axe-core/cli http://localhost:3000/ --save axe.json
|
|
22
|
-
# AXE_JSON=axe.json bundle exec rake
|
|
22
|
+
# AXE_JSON=axe.json bundle exec rake ui_guardrails:audit
|
|
23
23
|
#
|
|
24
24
|
# The result is a single unified report — static findings from
|
|
25
25
|
# A11yAudit plus runtime findings from axe — with the same exit-code
|
|
@@ -25,6 +25,10 @@ module Guardrails
|
|
|
25
25
|
helper_recommended: {
|
|
26
26
|
rule: "Wrapping ERB output in a literal element hides intent from static analysis and a11y tooling.",
|
|
27
27
|
replacement: "Use the Rails helper for this element so attributes (including aria-*) flow through one place."
|
|
28
|
+
},
|
|
29
|
+
component_match: {
|
|
30
|
+
rule: "This markup copies one of your components; render the component so changes to it reach every use.",
|
|
31
|
+
replacement: "Render the component (see .ui_guardrails/COMPONENTS.md for its params and an example)."
|
|
28
32
|
}
|
|
29
33
|
}.freeze
|
|
30
34
|
|
|
@@ -151,6 +155,7 @@ module Guardrails
|
|
|
151
155
|
end
|
|
152
156
|
|
|
153
157
|
def helper_specific_replacement(violation)
|
|
158
|
+
return "Render `#{violation.value}` (see .ui_guardrails/COMPONENTS.md for its params and an example)." if violation.type == :component_match
|
|
154
159
|
return nil unless violation.type == :helper_recommended
|
|
155
160
|
|
|
156
161
|
HELPER_REPLACEMENTS[violation.value]
|
data/lib/guardrails/audit.rb
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require "set"
|
|
5
5
|
require "stringio"
|
|
6
|
+
require "json"
|
|
6
7
|
require "yaml"
|
|
7
8
|
require_relative "erb_parser"
|
|
9
|
+
require_relative "feed_ramp/config"
|
|
8
10
|
require_relative "report/style"
|
|
9
11
|
require_relative "report/finding"
|
|
10
12
|
require_relative "report/severity"
|
|
@@ -89,7 +91,7 @@ module Guardrails
|
|
|
89
91
|
# SUGGEST checklist, and the returned list (→ exit code, JSON)
|
|
90
92
|
# all agree on what this run was about.
|
|
91
93
|
violations = collect_files.flat_map { |file| scan_file(file) }.select do |v|
|
|
92
|
-
|
|
94
|
+
reported?(v.type)
|
|
93
95
|
end
|
|
94
96
|
print_report(violations)
|
|
95
97
|
remaining = @apply ? apply_auto_fixes(violations) : violations
|
|
@@ -106,7 +108,7 @@ module Guardrails
|
|
|
106
108
|
list = by_type[type]
|
|
107
109
|
next if list.nil? || list.empty?
|
|
108
110
|
|
|
109
|
-
severity =
|
|
111
|
+
severity = severity_for(type)
|
|
110
112
|
auto_fix = AUTO_FIXABLE_TYPES.include?(type)
|
|
111
113
|
Report::Category.new(
|
|
112
114
|
name: type.to_s, severity: severity, framing: FRAMING_FOR_TYPE[type], auto_fix: auto_fix,
|
|
@@ -129,7 +131,7 @@ module Guardrails
|
|
|
129
131
|
# check scan_paths or ignores; ask `covers?` first.
|
|
130
132
|
def scan_source(path, content = nil)
|
|
131
133
|
scan_file(Pathname(path).expand_path(@root), content).select do |v|
|
|
132
|
-
|
|
134
|
+
reported?(v.type)
|
|
133
135
|
end
|
|
134
136
|
end
|
|
135
137
|
|
|
@@ -142,6 +144,20 @@ module Guardrails
|
|
|
142
144
|
scan_paths.any? { |dir| path.to_s.start_with?("#{@root.join(dir)}/") } && !ignored?(path)
|
|
143
145
|
end
|
|
144
146
|
|
|
147
|
+
# A violation type's severity: fixed per type, except
|
|
148
|
+
# `component_match`, set by `feed_ramp.component_match` in
|
|
149
|
+
# guardrails.yml (nil when it's off).
|
|
150
|
+
def severity_for(type)
|
|
151
|
+
return component_match_settings.severity if type == :component_match
|
|
152
|
+
|
|
153
|
+
SEVERITY_FOR_TYPE.fetch(type, :warning)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The templates a full run scans, relative to root.
|
|
157
|
+
def templates
|
|
158
|
+
collect_files.map { |path| relative(path) }.sort
|
|
159
|
+
end
|
|
160
|
+
|
|
145
161
|
private
|
|
146
162
|
|
|
147
163
|
def load_audit_config
|
|
@@ -191,7 +207,97 @@ module Guardrails
|
|
|
191
207
|
detect_inline_styles_ast(ast_result, file, original_lines) +
|
|
192
208
|
detect_raw_color_literals_ast(ast_result, file, original_lines) +
|
|
193
209
|
detect_tailwind_arbitrary_ast(ast_result, file, original_lines) +
|
|
194
|
-
detect_helper_recommended_ast(ast_result, file, original_lines)
|
|
210
|
+
detect_helper_recommended_ast(ast_result, file, original_lines) +
|
|
211
|
+
detect_component_match_ast(ast_result, file, original_lines)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def reported?(type)
|
|
215
|
+
severity = severity_for(type)
|
|
216
|
+
!severity.nil? && Report::Severity.include?(severity, @min_severity)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def component_match_settings
|
|
220
|
+
@component_match_settings ||= FeedRamp::Config.load(@root).component_match
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Components to match markup against: those in the committed Feed
|
|
224
|
+
# Ramp manifest whose root element is recognisable, by enough static
|
|
225
|
+
# classes or by a class named after the component. None without a
|
|
226
|
+
# manifest, so the rule is silent until
|
|
227
|
+
# `ui_guardrails:feed_ramp:manifest` has been run.
|
|
228
|
+
def matchable_components
|
|
229
|
+
@matchable_components ||= begin
|
|
230
|
+
path = FeedRamp::Config.load(@root).manifest_path(@root)
|
|
231
|
+
components = path.file? ? Array(JSON.parse(path.read)["components"]) : []
|
|
232
|
+
components.select do |component|
|
|
233
|
+
root = component["root_element"]
|
|
234
|
+
next false unless root.is_a?(Hash)
|
|
235
|
+
|
|
236
|
+
Array(root["classes"]).length >= component_match_settings.min_classes || signature_class(component)
|
|
237
|
+
end
|
|
238
|
+
rescue JSON::ParserError, SystemCallError
|
|
239
|
+
[]
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Elements carrying the root tag and most of the root classes of an
|
|
244
|
+
# existing component: markup that should be a `render` of it. One
|
|
245
|
+
# finding per element, for the component it covers best. A
|
|
246
|
+
# component's own template is never matched against itself.
|
|
247
|
+
def detect_component_match_ast(parse_result, file, original_lines)
|
|
248
|
+
return [] if severity_for(:component_match).nil? || matchable_components.empty?
|
|
249
|
+
|
|
250
|
+
path = relative(file)
|
|
251
|
+
results = []
|
|
252
|
+
ErbParser.each_node(parse_result.document) do |node|
|
|
253
|
+
next unless node.is_a?(::Herb::AST::HTMLElementNode)
|
|
254
|
+
|
|
255
|
+
tag = ErbParser.tag_name(node)
|
|
256
|
+
classes = ErbParser.static_classes(node)
|
|
257
|
+
next if tag.nil? || classes.empty?
|
|
258
|
+
|
|
259
|
+
match = best_component_match(tag, classes, path)
|
|
260
|
+
next unless match
|
|
261
|
+
|
|
262
|
+
line, column = ErbParser.start_position(node)
|
|
263
|
+
results << Violation.new(type: :component_match, file: path, line: line, column: column,
|
|
264
|
+
snippet: snippet(original_lines, line - 1), value: match["name"])
|
|
265
|
+
end
|
|
266
|
+
results
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The component its markup matches best: its signature class on any
|
|
270
|
+
# tag (a component may render a link or a button), or the same tag
|
|
271
|
+
# with most of a long-enough class list.
|
|
272
|
+
def best_component_match(tag, classes, path)
|
|
273
|
+
settings = component_match_settings
|
|
274
|
+
scored = matchable_components.filter_map do |component|
|
|
275
|
+
root = component["root_element"]
|
|
276
|
+
next if component["template"] == path
|
|
277
|
+
|
|
278
|
+
wanted = root["classes"]
|
|
279
|
+
coverage = (classes & wanted).length.fdiv(wanted.length)
|
|
280
|
+
by_classes = root["tag"] == tag && wanted.length >= settings.min_classes && coverage >= settings.coverage
|
|
281
|
+
by_signature = classes.include?(signature_class(component))
|
|
282
|
+
[component, coverage, wanted.length] if by_classes || by_signature
|
|
283
|
+
end
|
|
284
|
+
scored.min_by { |component, coverage, size| [-coverage, -size, component["name"]] }&.first
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The root class named after the component (`button` or
|
|
288
|
+
# `button-component` for ButtonComponent, `icon-button` /
|
|
289
|
+
# `icon_button` for IconButtonComponent, `alert` for
|
|
290
|
+
# Components::Alert), which marks markup as that component's even
|
|
291
|
+
# when it's the only class. nil when the root has none.
|
|
292
|
+
def signature_class(component)
|
|
293
|
+
@signature_classes ||= {}
|
|
294
|
+
@signature_classes.fetch(component["name"]) do
|
|
295
|
+
words = component["name"].to_s.split("::").last.to_s
|
|
296
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').downcase
|
|
297
|
+
base = words.delete_suffix("_component")
|
|
298
|
+
names = [base, words].uniq.flat_map { |name| [name.tr("_", "-"), name] }.uniq
|
|
299
|
+
@signature_classes[component["name"]] = (Array(component.dig("root_element", "classes")) & names).first
|
|
300
|
+
end
|
|
195
301
|
end
|
|
196
302
|
|
|
197
303
|
# AST-based inline_style detector. Walks the parsed document for
|
|
@@ -524,7 +630,7 @@ module Guardrails
|
|
|
524
630
|
end
|
|
525
631
|
end
|
|
526
632
|
|
|
527
|
-
TYPE_ORDER = %i[inline_style raw_color tailwind_arbitrary helper_recommended
|
|
633
|
+
TYPE_ORDER = %i[inline_style raw_color tailwind_arbitrary helper_recommended component_match
|
|
528
634
|
image_alt button_name link_name input_label].freeze
|
|
529
635
|
|
|
530
636
|
SEVERITY_FOR_TYPE = {
|
|
@@ -532,6 +638,7 @@ module Guardrails
|
|
|
532
638
|
raw_color: :error,
|
|
533
639
|
tailwind_arbitrary: :error,
|
|
534
640
|
helper_recommended: :warning,
|
|
641
|
+
component_match: :warning,
|
|
535
642
|
image_alt: :error,
|
|
536
643
|
button_name: :error,
|
|
537
644
|
link_name: :error,
|
|
@@ -543,6 +650,7 @@ module Guardrails
|
|
|
543
650
|
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.",
|
|
544
651
|
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.",
|
|
545
652
|
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.",
|
|
653
|
+
component_match: "Hand-written markup with the root element and classes of one of your components. Render the component instead, so a change to it reaches every use. Components come from the Feed Ramp manifest (rake ui_guardrails:feed_ramp:manifest).",
|
|
546
654
|
image_alt: "Images need accessible alt text. Use alt=\"\" for purely decorative images.",
|
|
547
655
|
button_name: "Buttons need an accessible name — text content, aria-label, or aria-labelledby.",
|
|
548
656
|
link_name: "Links need an accessible name — text content, aria-label, or aria-labelledby.",
|
|
@@ -552,7 +660,7 @@ module Guardrails
|
|
|
552
660
|
AUTO_FIXABLE_TYPES = %i[raw_color tailwind_arbitrary].freeze
|
|
553
661
|
|
|
554
662
|
def print_violation_type(type, violations)
|
|
555
|
-
severity =
|
|
663
|
+
severity = severity_for(type)
|
|
556
664
|
auto_fix_marker = AUTO_FIXABLE_TYPES.include?(type) ? ", auto-fix available" : ""
|
|
557
665
|
|
|
558
666
|
@output.puts ""
|
|
@@ -580,6 +688,9 @@ module Guardrails
|
|
|
580
688
|
violation.value
|
|
581
689
|
when :inline_style
|
|
582
690
|
violation.value || "<inline style>"
|
|
691
|
+
when :component_match
|
|
692
|
+
open_tag = violation.snippet.to_s[/<[^>]*>/] || violation.snippet.to_s.strip
|
|
693
|
+
"#{open_tag.length > 70 ? "#{open_tag[0, 69]}…" : open_tag} looks like #{violation.value}"
|
|
583
694
|
else
|
|
584
695
|
violation.snippet.to_s[0, 60]
|
|
585
696
|
end
|
|
@@ -621,6 +732,8 @@ module Guardrails
|
|
|
621
732
|
"extract to a CSS class or component stylesheet"
|
|
622
733
|
when :helper_recommended
|
|
623
734
|
helper_recommended_suggestion(violation)
|
|
735
|
+
when :component_match
|
|
736
|
+
component_match_suggestion(violation)
|
|
624
737
|
when :image_alt
|
|
625
738
|
"add an alt attribute (or alt=\"\" if decorative)"
|
|
626
739
|
when :button_name
|
|
@@ -653,6 +766,16 @@ module Guardrails
|
|
|
653
766
|
end
|
|
654
767
|
end
|
|
655
768
|
|
|
769
|
+
# "render ButtonComponent", with the start of its usage example from
|
|
770
|
+
# the manifest when it has one.
|
|
771
|
+
def component_match_suggestion(violation)
|
|
772
|
+
component = matchable_components.find { |c| c["name"] == violation.value }
|
|
773
|
+
example = component&.dig("example", "source").to_s.lines.first.to_s.strip
|
|
774
|
+
return "render #{violation.value} instead" if example.empty?
|
|
775
|
+
|
|
776
|
+
"render #{violation.value} instead, e.g. #{example.length > 90 ? "#{example[0, 89]}…" : example}"
|
|
777
|
+
end
|
|
778
|
+
|
|
656
779
|
def helper_recommended_suggestion(violation)
|
|
657
780
|
tag = violation.snippet.to_s[/<(\w+)/, 1]
|
|
658
781
|
case tag
|
|
@@ -142,54 +142,11 @@ module Guardrails
|
|
|
142
142
|
end
|
|
143
143
|
|
|
144
144
|
def element_tag_name(element)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
tok = element.open_tag.tag_name
|
|
148
|
-
tok && tok.respond_to?(:value) ? tok.value.to_s.downcase : nil
|
|
145
|
+
ErbParser.tag_name(element)
|
|
149
146
|
end
|
|
150
147
|
|
|
151
|
-
# Pull the static portion of the element's `class` attribute. If
|
|
152
|
-
# the attribute is missing, ERB-driven, or empty, returns []. ERB
|
|
153
|
-
# fragments inside a class attribute are dropped (we can't know
|
|
154
|
-
# what they'll render); the literal pieces are concatenated and
|
|
155
|
-
# whitespace-tokenized. Tokens are sorted so two identical lists
|
|
156
|
-
# in different source order produce the same fingerprint key.
|
|
157
148
|
def static_class_tokens(element)
|
|
158
|
-
|
|
159
|
-
return [] unless attr
|
|
160
|
-
|
|
161
|
-
static = static_attribute_value(attr)
|
|
162
|
-
return [] if static.nil? || static.strip.empty?
|
|
163
|
-
|
|
164
|
-
static.split(/\s+/).reject(&:empty?).sort.uniq
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def class_attribute_node(element)
|
|
168
|
-
open_children = ErbParser.compact_children(element.open_tag)
|
|
169
|
-
open_children.find do |child|
|
|
170
|
-
next false unless child.is_a?(::Herb::AST::HTMLAttributeNode)
|
|
171
|
-
|
|
172
|
-
name_wrapper, _value_wrapper = ErbParser.compact_children(child)
|
|
173
|
-
next false unless name_wrapper
|
|
174
|
-
|
|
175
|
-
literal = ErbParser.compact_children(name_wrapper).first
|
|
176
|
-
next false unless literal && literal.respond_to?(:content)
|
|
177
|
-
|
|
178
|
-
name = literal.content.respond_to?(:value) ? literal.content.value.to_s : literal.content.to_s
|
|
179
|
-
name.downcase == "class"
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def static_attribute_value(attribute_node)
|
|
184
|
-
_name_wrapper, value_wrapper = ErbParser.compact_children(attribute_node)
|
|
185
|
-
return nil unless value_wrapper
|
|
186
|
-
|
|
187
|
-
ErbParser.compact_children(value_wrapper).filter_map do |child|
|
|
188
|
-
next unless child.is_a?(::Herb::AST::LiteralNode)
|
|
189
|
-
|
|
190
|
-
content = child.content
|
|
191
|
-
content.respond_to?(:value) ? content.value.to_s : content.to_s
|
|
192
|
-
end.join
|
|
149
|
+
ErbParser.static_classes(element)
|
|
193
150
|
end
|
|
194
151
|
|
|
195
152
|
def print_report(clusters)
|
|
@@ -80,6 +80,59 @@ module Guardrails
|
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
# An element's tag name, downcased, or nil.
|
|
84
|
+
def tag_name(element)
|
|
85
|
+
return nil unless element.respond_to?(:open_tag) && element.open_tag
|
|
86
|
+
|
|
87
|
+
tok = element.open_tag.tag_name
|
|
88
|
+
tok.respond_to?(:value) ? tok.value.to_s.downcase : nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The static portion of an element's `class` attribute, as sorted,
|
|
92
|
+
# unique tokens. ERB fragments inside the attribute are dropped (we
|
|
93
|
+
# can't know what they'll render); a missing or ERB-only attribute
|
|
94
|
+
# gives [].
|
|
95
|
+
def static_classes(element)
|
|
96
|
+
attr = attribute(element, "class")
|
|
97
|
+
return [] unless attr
|
|
98
|
+
|
|
99
|
+
_name_wrapper, value_wrapper = compact_children(attr)
|
|
100
|
+
return [] unless value_wrapper
|
|
101
|
+
|
|
102
|
+
pieces = compact_children(value_wrapper).map do |child|
|
|
103
|
+
next nil unless child.is_a?(::Herb::AST::LiteralNode)
|
|
104
|
+
|
|
105
|
+
content = child.content
|
|
106
|
+
content.respond_to?(:value) ? content.value.to_s : content.to_s
|
|
107
|
+
end
|
|
108
|
+
class_tokens(pieces)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Class tokens from a value written in pieces: strings, and nil for
|
|
112
|
+
# each dynamic part. A token touching a dynamic part (`btn--` in
|
|
113
|
+
# `btn--<%= variant %>`) isn't a class anyone can know, so it's left
|
|
114
|
+
# out.
|
|
115
|
+
def class_tokens(pieces)
|
|
116
|
+
pieces.map { |piece| piece.nil? ? "\0" : piece.delete("\0") }.join
|
|
117
|
+
.split(/\s+/).reject { |token| token.empty? || token.include?("\0") }.sort.uniq
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# An element's attribute node by name, or nil.
|
|
121
|
+
def attribute(element, name)
|
|
122
|
+
return nil unless element.respond_to?(:open_tag) && element.open_tag
|
|
123
|
+
|
|
124
|
+
compact_children(element.open_tag).find do |child|
|
|
125
|
+
next false unless child.is_a?(::Herb::AST::HTMLAttributeNode)
|
|
126
|
+
|
|
127
|
+
name_wrapper, _value_wrapper = compact_children(child)
|
|
128
|
+
literal = name_wrapper && compact_children(name_wrapper).first
|
|
129
|
+
next false unless literal.respond_to?(:content)
|
|
130
|
+
|
|
131
|
+
text = literal.content.respond_to?(:value) ? literal.content.value.to_s : literal.content.to_s
|
|
132
|
+
text.downcase == name
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
83
136
|
# Convert a Herb location to (line, column) for a node's start.
|
|
84
137
|
# Herb uses 1-indexed lines and 0-indexed columns; Guardrails violations
|
|
85
138
|
# use 1-indexed columns. This adjusts for that.
|