ui_guardrails 1.7.0 → 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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +254 -46
  3. data/doc/A11Y.md +4 -4
  4. data/doc/PRD.md +7 -7
  5. data/doc/PUBLISHING.md +4 -0
  6. data/doc/ROADMAP.md +30 -10
  7. data/doc/VISUAL-DIFF.md +8 -8
  8. data/exe/guardrails-hook +1 -1
  9. data/exe/ui_guardrails-mcp +32 -0
  10. data/lib/guardrails/a11y_deep.rb +1 -1
  11. data/lib/guardrails/audit/markdown_writer.rb +5 -0
  12. data/lib/guardrails/audit.rb +129 -6
  13. data/lib/guardrails/class_itis.rb +2 -45
  14. data/lib/guardrails/erb_parser.rb +53 -0
  15. data/lib/guardrails/feed_ramp/agent_rules.rb +309 -0
  16. data/lib/guardrails/feed_ramp/components_markdown.rb +135 -0
  17. data/lib/guardrails/feed_ramp/config.rb +113 -0
  18. data/lib/guardrails/feed_ramp/design_tokens.rb +112 -0
  19. data/lib/guardrails/feed_ramp/discovery.rb +201 -0
  20. data/lib/guardrails/feed_ramp/libraries.rb +27 -0
  21. data/lib/guardrails/feed_ramp/manifest.rb +142 -0
  22. data/lib/guardrails/feed_ramp/mcp.rb +325 -0
  23. data/lib/guardrails/feed_ramp/mcp_install.rb +150 -0
  24. data/lib/guardrails/feed_ramp/modern_web.rb +83 -0
  25. data/lib/guardrails/feed_ramp/rails_config.rb +144 -0
  26. data/lib/guardrails/feed_ramp/root_element.rb +361 -0
  27. data/lib/guardrails/feed_ramp/ruby_source.rb +230 -0
  28. data/lib/guardrails/feed_ramp/score.rb +256 -0
  29. data/lib/guardrails/feed_ramp/snippet_check.rb +221 -0
  30. data/lib/guardrails/feed_ramp/usages.rb +174 -0
  31. data/lib/guardrails/feed_ramp.rb +27 -0
  32. data/lib/guardrails/hooks/config_file.rb +28 -2
  33. data/lib/guardrails/hooks/installer.rb +5 -2
  34. data/lib/guardrails/hooks.rb +24 -1
  35. data/lib/guardrails/init/config_writer.rb +21 -4
  36. data/lib/guardrails/init/prompter.rb +1 -1
  37. data/lib/guardrails/railtie.rb +2 -0
  38. data/lib/guardrails/report/detectors.rb +1 -1
  39. data/lib/guardrails/report/run.rb +6 -5
  40. data/lib/guardrails/tokens.rb +9 -9
  41. data/lib/guardrails/tui.rb +2 -2
  42. data/lib/guardrails/version.rb +1 -1
  43. data/lib/tasks/guardrails.rake +16 -156
  44. data/lib/tasks/ui_guardrails.rake +304 -0
  45. metadata +21 -1
data/doc/ROADMAP.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Guardrails — Roadmap
2
2
 
3
- **Status:** **1.5.0 released on RubyGems.org** — V0 ✅, V1 ✅, V2 ✅ (the originally-planned roadmap, published as 1.0.0). Post-1.0 releases have been about reading the findings rather than producing more of them: inline suggestions (1.1.0), then an interactive browser + HTML report (1.3.0), then a severity floor for CI adoption on existing codebases (1.4.0). 1.5.0 returns to producing findings — emoji-as-icon detection, the one drift vector that had zero coverage.
4
- **Last updated:** 2026-09-19
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 guardrails:audit`)
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 guardrails:icons`)
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 guardrails:init`)
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 guardrails:tokens`)
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 guardrails:audit` or `rake guardrails:a11y:deep`. Stayed parse-only (no Capybara / headless Chrome runtime deps) per the original size constraint. |
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 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. |
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. `guardrails:icons` also flipped to blocking — exit 1 on inline SVGs, unknown sprite refs, or emoji icons. |
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 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. |
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 guardrails:audit
17
+ VISUAL_DIFF=1 bundle exec rake ui_guardrails:audit
18
18
 
19
19
  # Or run the visual check standalone:
20
- bundle exec rake guardrails:visual:deep
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 guardrails:visual:deep
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 guardrails:audit
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 guardrails:audit` always runs the visual-diff check; no need to set `VISUAL_DIFF=1` on each invocation.
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 guardrails:audit` (the standalone `guardrails:visual:deep` task is always enabled). |
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 `guardrails:audit`, visual-diff findings appear under `visual_diff:`:
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 guardrails:audit FORMAT=json > findings.json
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 guardrails:hooks:install`
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
@@ -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 guardrails:audit
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]
@@ -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
- Report::Severity.include?(SEVERITY_FOR_TYPE.fetch(v.type, :warning), @min_severity)
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 = SEVERITY_FOR_TYPE.fetch(type, :warning)
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
- Report::Severity.include?(SEVERITY_FOR_TYPE.fetch(v.type, :warning), @min_severity)
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 = SEVERITY_FOR_TYPE.fetch(type, :warning)
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
- return nil unless element.respond_to?(:open_tag) && element.open_tag
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
- attr = class_attribute_node(element)
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.