ui_guardrails 1.3.0 → 1.5.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: a78df8763f35470a40ab6c8c8dcc6daf1351d35e733772a33afde5d1b7af1a54
4
- data.tar.gz: a1f8aa3366f9a350673c55ff4200716e6c37db2f777592b914ada3236f67682e
3
+ metadata.gz: b29232255e89ade665ffe0aa02661183fbf71c5918c53704d8241293be757454
4
+ data.tar.gz: 5d3cf78b2091bb6e781f6bd563976c9bc1c583a8269309ce897ed90d8eae3b3b
5
5
  SHA512:
6
- metadata.gz: f16927ab59ea1e9e89fe51203d3a0ca0f14331956fdcce4ca1e957642579129ba143651d066d9bb763f85fb4b2f3e5afd93a657a9266550b8435e0159478c659
7
- data.tar.gz: 7f4dfebecd72b184a84258ca90f48f978c603eb01739d3217f8cad8b24d0ab7991f2d8a79dc53834e7e67002b0da74219cf68e3aee0ca60c344febb1e3e722e0
6
+ metadata.gz: 99aa335b527770be65aa661262461ec9b9cc0e1a9f1f2b70153b804b584c5f3de497c0e38f18183d6eac1977d343506d99f42685a49c3bbe3e953781ba3cd176
7
+ data.tar.gz: 894f7504d9e0f559b79051645f7f2c29de05215de57b2f4e04f78f9eb0bb4145568d13bd593123be401a15f626b9c3872174467dd78920a4ce3f265709bb3712
data/README.md CHANGED
@@ -4,7 +4,7 @@ A Rails toolset that prevents UI drift in AI-assisted applications. Static audit
4
4
 
5
5
  Built and maintained by [Meticulous](https://meticulous.com).
6
6
 
7
- **Current release:** 1.3.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.4.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
 
@@ -93,7 +93,7 @@ APPLY=1 bundle exec rake guardrails:audit
93
93
  | `guardrails:init` | Stack detection, writes `guardrails.yml`, scaffolds prefers-color-scheme / prefers-contrast media queries. Refuses to overwrite an existing config — `FORCE=1` overrides. |
94
94
  | `guardrails:audit` | Runs every detector — view drift, stimulus, partial similarity, view-components, a11y, cross-codebase patterns, class-itis. Exits 1 on violations. |
95
95
  | `guardrails:tui` | The same audit, browsable: a severity roll-up you drill into (category → finding → source), regroup by file, filter, and jump from into your editor or the HTML report. See [Browsing findings](#browsing-findings). |
96
- | `guardrails:icons` | Generates an SVG sprite from `app/assets/images/icons/`, flags inline `<svg>` in views, reports unused icons. |
96
+ | `guardrails:icons` | Generates an SVG sprite from `app/assets/images/icons/`, flags inline `<svg>` in views, catches emoji / glyph icons across views + helpers + models + JS + locales, reports unused sprite entries. **Exits 1** on any inline SVG, unknown sprite reference, or emoji/glyph icon (1.5.0). Dead icons stay reporting-only. `SUGGEST=1` prints sprite-name suggestions; `FORMAT=json` emits the machine-readable payload. |
97
97
  | `guardrails:tokens` | Parses your color and type-scale tokens (CSS vars / SCSS vars / Tailwind v3 config / Tailwind v4 `@theme`), reports hex literals in stylesheets that should reference a token. |
98
98
  | `guardrails:a11y:deep` | Reads axe-core JSON output and folds it into the unified report. Doesn't run axe itself (no Capybara / headless Chrome runtime deps) — point it at axe output your existing tooling produces. |
99
99
  | `guardrails:visual:deep` | Consumes screenshot-diff tool output (snap_diff-capybara today; BackstopJS in flight, [#15](https://github.com/meticulous/guardrails/issues/15)) and reports visual regressions. Same parse-only design — your existing test toolchain runs screenshots; Guardrails reports. |
@@ -125,7 +125,7 @@ Static AST walk over every `.html.erb` under `app/views/` and `app/components/`
125
125
 
126
126
  - CSS custom properties in your configured `colors_file`
127
127
  - SCSS variables in the same
128
- - Tailwind v3 `tailwind.config.js` — flat colors and nested scales (`gray.50` → `gray-50`)
128
+ - Tailwind v3 `tailwind.config.js` — flat colors and nested scales (`gray.50` → `gray-50`). Found automatically at the repo root or at `config/tailwind.config.js` (the `tailwindcss-rails` default); set `tokens.tailwind_config` for anything else (`.cjs`, `.ts`, monorepo paths)
129
129
  - Tailwind v4 `@theme {}` blocks — picked up by the CSS-custom-property scanner
130
130
 
131
131
  Then scans every other stylesheet for hex literals and reports drift, matching each to the closest defined token. Block + line comments are stripped before matching, preserving line/column positions so reports are accurate.
@@ -186,6 +186,48 @@ Or standalone via `rake guardrails:a11y:deep`. Stays parse-only — your existin
186
186
 
187
187
  When [Lookbook](https://lookbook.build) is in the Gemfile, Guardrails auto-registers a `:guardrails` panel that appears next to every preview's Source / Notes panels. The panel renders `Guardrails::Lookbook::ComponentReport#for(component_class_name)` — drift in the template, orphan slots, similar templates — inline. Host apps override by dropping their own partial at `app/views/lookbook_panels/_guardrails.html.erb`. See [`doc/LOOKBOOK.md`](doc/LOOKBOOK.md).
188
188
 
189
+ ### Emoji and glyph icons (1.5.0)
190
+
191
+ The `guardrails:icons` task now catches emoji and Unicode-dingbat glyphs used as UI iconography — the most common way AI-assisted code bypasses the sprite. Two tiers, both on by default:
192
+
193
+ | Tier | What matches | Example |
194
+ |---|---|---|
195
+ | `emoji` | Extended_Pictographic pictographs + multi-codepoint sequences (VS16, ZWJ, flags, keycaps) | `📄` `👨‍👩‍👧` `🇺🇸` `1️⃣` |
196
+ | `glyph` | Single codepoints in Arrows, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Misc Symbols & Arrows | `✓` `→` `★` `●` `⚠` |
197
+
198
+ Grapheme-cluster iteration counts VS16-styled pictographs / ZWJ families / flag pairs / keycaps as **one** violation with the full cluster in the snippet. `\p{Emoji}` is deliberately **not** used (it matches ASCII digits, `#`, and `*`). Explicitly excluded from both tiers: letters, digits, punctuation, currency signs, `©®™`, ellipsis, dashes, quotes — those are copy, not icons.
199
+
200
+ **Scans**, by default: `app/views`, `app/components`, `app/helpers`, `app/models`, `app/presenters`, `app/javascript`, and `config/locales` — where the emoji-as-icon pattern usually lives (a helper returning `"📄"` per content type, a Stimulus controller setting `textContent = "✓"`). Comments are masked per file type: Prism-parsed for `.rb`, `<%# %>` for ERB, `//` and `/* */` for JS/SCSS/CSS, `#` for YAML. Line and column stay accurate.
201
+
202
+ **Config** in `guardrails.yml` (defaults shown; `guardrails:init` writes this block for you):
203
+
204
+ ```yaml
205
+ guardrails:
206
+ icons:
207
+ emoji:
208
+ enabled: true # the emoji tier
209
+ glyphs: true # the dingbat/glyph tier; false = emoji only
210
+ scan_paths: # optional override of the default file list
211
+ allow_files: # emoji-as-content: scanned but never flagged
212
+ - app/models/reaction.rb
213
+ allow_chars: [] # specific codepoints to never flag, e.g. ["→"]
214
+ ```
215
+
216
+ **Inline escape** for a single line — the first inline-marker convention in the gem:
217
+
218
+ ```ruby
219
+ BAD = "📄" # guardrails-ok: emoji intentional in this seed data
220
+
221
+ # guardrails-ok: emoji shipping consciously — deprecation notice text
222
+ LEGACY = "📎 old attachment"
223
+ ```
224
+
225
+ Works on the same line or the preceding line, in Ruby `#` comments, ERB `<%# %>` blocks, JS `//`, SCSS `//`, and YAML `#`.
226
+
227
+ **Suggestions** with `SUGGEST=1`: when the consumer's sprite contains a conventional name for the flagged emoji (`📄 → check for icon-file or icon-document`, `✓ → icon-check` / `icon-checkmark`), the task prints `→ use <use href="#icon-document"/>`. When no equivalent exists, it prints `→ no sprite equivalent — add one`. Only names the consumer's `collect_icon_names` includes are surfaced. No auto-fix path (`APPLY=1`) — the right sprite name depends on the consumer's semantics.
228
+
229
+ **No-fix policy**: emoji contribute to `guardrails:icons` exit code (part of Result#violations?). Dead icons stay reporting-only — deleting a file is a human decision.
230
+
189
231
  ---
190
232
 
191
233
  ## Output
@@ -219,6 +261,7 @@ The editor is `$GUARDRAILS_EDITOR`, then `$VISUAL`, then `$EDITOR`. VS Code-fami
219
261
  |---|---|
220
262
  | `SUGGEST=1` | Write the markdown checklist alongside the text report. |
221
263
  | `APPLY=1` | Auto-fix raw_color + tailwind_arbitrary where tokens match. |
264
+ | `SEVERITY=error` / `SEVERITY=warning` | Severity floor. `error` checks errors only; `warning` drops suggestions; default is everything. Applies to the report, the exit code, JSON, HTML, and the TUI alike. Detectors that can only produce muted findings aren't run. An unrecognized value aborts rather than being ignored. |
222
265
  | `FORMAT=json` | Emit one JSON document to stdout (all other audit output is suppressed). |
223
266
  | `FORMAT=html` / `OUTPUT=path` | Write the self-contained HTML report (default `tmp/guardrails/audit.html`). |
224
267
  | `GUARDRAILS_EDITOR=cmd` | Editor `guardrails:tui` opens locations in. Falls back to `$VISUAL`, then `$EDITOR`. |
@@ -242,6 +285,15 @@ The audit task is a single shell command:
242
285
  run: bundle exec rake guardrails:audit
243
286
  ```
244
287
 
288
+ Adopting Guardrails on an existing codebase usually means a backlog of warnings you can't clear in one PR. Gate on errors now and ratchet down later:
289
+
290
+ ```yaml
291
+ - name: Guardrails audit (errors only)
292
+ run: bundle exec rake guardrails:audit SEVERITY=error
293
+ ```
294
+
295
+ `SEVERITY=error` exits 1 only when there are error-level findings (raw colors, arbitrary Tailwind values, a11y, visual diffs) and skips the warning- and suggestion-tier detectors entirely, so it's also the fastest way to run the audit. The report ends with a line saying what wasn't checked, so a green run can't be mistaken for a clean codebase.
296
+
245
297
  For richer integration:
246
298
 
247
299
  ```yaml
@@ -275,6 +327,10 @@ guardrails:
275
327
  # guessed wrong.
276
328
  colors_file: app/assets/stylesheets/tokens/_colors.css
277
329
  type_scale_file: app/assets/stylesheets/tokens/_type.css
330
+ # Optional. Tailwind v3 config to read theme colors from. Not needed
331
+ # for tailwind.config.js at the root or under config/ — init writes
332
+ # it for the latter, and both are discovered without it.
333
+ tailwind_config: config/tailwind.config.js
278
334
  # Per-channel R/G/B distance at which a near-match becomes a
279
335
  # "close enough to suggest" finding. 0 = exact only; 4 = default
280
336
  # (catches #0066ff ↔ #0067fe); 20+ = aggressive.
data/doc/ROADMAP.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Guardrails — Roadmap
2
2
 
3
- **Status:** **1.0.0 released on RubyGems.org** — V0 ✅, V1 ✅, V2 ✅ (visual-diff via snap_diff-capybara landed 0.8.0; published as 1.0.0 once the trusted-publisher pipeline merged)
4
- **Last updated:** 2026-05-11
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
5
5
 
6
6
  ## Context
7
7
 
@@ -116,6 +116,26 @@ This doc tracks shipped vs. planned scope and parks remaining unknowns. V0 + mos
116
116
 
117
117
  ---
118
118
 
119
+ ## Post-1.0 — Report UX
120
+
121
+ The planned roadmap ended at V2. Running the gem on real codebases (Patchvault: 981 findings) showed the next problem wasn't detection, it was *reading* the output.
122
+
123
+ | Item | Status | Notes |
124
+ |---|---|---|
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
+ | 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
+ | 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. |
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
+ | File-grouped view | ✅ shipped (1.3.0) | `tab` in the TUI. Was a 1.1.0 follow-up. |
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. |
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
+ | 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
+ | CI test workflow | not started | Specs only run inside the release workflow, on one Ruby. A PR-time matrix (3.4 + head) would catch floor regressions before tag time. |
136
+
137
+ ---
138
+
119
139
  ## Decisions Captured
120
140
 
121
141
  | Question | Decision |
@@ -5,6 +5,7 @@ require "pathname"
5
5
  require "set"
6
6
  require_relative "report/style"
7
7
  require_relative "report/finding"
8
+ require_relative "report/severity"
8
9
 
9
10
  module Guardrails
10
11
  # Consumes axe-core JSON output and folds the findings into Guardrails'
@@ -39,7 +40,9 @@ module Guardrails
39
40
  # `failing_impacts:` if your rule pack emits custom severities.
40
41
  DEFAULT_FAILING_IMPACTS = %w[minor moderate serious critical].freeze
41
42
 
42
- def initialize(input:, output: $stdout, failing_impacts: DEFAULT_FAILING_IMPACTS, style: nil)
43
+ def initialize(input:, output: $stdout, failing_impacts: DEFAULT_FAILING_IMPACTS, style: nil,
44
+ min_severity: Report::Severity::DEFAULT)
45
+ @min_severity = min_severity
43
46
  @input = input
44
47
  @output = output
45
48
  @failing_impacts = Set.new(failing_impacts.map(&:to_s))
@@ -47,7 +50,7 @@ module Guardrails
47
50
  end
48
51
 
49
52
  def run
50
- findings = parse_input
53
+ findings = parse_input.select { |f| Report::Severity.include?(impact_to_severity(f.impact), @min_severity) }
51
54
  print_report(findings)
52
55
  findings
53
56
  end
@@ -7,6 +7,7 @@ require "yaml"
7
7
  require_relative "erb_parser"
8
8
  require_relative "report/style"
9
9
  require_relative "report/finding"
10
+ require_relative "report/severity"
10
11
 
11
12
  module Guardrails
12
13
  class Audit
@@ -71,7 +72,9 @@ module Guardrails
71
72
  flood-color lighting-color stop-color
72
73
  ].freeze
73
74
 
74
- def initialize(root:, output: $stdout, suggest: false, format: :text, apply: false, style: nil)
75
+ def initialize(root:, output: $stdout, suggest: false, format: :text, apply: false, style: nil,
76
+ min_severity: Report::Severity::DEFAULT)
77
+ @min_severity = min_severity
75
78
  @root = Pathname(root)
76
79
  @output = output
77
80
  @suggest = suggest
@@ -82,7 +85,12 @@ module Guardrails
82
85
  end
83
86
 
84
87
  def run
85
- violations = collect_files.flat_map { |file| scan_file(file) }
88
+ # Filtered before anything else sees them, so the report, the
89
+ # SUGGEST checklist, and the returned list (→ exit code, JSON)
90
+ # all agree on what this run was about.
91
+ 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)
93
+ end
86
94
  print_report(violations)
87
95
  remaining = @apply ? apply_auto_fixes(violations) : violations
88
96
  write_suggestions(remaining) if @suggest
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "prism"
5
+ require "set"
6
+
7
+ module Guardrails
8
+ class Icons
9
+ # Emoji and dingbat-glyph detection.
10
+ #
11
+ # Two tiers, both on by default:
12
+ #
13
+ # emoji — anything in \p{Extended_Pictographic}, plus regional-
14
+ # indicator pairs (flags, U+1F1E6..U+1F1FF), plus keycap
15
+ # sequences ([0-9#*]️?⃣). Handles VS16 (U+FE0F)
16
+ # and ZWJ (U+200D) so a family sequence or VS16-styled
17
+ # pictograph is reported as one grapheme with the full
18
+ # cluster in `snippet`.
19
+ #
20
+ # glyph — Unicode symbols used as icons that are NOT emoji:
21
+ # Dingbats (U+2700..27BF), Miscellaneous Symbols
22
+ # (U+2600..26FF), Miscellaneous Technical (U+2300..23FF),
23
+ # Arrows (U+2190..21FF), Geometric Shapes
24
+ # (U+25A0..25FF), Misc Symbols & Arrows (U+2B00..2BFF).
25
+ #
26
+ # These render inconsistently across platforms and are what the
27
+ # sprite exists to replace. Some teams accept `→` in prose, so the
28
+ # glyph tier is independently configurable via
29
+ # `guardrails.icons.emoji.glyphs`.
30
+ #
31
+ # `\p{Emoji}` matches ASCII digits, `#`, and `*` — never use it as
32
+ # the primary test. Iterate grapheme clusters instead, classify
33
+ # each with the three-way predicate below, and everything ASCII,
34
+ # `©®™`, `…`, `–—`, currency, typographic quotes stays unflagged.
35
+ #
36
+ # Classification order is emoji-first, glyph-second, because
37
+ # characters like `⚠` (U+26A0) and `✏` (U+270F) fall in the glyph
38
+ # codepoint ranges but ALSO in Extended_Pictographic. Reporting
39
+ # them as `emoji` matches user intuition (they're colored on all
40
+ # modern OSes) and keeps `glyphs: false` semantics honest.
41
+ class EmojiScan
42
+ Violation = Struct.new(:type, :file, :line, :column, :snippet, :codepoints, :tier,
43
+ keyword_init: true)
44
+
45
+ # Default file globs. Reuse Icons::USAGE_SCAN_PATTERNS shape but
46
+ # extend to helpers/models/presenters and locales — where the
47
+ # motivating consumer bug lived. `spec/` and `test/` are excluded
48
+ # by convention (they're outside `app/`).
49
+ DEFAULT_SCAN_PATTERNS = [
50
+ "app/views/**/*.html.erb",
51
+ "app/components/**/*.{html.erb,rb}",
52
+ "app/helpers/**/*.rb",
53
+ "app/models/**/*.rb",
54
+ "app/presenters/**/*.rb",
55
+ "app/javascript/**/*.{js,ts,jsx,tsx}",
56
+ "config/locales/**/*.yml"
57
+ ].freeze
58
+
59
+ # Path components we always skip, matching the ignore list other
60
+ # detectors use. `previews` covers Lookbook / ViewComponent
61
+ # preview directories under both `app/` and outside it.
62
+ IMPLICIT_IGNORE_SEGMENTS = %w[vendor node_modules tmp public log spec test previews].freeze
63
+
64
+ # Regional-indicator range for flag sequences (pairs of these
65
+ # form a grapheme cluster that renders as a flag).
66
+ REGIONAL_INDICATOR = /\A[\u{1F1E6}-\u{1F1FF}]{2}\z/
67
+
68
+ # Keycap sequence: a base character (digit, hash, or asterisk)
69
+ # optionally followed by VS16, then the combining enclosing
70
+ # keycap U+20E3.
71
+ KEYCAP = /\A[0-9#*]\u{FE0F}?\u{20E3}\z/
72
+
73
+ # Glyph-tier codepoint ranges. The spec groups characters by
74
+ # visual family (dingbat, arrow, geometric shape) — many of these
75
+ # ALSO carry the Extended_Pictographic property under Unicode
76
+ # (⚠ U+26A0, ✏ U+270F, ★ U+2605), but the spec's grouping is
77
+ # what maps to user intent: a team that accepts `→` in prose
78
+ # under `glyphs: false` will also accept `★`. So a single
79
+ # codepoint in one of these ranges wins glyph classification
80
+ # regardless of Extended_Pictographic — see `classify_single`.
81
+ #
82
+ # Multi-codepoint clusters (VS16, ZWJ, flag, keycap) never enter
83
+ # this branch; they're always emoji.
84
+ GLYPH_RANGES = [
85
+ (0x2190..0x21FF), # Arrows
86
+ (0x2300..0x23FF), # Misc Technical
87
+ (0x25A0..0x25FF), # Geometric Shapes
88
+ (0x2600..0x26FF), # Misc Symbols
89
+ (0x2700..0x27BF), # Dingbats
90
+ (0x2B00..0x2BFF) # Misc Symbols and Arrows
91
+ ].freeze
92
+
93
+ # Single codepoints Unicode marks Extended_Pictographic but are
94
+ # never icons in practice — trademark/registered/copyright marks
95
+ # ship in body copy as legal notation. Explicit skip so
96
+ # `Foo® Inc` doesn't report as `emoji`.
97
+ TYPOGRAPHIC_SKIP = Set[0x00A9, 0x00AE, 0x2122].freeze
98
+
99
+ # Same-line or preceding-line marker that silences the finding
100
+ # on that line. Reason text after `emoji` is optional but
101
+ # encouraged; only the presence of the marker is required.
102
+ INLINE_MARKER = /guardrails-ok:\s*emoji\b/
103
+
104
+ # `output:` is accepted for symmetry with other detectors but
105
+ # ignored — EmojiScan is pure return-a-value; the human-readable
106
+ # report belongs to Icons#report_emoji, which owns the output
107
+ # stream and the Style.
108
+ def initialize(root:, output: nil, # rubocop:disable Lint/UnusedMethodArgument
109
+ enabled: true, glyphs: true,
110
+ scan_paths: nil, allow_files: nil, allow_chars: nil)
111
+ @root = Pathname(root)
112
+ @enabled = enabled
113
+ @glyphs = glyphs
114
+ @scan_paths = Array(scan_paths).compact.map(&:to_s)
115
+ @allow_files = normalize_allow_files(allow_files)
116
+ @allow_chars = Array(allow_chars).flat_map { |c| c.to_s.grapheme_clusters }.to_set
117
+ end
118
+
119
+ def call
120
+ return [] unless @enabled
121
+
122
+ scan_files.flat_map { |file| scan_file(file) }
123
+ end
124
+
125
+ # Public for testing — the classifier is the load-bearing bit.
126
+ #
127
+ # Returns :emoji, :glyph, or nil. Grapheme clusters that are
128
+ # allowlisted, ASCII, punctuation, currency, `©®™`, etc. return
129
+ # nil.
130
+ #
131
+ # Multi-codepoint clusters (VS16, ZWJ, flag, keycap) are always
132
+ # emoji. Single codepoints check GLYPH_RANGES first (spec's
133
+ # visual grouping), then fall through to Extended_Pictographic.
134
+ def classify(grapheme)
135
+ return nil if grapheme.nil? || grapheme.empty?
136
+ return nil if @allow_chars.include?(grapheme)
137
+
138
+ chars = grapheme.each_char.to_a
139
+ if chars.length > 1
140
+ return :emoji if multi_codepoint_emoji?(grapheme)
141
+ return nil
142
+ end
143
+
144
+ classify_single(chars.first.ord)
145
+ end
146
+
147
+ private
148
+
149
+ def multi_codepoint_emoji?(grapheme)
150
+ return true if grapheme.match?(REGIONAL_INDICATOR)
151
+ return true if grapheme.match?(KEYCAP)
152
+
153
+ # ZWJ sequences and VS16-styled pictographs anchor on at least
154
+ # one Extended_Pictographic codepoint (a specific pictograph);
155
+ # the connecting ZWJ / selector pieces don't match on their own
156
+ # but the base does.
157
+ grapheme.each_char.any? { |c| c.match?(/\p{Extended_Pictographic}/) }
158
+ end
159
+
160
+ def classify_single(cp)
161
+ return nil if TYPOGRAPHIC_SKIP.include?(cp)
162
+
163
+ # Range-first: a codepoint in a glyph range is a glyph even if
164
+ # Unicode marks it Extended_Pictographic. This matches the
165
+ # spec's visual grouping and keeps `glyphs: false` predictable
166
+ # for teams that accept small monochrome symbols in prose.
167
+ if GLYPH_RANGES.any? { |r| r.cover?(cp) }
168
+ return @glyphs ? :glyph : nil
169
+ end
170
+
171
+ return :emoji if [cp].pack("U*").match?(/\p{Extended_Pictographic}/)
172
+
173
+ nil
174
+ end
175
+
176
+ def scan_files
177
+ patterns = @scan_paths.empty? ? DEFAULT_SCAN_PATTERNS : @scan_paths
178
+ patterns
179
+ .flat_map { |pattern| Dir.glob(@root.join(pattern)) }
180
+ .map { |path| Pathname(path) }
181
+ .uniq
182
+ .reject { |path| ignored?(path) }
183
+ .reject { |path| allow_listed?(path) }
184
+ end
185
+
186
+ def ignored?(path)
187
+ segments = relative(path).split("/")
188
+ (IMPLICIT_IGNORE_SEGMENTS & segments).any?
189
+ end
190
+
191
+ def allow_listed?(path)
192
+ @allow_files.include?(relative(path))
193
+ end
194
+
195
+ def scan_file(file)
196
+ raw = read(file)
197
+ return [] if raw.nil? || raw.empty?
198
+
199
+ masked = mask_comments(raw, file)
200
+ suppressed_lines = suppressed_line_set(raw, masked)
201
+ relative_path = relative(file)
202
+
203
+ violations = []
204
+ masked.each_line.with_index(1) do |line, line_num|
205
+ next if suppressed_lines.include?(line_num)
206
+
207
+ scan_line(line).each do |(grapheme, tier, column)|
208
+ violations << Violation.new(
209
+ type: :emoji_icon,
210
+ file: relative_path,
211
+ line: line_num,
212
+ column: column,
213
+ snippet: grapheme,
214
+ codepoints: grapheme.each_char.map { |c| format("U+%04X", c.ord) }.join(" "),
215
+ tier: tier
216
+ )
217
+ end
218
+ end
219
+ violations
220
+ end
221
+
222
+ # Walk the line by grapheme cluster, tracking char offset so
223
+ # `column` is the 1-indexed character position — not byte
224
+ # position (would be wrong for any multibyte char) and not
225
+ # grapheme index (a family sequence spans 5 characters but
226
+ # counts as one grapheme, and the column of the NEXT thing
227
+ # after it needs to be char-accurate).
228
+ def scan_line(line)
229
+ results = []
230
+ char_offset = 0
231
+ line.each_grapheme_cluster do |g|
232
+ tier = classify(g)
233
+ results << [g, tier, char_offset + 1] if tier
234
+ char_offset += g.length
235
+ end
236
+ results
237
+ end
238
+
239
+ # Length-preserving comment mask per file type. Non-empty return
240
+ # value preserves line numbers and columns for accurate location
241
+ # reporting — mirrors the strategy `mask_erb` uses in Icons.
242
+ def mask_comments(source, file)
243
+ ext = file.extname.downcase
244
+ case ext
245
+ when ".rb" then mask_ruby_comments(source)
246
+ when ".erb" then mask_erb_comments(source)
247
+ when ".js", ".ts", ".jsx", ".tsx", ".scss", ".sass", ".css"
248
+ mask_js_style_comments(source)
249
+ when ".yml", ".yaml" then mask_yaml_comments(source)
250
+ else source
251
+ end
252
+ end
253
+
254
+ # Prism ships with Ruby 3.4 (min supported). Its `comments` list
255
+ # is more accurate than any regex: it correctly ignores `#` inside
256
+ # string literals, interpolation, and heredocs. A regex on `#.*$`
257
+ # would false-mask `"#icon-check"` and break inline markers.
258
+ #
259
+ # NB: Prism's default `start_offset` / `end_offset` are BYTE
260
+ # offsets; indexing an array of characters with them misaligns
261
+ # after any multibyte content earlier in the file (an emoji-in-
262
+ # a-string followed by a `#` comment would leave the `#` region
263
+ # unmasked and the following line partly masked). Use the
264
+ # `_character_offset` accessors (Prism 1.5+, bundled with Ruby
265
+ # 3.4 as a default gem) which count characters and work with
266
+ # `String#[]=` on the source.
267
+ def mask_ruby_comments(source)
268
+ result = Prism.parse(source)
269
+ chars = source.chars
270
+ result.comments.each do |c|
271
+ start_off = c.location.start_character_offset
272
+ end_off = c.location.end_character_offset
273
+ # Replace with spaces, but keep newlines so line numbers hold.
274
+ (start_off...end_off).each do |i|
275
+ chars[i] = " " unless chars[i] == "\n"
276
+ end
277
+ end
278
+ chars.join
279
+ rescue StandardError
280
+ # If Prism fails on malformed source, leave uncommented — we'd
281
+ # rather over-report than skip a real emoji hidden by a parse
282
+ # error.
283
+ source
284
+ end
285
+
286
+ # Only `<%# ... %>` in ERB is a comment. `<% ... %>` and
287
+ # `<%= ... %>` execute Ruby that may legitimately contain
288
+ # emoji as string literals — those must remain scannable.
289
+ ERB_COMMENT = /<%#[\s\S]*?%>/
290
+ def mask_erb_comments(source)
291
+ source.gsub(ERB_COMMENT) { |m| preserve_newlines(m) }
292
+ end
293
+
294
+ JS_LINE_COMMENT = %r{//[^\n]*}
295
+ JS_BLOCK_COMMENT = %r{/\*[\s\S]*?\*/}
296
+ # Note: string-embedded `//` (e.g. inside a URL literal) will
297
+ # false-mask through this regex. Documented gap; a full JS parser
298
+ # would fix it but the false-negative surface is small — a URL
299
+ # `"http://foo/📄"` would incorrectly not report. Acceptable
300
+ # tradeoff for zero-dep scanning; add a marker if it hurts.
301
+ def mask_js_style_comments(source)
302
+ source
303
+ .gsub(JS_BLOCK_COMMENT) { |m| preserve_newlines(m) }
304
+ .gsub(JS_LINE_COMMENT) { |m| " " * m.length }
305
+ end
306
+
307
+ # YAML: `#` starts a comment to end-of-line, except inside quoted
308
+ # strings. Approximated with a simpler regex: mask `#` at start
309
+ # of line or after whitespace. Quoted `#` (rare in locale files
310
+ # for icons) will false-mask; document if it becomes a problem.
311
+ YAML_COMMENT = /(^|\s)#[^\n]*/
312
+ def mask_yaml_comments(source)
313
+ source.gsub(YAML_COMMENT) do |m|
314
+ # Preserve the leading whitespace/anchor character.
315
+ prefix = m.start_with?("#") ? "" : m[0]
316
+ "#{prefix}#{" " * (m.length - prefix.length)}"
317
+ end
318
+ end
319
+
320
+ def preserve_newlines(str)
321
+ newline_count = str.count("\n")
322
+ "\n" * newline_count + " " * (str.length - newline_count)
323
+ end
324
+
325
+ # Build a set of line numbers to suppress based on inline
326
+ # markers. A marker suppresses the line it appears on. It ALSO
327
+ # suppresses the following line only when the marker line has no
328
+ # non-comment content — that's the "marker on the preceding
329
+ # line" case. A trailing marker (`FOO = "\u{1F4C4}" # guardrails-ok:
330
+ # emoji`) suppresses only its own line, so a legitimate finding
331
+ # on the next line still surfaces.
332
+ #
333
+ # `raw` carries the marker text (comments are erased in `masked`);
334
+ # `masked` tells us whether the marker line was a pure comment
335
+ # (its `strip.empty?` in the masked form) or a trailing comment
336
+ # after code (non-empty).
337
+ def suppressed_line_set(raw, masked)
338
+ suppressed = Set.new
339
+ masked_lines = masked.lines
340
+ raw.each_line.with_index(1) do |line, line_num|
341
+ next unless line.match?(INLINE_MARKER)
342
+
343
+ suppressed << line_num
344
+ masked_line = masked_lines[line_num - 1] || ""
345
+ suppressed << line_num + 1 if masked_line.strip.empty?
346
+ end
347
+ suppressed
348
+ end
349
+
350
+ def read(file)
351
+ File.read(file, encoding: Encoding::UTF_8)
352
+ rescue Errno::ENOENT, Errno::EACCES
353
+ nil
354
+ end
355
+
356
+ def relative(path)
357
+ path.relative_path_from(@root).to_s
358
+ rescue ArgumentError
359
+ path.to_s
360
+ end
361
+
362
+ def normalize_allow_files(list)
363
+ Array(list).map(&:to_s).to_set
364
+ end
365
+ end
366
+ end
367
+ end
@@ -1,12 +1,86 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
+ require "set"
4
5
  require "yaml"
6
+ require_relative "report/style"
7
+ require_relative "icons/emoji_scan"
5
8
 
6
9
  module Guardrails
7
10
  class Icons
8
11
  Violation = Struct.new(:type, :file, :line, :column, :snippet, keyword_init: true)
9
12
 
13
+ # Result of a full run. Struct with keyword_init keeps the Hash-style
14
+ # `result[:inline_svgs]` access existing consumers rely on (see
15
+ # spec/integration/demo_app_spec.rb) while adding a `violations?`
16
+ # predicate the rake task can gate `exit 1` on. Mirrors the shape
17
+ # of `StimulusAudit::Result` / `ViewComponentAudit::Result`.
18
+ #
19
+ # Dead icons are excluded from `violations?` on purpose: deleting an
20
+ # unused SVG file is a human decision (it may be genuinely used by a
21
+ # deploy path the scan can't see), so the audit reports them but
22
+ # doesn't fail the build over them. Emoji findings DO fail — the
23
+ # sprite is the design-system boundary that emoji bypass.
24
+ Result = Struct.new(:inline_svgs, :dead_icons, :unknown_refs, :emoji, keyword_init: true) do
25
+ def violations?
26
+ !inline_svgs.empty? || !unknown_refs.empty? || !emoji.empty?
27
+ end
28
+
29
+ # Machine-readable serialization for FORMAT=json. Structs' default
30
+ # `to_h` recurses into member Structs but not their arrays of
31
+ # Structs; call `to_h` on each Violation manually.
32
+ def to_h
33
+ {
34
+ inline_svgs: inline_svgs.map(&:to_h),
35
+ dead_icons: dead_icons,
36
+ unknown_refs: unknown_refs,
37
+ emoji: emoji.map(&:to_h),
38
+ summary: {
39
+ inline_svgs: inline_svgs.length,
40
+ dead_icons: dead_icons.length,
41
+ unknown_refs: unknown_refs.length,
42
+ emoji: emoji.length
43
+ }
44
+ }
45
+ end
46
+ end
47
+
48
+ # Sprite-name hint map for SUGGEST=1 output. The pasted spec lists
49
+ # the well-known emoji → semantic-name mappings; only surface a
50
+ # suggestion when the target name actually exists in the consumer's
51
+ # collected icon set (via `collect_icon_names`). Otherwise print
52
+ # "no sprite equivalent — add one." so users know the gap.
53
+ #
54
+ # Codepoints as keys (single-cluster form after removing VS16) so
55
+ # the lookup survives whichever form the source used.
56
+ SPRITE_NAME_HINTS = {
57
+ "✓" => %w[check checkmark], # ✓
58
+ "✔" => %w[check checkmark], # ✔
59
+ "✕" => %w[x close], # ✕
60
+ "✖" => %w[x close], # ✖
61
+ "✗" => %w[x close], # ✗
62
+ "✘" => %w[x close], # ✘
63
+ "×" => %w[x close], # ×
64
+ "★" => %w[star favorite], # ★
65
+ "☆" => %w[star favorite], # ☆
66
+ "⚠" => %w[warning alert], # ⚠
67
+ "\u{1F4C4}" => %w[file document], # 📄
68
+ "\u{1F4CE}" => %w[attachment paperclip], # 📎
69
+ "\u{1F511}" => %w[key], # 🔑
70
+ "\u{1F464}" => %w[user person avatar], # 👤
71
+ "\u{1F3E2}" => %w[building office], # 🏢
72
+ "\u{1F5D1}" => %w[trash delete], # 🗑
73
+ "\u{270F}" => %w[edit pencil], # ✏
74
+ "➕" => %w[plus add], # ➕
75
+ "➖" => %w[minus remove], # ➖
76
+ "\u{1F514}" => %w[bell notification], # 🔔
77
+ "\u{1F507}" => %w[mute bell-off], # 🔇
78
+ "\u{1F4E6}" => %w[package zip archive], # 📦
79
+ "\u{1F3AC}" => %w[video film], # 🎬
80
+ "\u{1F3B5}" => %w[audio music note], # 🎵
81
+ "\u{1F4CA}" => %w[chart bar-chart] # 📊
82
+ }.freeze
83
+
10
84
  DEFAULT_SOURCE = "app/assets/images/icons"
11
85
  DEFAULT_SPRITE_OUTPUT = "app/assets/images/icons/sprite.svg"
12
86
  DEFAULT_VIEWBOX = "0 0 24 24"
@@ -50,22 +124,58 @@ module Guardrails
50
124
  "app/javascript/**/*.{js,ts,jsx,tsx}"
51
125
  ].freeze
52
126
 
53
- def initialize(root:, output: $stdout, source: nil, sprite_output: nil)
127
+ def initialize(root:, output: $stdout, source: nil, sprite_output: nil,
128
+ style: nil, suggest: false, format: :text)
54
129
  @root = Pathname(root)
55
130
  @output = output
56
- config = load_config
131
+ @style = style || Report::Style.new(io: output)
132
+ @suggest = suggest
133
+ @format = format
134
+ @config = load_config
57
135
 
58
- @source = resolve_path(source || config.dig("guardrails", "icons", "source") || DEFAULT_SOURCE)
59
- @sprite_output = resolve_path(sprite_output || config.dig("guardrails", "icons", "sprite_output") || DEFAULT_SPRITE_OUTPUT)
136
+ @source = resolve_path(source || @config.dig("guardrails", "icons", "source") || DEFAULT_SOURCE)
137
+ @sprite_output = resolve_path(sprite_output || @config.dig("guardrails", "icons", "sprite_output") || DEFAULT_SPRITE_OUTPUT)
60
138
  end
61
139
 
62
140
  def run
63
141
  generate_sprite
64
- violations = audit_inline_svgs
65
- report_inline_svgs(violations)
142
+ inline_violations = audit_inline_svgs
66
143
  dead_report = report_dead_icons
67
- print_dead_report(dead_report)
68
- { inline_svgs: violations, dead_icons: dead_report[:dead], unknown_refs: dead_report[:unknown] }
144
+ emoji_violations = audit_emoji
145
+
146
+ unless @format == :json
147
+ report_inline_svgs(inline_violations)
148
+ print_dead_report(dead_report)
149
+ report_emoji(emoji_violations)
150
+ end
151
+
152
+ Result.new(
153
+ inline_svgs: inline_violations,
154
+ dead_icons: dead_report[:dead],
155
+ unknown_refs: dead_report[:unknown],
156
+ emoji: emoji_violations
157
+ )
158
+ end
159
+
160
+ # Runs the emoji/glyph scan against the configured file set.
161
+ # Returns [] when the tier is disabled or the config block is
162
+ # missing entirely (opt-out escape hatch for teams that don't want
163
+ # this rule yet).
164
+ def audit_emoji
165
+ opts = emoji_config
166
+ enabled = opts.fetch("enabled", true)
167
+ return [] unless enabled
168
+
169
+ scanner = EmojiScan.new(
170
+ root: @root,
171
+ output: @output,
172
+ enabled: enabled,
173
+ glyphs: opts.fetch("glyphs", true),
174
+ scan_paths: opts["scan_paths"],
175
+ allow_files: opts["allow_files"],
176
+ allow_chars: opts["allow_chars"]
177
+ )
178
+ scanner.call
69
179
  end
70
180
 
71
181
  def audit_inline_svgs
@@ -106,6 +216,10 @@ module Guardrails
106
216
  YAML.safe_load_file(path) || {}
107
217
  end
108
218
 
219
+ def emoji_config
220
+ @config.dig("guardrails", "icons", "emoji") || {}
221
+ end
222
+
109
223
  def resolve_path(path)
110
224
  pathname = Pathname(path)
111
225
  pathname.absolute? ? pathname : @root.join(pathname)
@@ -229,5 +343,54 @@ module Guardrails
229
343
  @output.puts " #{v.snippet}"
230
344
  end
231
345
  end
346
+
347
+ def report_emoji(violations)
348
+ return if violations.empty?
349
+
350
+ grouped = violations.group_by(&:file).sort_by { |file, _| file }
351
+ noun = violations.length == 1 ? "emoji icon" : "emoji icons"
352
+ @output.puts ""
353
+ @output.puts "Guardrails icons: #{violations.length} #{noun} used as UI iconography (the sprite is the design-system boundary; these bypass it)"
354
+ grouped.each do |file, list|
355
+ @output.puts " #{file}"
356
+ list.each { |v| report_emoji_line(v) }
357
+ end
358
+ end
359
+
360
+ def report_emoji_line(violation)
361
+ # `snippet` here IS the grapheme cluster (`📄`), which chomp
362
+ # only touches \n / \r / \r\n, safe on the cluster.
363
+ shown = violation.snippet.to_s.chomp
364
+ location = @style.location("#{violation.line}:#{violation.column}")
365
+ meta = @style.location("(#{violation.codepoints}, #{violation.tier})")
366
+ @output.puts " #{location} #{shown} #{meta}"
367
+ hint = suggestion_for(violation)
368
+ @output.puts " #{@style.suggestion(hint)}" if hint
369
+ end
370
+
371
+ # Sprite-name hint under SUGGEST=1, per the pasted spec. Only
372
+ # surface a suggestion when one of the mapped names actually exists
373
+ # in the consumer's icon set — otherwise the "add one" nudge is
374
+ # more useful than a name that doesn't resolve.
375
+ def suggestion_for(violation)
376
+ return nil unless @suggest
377
+
378
+ candidates = SPRITE_NAME_HINTS[canonical_key(violation.snippet)] || []
379
+ present = candidates & collect_icon_names
380
+ if present.any?
381
+ %(use <use href="#icon-#{present.first}"/>)
382
+ else
383
+ "no sprite equivalent — add one"
384
+ end
385
+ end
386
+
387
+ # Look up the hint using the grapheme's base character (dropping
388
+ # VS16 U+FE0F) so `📽️` and `📽` share a mapping. Multi-codepoint
389
+ # emoji (ZWJ families, flags, keycaps) rarely have single-icon
390
+ # sprite equivalents; those miss SPRITE_NAME_HINTS entirely and
391
+ # fall through to the "add one" hint.
392
+ def canonical_key(grapheme)
393
+ grapheme.to_s.delete("\u{FE0F}")
394
+ end
232
395
  end
233
396
  end
@@ -51,6 +51,14 @@ module Guardrails
51
51
 
52
52
  def yaml_for(result, overrides)
53
53
  token_paths = DEFAULT_TOKEN_PATHS.fetch(result.strategy)
54
+ tokens = {
55
+ "strategy" => result.strategy.to_s,
56
+ "colors_file" => token_paths["colors_file"],
57
+ "type_scale_file" => token_paths["type_scale_file"]
58
+ }
59
+ tokens["tailwind_config"] = overrides[:tailwind_config] if overrides[:tailwind_config]
60
+ tokens["near_match_policy"] = overrides.fetch(:near_match_policy, "notify")
61
+ tokens["near_match_threshold"] = overrides.fetch(:near_match_threshold, 4)
54
62
  config = {
55
63
  "guardrails" => {
56
64
  "audit" => {
@@ -59,19 +67,19 @@ module Guardrails
59
67
  },
60
68
  "icons" => {
61
69
  "source" => "app/assets/images/icons",
62
- "sprite_output" => "app/assets/images/icons/sprite.svg"
70
+ "sprite_output" => "app/assets/images/icons/sprite.svg",
71
+ "emoji" => {
72
+ "enabled" => true,
73
+ "glyphs" => true,
74
+ "allow_files" => [],
75
+ "allow_chars" => []
76
+ }
63
77
  },
64
- "tokens" => {
65
- "strategy" => result.strategy.to_s,
66
- "colors_file" => token_paths["colors_file"],
67
- "type_scale_file" => token_paths["type_scale_file"],
68
- "near_match_policy" => overrides.fetch(:near_match_policy, "notify"),
69
- "near_match_threshold" => overrides.fetch(:near_match_threshold, 4)
70
- }
78
+ "tokens" => tokens
71
79
  }
72
80
  }
73
81
 
74
- header(result) + config.to_yaml(line_width: -1) + threshold_footer
82
+ header(result) + config.to_yaml(line_width: -1) + threshold_footer + icons_emoji_footer
75
83
  end
76
84
 
77
85
  def threshold_footer
@@ -86,6 +94,28 @@ module Guardrails
86
94
  YAML
87
95
  end
88
96
 
97
+ def icons_emoji_footer
98
+ <<~YAML
99
+
100
+ # icons.emoji tiers:
101
+ # emoji Extended_Pictographic pictographs plus multi-codepoint
102
+ # sequences (flags, keycaps, ZWJ family, VS16-styled).
103
+ # Colored on every modern OS — always drift.
104
+ # glyphs Monochrome symbols in the Arrows / Misc Technical /
105
+ # Geometric Shapes / Misc Symbols / Dingbats / Misc
106
+ # Symbols & Arrows ranges (→ ✓ ★ ● ⚠). Set glyphs: false
107
+ # if your team accepts → in prose.
108
+ #
109
+ # allow_files skips a file entirely (chat reactions constants,
110
+ # emoji picker data). allow_chars keeps specific codepoints from
111
+ # flagging anywhere, e.g. ["→"].
112
+ #
113
+ # Inline escape for a single line: append `# guardrails-ok: emoji <reason>`
114
+ # (same line or preceding line, works in Ruby / ERB `<%# %>` / JS //
115
+ # / SCSS // / YAML #).
116
+ YAML
117
+ end
118
+
89
119
  def header(result)
90
120
  unset_paths = result.strategy == :raw_hex || result.strategy == :none
91
121
  comment = +"# Generated by `rails guardrails:init` on #{@now.strftime('%Y-%m-%d')}\n"
@@ -6,6 +6,7 @@ require_relative "init/stack_detector"
6
6
  require_relative "init/config_writer"
7
7
  require_relative "init/media_query_scaffolder"
8
8
  require_relative "init/prompter"
9
+ require_relative "tokens"
9
10
 
10
11
  module Guardrails
11
12
  class Init
@@ -34,6 +35,7 @@ module Guardrails
34
35
  # Don't prompt the user if we know we won't write — keeps reruns from
35
36
  # asking questions whose answers will be discarded.
36
37
  overrides = config_writeable? ? collect_overrides : {}
38
+ overrides[:tailwind_config] = detected_tailwind_config
37
39
 
38
40
  written = ConfigWriter.new(@root, output: @output).write(result, overrides: overrides, force: @force)
39
41
  if written
@@ -86,6 +88,17 @@ module Guardrails
86
88
  value.to_s.split(",").map(&:strip).reject(&:empty?)
87
89
  end
88
90
 
91
+ # Relative path of the Tailwind v3 config when one exists somewhere
92
+ # other than the repo root (typically `config/tailwind.config.js`
93
+ # from tailwindcss-rails). Root-level configs are found without
94
+ # configuration, so they're left out of the generated yml.
95
+ def detected_tailwind_config
96
+ found = Tokens::TAILWIND_CONFIG_CANDIDATES.find { |relative| @root.join(relative).exist? }
97
+ return nil if found.nil? || found == Tokens::TAILWIND_CONFIG_CANDIDATES.first
98
+
99
+ found
100
+ end
101
+
89
102
  def scaffold_media_queries
90
103
  file = configured_colors_file
91
104
  status, message = MediaQueryScaffolder.new(file, output: @output).scaffold
@@ -110,6 +123,8 @@ module Guardrails
110
123
  @output.puts " custom-property files: #{result.evidence[:custom_property_files]}"
111
124
  @output.puts " SCSS-variable files: #{result.evidence[:scss_variable_files]}"
112
125
  @output.puts " raw-hex files: #{result.evidence[:raw_hex_files]}"
126
+ tailwind = Tokens::TAILWIND_CONFIG_CANDIDATES.find { |relative| @root.join(relative).exist? }
127
+ @output.puts "Tailwind config: #{tailwind}" if tailwind
113
128
  end
114
129
  end
115
130
  end
@@ -107,7 +107,7 @@
107
107
  </header>
108
108
 
109
109
  <main class="wrap">
110
- <p class="meta"><%= pluralize(findings.length, "finding") %> across <%= pluralize(@categories.length, "category", "categories") %> · <span class="mono"><%= h @root %></span> · generated <%= h @generated_at.strftime("%Y-%m-%d %H:%M") %> by ui_guardrails <%= h Guardrails::VERSION %></p>
110
+ <p class="meta"><%= pluralize(findings.length, "finding") %> across <%= pluralize(@categories.length, "category", "categories") %> · <span class="mono"><%= h @root %></span> · generated <%= h @generated_at.strftime("%Y-%m-%d %H:%M") %> by ui_guardrails <%= h Guardrails::VERSION %><% if muted_note %> · <strong><%= h muted_note %></strong><% end %></p>
111
111
 
112
112
  <%- if @categories.empty? -%>
113
113
  <p class="clean">✓ No findings — the audit is clean.</p>
@@ -34,7 +34,10 @@ module Guardrails
34
34
 
35
35
  SEVERITY_LABEL = { error: "Error", warning: "Warning", suggestion: "Suggestion" }.freeze
36
36
 
37
- def initialize(categories:, root:, generated_at: Time.now)
37
+ # `muted` — severities the run was told not to check (SEVERITY=).
38
+ # Stated on the page so a filtered report can't pass for a full one.
39
+ def initialize(categories:, root:, generated_at: Time.now, muted: [])
40
+ @muted = muted
38
41
  @categories = categories
39
42
  @root = Pathname(root).expand_path
40
43
  @generated_at = generated_at
@@ -74,6 +77,12 @@ module Guardrails
74
77
  end
75
78
  end
76
79
 
80
+ def muted_note
81
+ return nil if @muted.empty?
82
+
83
+ "#{@muted.map { |s| "#{SEVERITY_LABEL[s].downcase}s" }.join(' and ')} not checked"
84
+ end
85
+
77
86
  def severity_count(severity)
78
87
  findings.count { |f| f.severity == severity }
79
88
  end
@@ -13,6 +13,7 @@ require_relative "../a11y_deep"
13
13
  require_relative "../visual_diff"
14
14
  require_relative "summary"
15
15
  require_relative "finding"
16
+ require_relative "severity"
16
17
  require_relative "../configuration"
17
18
 
18
19
  module Guardrails
@@ -33,11 +34,13 @@ module Guardrails
33
34
  TRUTHY = %w[1 true yes].freeze
34
35
 
35
36
  attr_reader :violations, :stimulus, :similarity, :view_components,
36
- :a11y, :patterns, :classitis, :a11y_deep, :visual_diff
37
+ :a11y, :patterns, :classitis, :a11y_deep, :visual_diff, :min_severity
37
38
 
38
39
  # Builds a Run from the documented env vars (SUGGEST, APPLY,
39
40
  # AXE_JSON, VISUAL_DIFF*, SIMILARITY_THRESHOLD, PATTERN_*,
40
- # CLASSITIS_*).
41
+ # CLASSITIS_*, SEVERITY). Raises ArgumentError on a SEVERITY it
42
+ # doesn't recognize — a typo there would otherwise silently
43
+ # un-gate a CI check.
41
44
  def self.from_env(root:, style: nil, env: ENV)
42
45
  # Visual-diff is opt-in (baselines need deliberate setup). Enabled
43
46
  # when either VISUAL_DIFF=1 is set in the env (sidecar mode) or
@@ -66,7 +69,8 @@ module Guardrails
66
69
  new(root: root, style: style,
67
70
  suggest: truthy?(env["SUGGEST"]), apply: truthy?(env["APPLY"]),
68
71
  axe_json: env["AXE_JSON"], visual_diff: visual_diff_on,
69
- similarity: similarity, patterns: patterns, classitis: classitis)
72
+ similarity: similarity, patterns: patterns, classitis: classitis,
73
+ min_severity: Severity.parse(env["SEVERITY"]))
70
74
  end
71
75
 
72
76
  def self.truthy?(value)
@@ -80,7 +84,9 @@ module Guardrails
80
84
  # uncolored output (what JSON mode wants — the body is discarded).
81
85
  def initialize(root:, style: nil, suggest: false, apply: false,
82
86
  axe_json: nil, visual_diff: false,
83
- similarity: {}, patterns: {}, classitis: {})
87
+ similarity: {}, patterns: {}, classitis: {},
88
+ min_severity: Severity::DEFAULT)
89
+ @min_severity = min_severity
84
90
  @root = Pathname(root)
85
91
  @style = style
86
92
  @suggest = suggest
@@ -94,20 +100,35 @@ module Guardrails
94
100
  end
95
101
 
96
102
  def call
97
- @violations = detect(Audit.new(**common, suggest: @suggest, apply: @apply, format: :text))
98
- @stimulus = detect(StimulusAudit.new(**common))
99
- @similarity = detect(PartialSimilarity.new(**common, **@similarity_opts))
100
- @view_components = detect(ViewComponentAudit.new(**common))
103
+ # Detectors whose findings all sit below the severity floor
104
+ # aren't run at all — SEVERITY=error skips the two slowest
105
+ # (similarity, patterns) rather than computing results to
106
+ # discard. Audit and A11yDeep emit mixed severities, so they
107
+ # always run and filter internally.
108
+ @violations = detect(Audit.new(**common, suggest: @suggest, apply: @apply, format: :text,
109
+ min_severity: @min_severity))
110
+ @stimulus = wanted?(:warning) ? detect(StimulusAudit.new(**common)) : StimulusAudit::Result.new(orphaned: [], dead: [])
111
+ @similarity = wanted?(:suggestion) ? detect(PartialSimilarity.new(**common, **@similarity_opts)) : []
112
+ @view_components = if wanted?(:warning) then detect(ViewComponentAudit.new(**common))
113
+ else ViewComponentAudit::Result.new(missing_previews: [], orphan_slots: [])
114
+ end
101
115
  @a11y = detect(A11yAudit.new(**common))
102
- @patterns = detect(CrossCodebasePatterns.new(**common, **@pattern_opts))
103
- @classitis = detect(ClassItis.new(**common, **@classitis_opts))
104
- @a11y_deep_runner = @axe_json ? A11yDeep.new(input: @axe_json, output: @sink, style: @style) : nil
116
+ @patterns = wanted?(:suggestion) ? detect(CrossCodebasePatterns.new(**common, **@pattern_opts)) : []
117
+ @classitis = wanted?(:suggestion) ? detect(ClassItis.new(**common, **@classitis_opts)) : []
118
+ @a11y_deep_runner = if @axe_json
119
+ A11yDeep.new(input: @axe_json, output: @sink, style: @style, min_severity: @min_severity)
120
+ end
105
121
  @a11y_deep = @a11y_deep_runner ? detect(@a11y_deep_runner) : []
106
122
  @visual_diff_runner = @visual_diff_on ? VisualDiff.new(**common) : nil
107
123
  @visual_diff = @visual_diff_runner ? detect(@visual_diff_runner) : []
108
124
  self
109
125
  end
110
126
 
127
+ # Severities this run didn't look at (empty by default).
128
+ def muted_severities
129
+ Severity.muted(@min_severity)
130
+ end
131
+
111
132
  # Every finding as detector-agnostic data (see Report::Finding),
112
133
  # grouped by category and ordered errors → warnings →
113
134
  # suggestions, biggest category first — the same order the
@@ -208,6 +229,10 @@ module Guardrails
208
229
  result
209
230
  end
210
231
 
232
+ def wanted?(severity)
233
+ Severity.include?(severity, @min_severity)
234
+ end
235
+
211
236
  def common
212
237
  { root: @root, output: @sink, style: @style }
213
238
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "summary"
4
+
5
+ module Guardrails
6
+ module Report
7
+ # The severity floor behind SEVERITY=: "only tell me about findings
8
+ # at least this serious". `:suggestion` (the default) is everything;
9
+ # `:warning` mutes suggestions; `:error` mutes warnings too.
10
+ module Severity
11
+ ORDER = Summary::SEVERITY_ORDER
12
+ DEFAULT = :suggestion
13
+
14
+ NAMES = {
15
+ "error" => :error, "errors" => :error,
16
+ "warning" => :warning, "warnings" => :warning,
17
+ "suggestion" => :suggestion, "suggestions" => :suggestion, "suggest" => :suggestion, "all" => :suggestion
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ # nil / blank is "not set", not an error — same blank-env
23
+ # tolerance as the VISUAL_DIFF_* vars.
24
+ def parse(value)
25
+ text = value.to_s.strip.downcase
26
+ return DEFAULT if text.empty?
27
+
28
+ NAMES.fetch(text) do
29
+ raise ArgumentError, "SEVERITY=#{value} isn't a severity. Use error, warning, or suggestion."
30
+ end
31
+ end
32
+
33
+ def include?(severity, floor)
34
+ ORDER.index(severity) <= ORDER.index(floor)
35
+ end
36
+
37
+ # The severities a floor hides, most serious first.
38
+ def muted(floor)
39
+ ORDER.reject { |severity| include?(severity, floor) }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -20,6 +20,15 @@ module Guardrails
20
20
  "app/assets/tailwind/**/*.css"
21
21
  ].freeze
22
22
 
23
+ # Where a Tailwind v3 config lives when `tokens.tailwind_config` isn't
24
+ # set. The repo root is the JS-first convention; `config/` is what
25
+ # `tailwindcss-rails` (the official Rails integration) generates for
26
+ # every Tailwind v3 app. First match wins.
27
+ TAILWIND_CONFIG_CANDIDATES = %w[
28
+ tailwind.config.js
29
+ config/tailwind.config.js
30
+ ].freeze
31
+
23
32
  # Same path-component skip-list as Audit / StackDetector — vendor
24
33
  # stylesheets nested under app/assets/stylesheets/ shouldn't surface
25
34
  # as drift since they're typically third-party.
@@ -59,8 +68,8 @@ module Guardrails
59
68
  # the config no longer matches the preset pattern.
60
69
  @tailwind_preset_hint = nil
61
70
 
62
- file = @root.join("tailwind.config.js")
63
- return [] unless file.exist?
71
+ file = tailwind_config_path
72
+ return [] unless file&.exist?
64
73
 
65
74
  content = File.read(file, encoding: Encoding::UTF_8)
66
75
  entries = TailwindConfigParser.parse(content)
@@ -72,7 +81,7 @@ module Guardrails
72
81
  # tailwind.config.js does `module.exports = { presets: [preset] }`.
73
82
  if entries.empty? && tailwind_uses_presets?(content)
74
83
  @tailwind_preset_hint =
75
- "tailwind.config.js uses a `presets:` import; only the literal config file " \
84
+ "#{file.relative_path_from(@root)} uses a `presets:` import; only the literal config file " \
76
85
  "is parsed (we don't evaluate JS). Define non-color tokens in v4 `@theme` " \
77
86
  "blocks for cross-tool token visibility."
78
87
  end
@@ -91,11 +100,10 @@ module Guardrails
91
100
  def detect_drift(tokens)
92
101
  lookup = tokens.to_h { |t| [HexNormalizer.normalize(t.value), t] }
93
102
  drift = []
94
- definition_files = [colors_file, type_scale_file].compact
103
+ definition_files = [colors_file, type_scale_file, tailwind_config_path].compact
95
104
 
96
105
  stylesheets.each do |file|
97
106
  next if definition_files.include?(file)
98
- next if file == @root.join("tailwind.config.js")
99
107
 
100
108
  raw_content = File.read(file, encoding: Encoding::UTF_8)
101
109
  content = strip_comments(raw_content)
@@ -118,6 +126,19 @@ module Guardrails
118
126
  drift
119
127
  end
120
128
 
129
+ # The Tailwind v3 config to parse, or nil when there isn't one.
130
+ # `tokens.tailwind_config` in guardrails.yml wins (needed for `.cjs` /
131
+ # `.ts` configs or monorepo layouts); otherwise the first existing
132
+ # candidate path is used.
133
+ def tailwind_config_path
134
+ configured = configured_token_file("tailwind_config")
135
+ return configured if configured
136
+
137
+ TAILWIND_CONFIG_CANDIDATES
138
+ .map { |relative| @root.join(relative) }
139
+ .find(&:exist?)
140
+ end
141
+
121
142
  private
122
143
 
123
144
  def tailwind_uses_presets?(content)
@@ -221,12 +242,13 @@ module Guardrails
221
242
  ["tokens.type_scale_file", type_scale_file]
222
243
  ].select { |_key, path| path }
223
244
 
224
- tailwind_path = @root.join("tailwind.config.js")
225
- tailwind_source = tailwind_path.exist? ? tailwind_path : nil
245
+ tailwind_path = tailwind_config_path
246
+ tailwind_source = tailwind_path&.exist? ? tailwind_path : nil
226
247
  all_sources = configured_entries.map { |_, p| p } + [tailwind_source].compact
227
248
 
228
249
  if all_sources.empty?
229
250
  @output.puts "Guardrails tokens: no colors_file, type_scale_file, or tailwind.config.js found"
251
+ @output.puts " → Looked for #{TAILWIND_CONFIG_CANDIDATES.join(' and ')}; set tokens.tailwind_config in guardrails.yml for other locations."
230
252
  return
231
253
  end
232
254
 
@@ -90,7 +90,7 @@ module Guardrails
90
90
  end
91
91
 
92
92
  def open_web_report
93
- path = Report::Html.new(categories: @run.categories, root: @root).write
93
+ path = Report::Html.new(categories: @run.categories, root: @root, muted: @run.muted_severities).write
94
94
  opener = Editor.opener(path.to_s)
95
95
  opened = opener && system(*opener, out: File::NULL, err: File::NULL)
96
96
  @state.notice = "#{opened ? 'Opened' : 'Wrote'} #{path.relative_path_from(Pathname(@root).expand_path)}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Guardrails
4
- VERSION = "1.3.0"
4
+ VERSION = "1.5.0"
5
5
  end
@@ -9,7 +9,7 @@ namespace :guardrails do
9
9
  Guardrails::Init.new(root: root, force: force).run
10
10
  end
11
11
 
12
- desc "Audit views and components for UI drift (SUGGEST=1, APPLY=1, FORMAT=json|html)"
12
+ desc "Audit views and components for UI drift (SUGGEST=1, APPLY=1, FORMAT=json|html, SEVERITY=error|warning)"
13
13
  task :audit do
14
14
  require "guardrails/report/run"
15
15
  require "guardrails/report/style"
@@ -22,7 +22,11 @@ namespace :guardrails do
22
22
  # (see Report::Run). JSON and HTML modes discard the report body,
23
23
  # so they run unstyled.
24
24
  report_style = format == :text ? Guardrails::Report::Style.new(io: $stdout) : nil
25
- run = Guardrails::Report::Run.from_env(root: root, style: report_style).call
25
+ run = begin
26
+ Guardrails::Report::Run.from_env(root: root, style: report_style).call
27
+ rescue ArgumentError => e
28
+ abort e.message
29
+ end
26
30
 
27
31
  if format == :json
28
32
  require "json"
@@ -32,7 +36,7 @@ namespace :guardrails do
32
36
  # CI artifact. OUTPUT= overrides the default tmp/ location.
33
37
  require "guardrails/report/html"
34
38
  output = ENV["OUTPUT"].to_s.strip
35
- path = Guardrails::Report::Html.new(categories: run.categories, root: root)
39
+ path = Guardrails::Report::Html.new(categories: run.categories, root: root, muted: run.muted_severities)
36
40
  .write(output.empty? ? Guardrails::Report::Html::DEFAULT_PATH : output)
37
41
  $stdout.puts "Guardrails audit: #{run.findings.length} findings → #{path}"
38
42
  else
@@ -45,6 +49,13 @@ namespace :guardrails do
45
49
  summary.render
46
50
  $stdout.write run.body
47
51
  summary.render(recap: true)
52
+ # A filtered run that prints "no violations found" must not read
53
+ # as a clean bill of health.
54
+ unless run.muted_severities.empty?
55
+ muted = run.muted_severities.map { |s| "#{s}s" }.join(" and ")
56
+ $stdout.puts ""
57
+ $stdout.puts report_style.colorize("SEVERITY=#{run.min_severity} — #{muted} were not checked.", :dim)
58
+ end
48
59
  end
49
60
 
50
61
  exit 1 if run.failing?
@@ -63,7 +74,7 @@ namespace :guardrails do
63
74
  runner = -> { Guardrails::Report::Run.from_env(root: root, env: env).call }
64
75
  begin
65
76
  Guardrails::TUI.new(runner: runner, root: root).start
66
- rescue Guardrails::TUI::NotInteractive => e
77
+ rescue Guardrails::TUI::NotInteractive, ArgumentError => e
67
78
  abort e.message
68
79
  end
69
80
  end
@@ -96,11 +107,25 @@ namespace :guardrails do
96
107
  exit 1 if runner.any_failing?(findings)
97
108
  end
98
109
 
99
- desc "Generate SVG icon sprite and audit icon usage"
110
+ desc "Generate SVG icon sprite and audit icon usage (SUGGEST=1, FORMAT=json) — exits 1 on inline SVGs, unknown icon refs, or emoji/glyph icons"
100
111
  task :icons do
101
112
  require "guardrails/icons"
113
+ require "stringio"
102
114
  root = defined?(Rails) ? Rails.root : Pathname(Dir.pwd)
103
- Guardrails::Icons.new(root: root).run
115
+ suggest = %w[1 true yes].include?(ENV["SUGGEST"]&.downcase)
116
+ format = ENV["FORMAT"].to_s.downcase == "json" ? :json : :text
117
+
118
+ if format == :json
119
+ # Suppress human-readable output; only JSON goes to stdout.
120
+ require "json"
121
+ result = Guardrails::Icons.new(root: root, output: StringIO.new,
122
+ suggest: suggest, format: :json).run
123
+ $stdout.puts JSON.pretty_generate(result.to_h)
124
+ else
125
+ result = Guardrails::Icons.new(root: root, suggest: suggest).run
126
+ end
127
+
128
+ exit 1 if result.violations?
104
129
  end
105
130
 
106
131
  desc "Audit design tokens and report drift"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ui_guardrails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Athayde
@@ -95,6 +95,7 @@ files:
95
95
  - lib/guardrails/erb_parser.rb
96
96
  - lib/guardrails/hex_normalizer.rb
97
97
  - lib/guardrails/icons.rb
98
+ - lib/guardrails/icons/emoji_scan.rb
98
99
  - lib/guardrails/init.rb
99
100
  - lib/guardrails/init/config_writer.rb
100
101
  - lib/guardrails/init/media_query_scaffolder.rb
@@ -109,6 +110,7 @@ files:
109
110
  - lib/guardrails/report/html.rb
110
111
  - lib/guardrails/report/html/template.html.erb
111
112
  - lib/guardrails/report/run.rb
113
+ - lib/guardrails/report/severity.rb
112
114
  - lib/guardrails/report/style.rb
113
115
  - lib/guardrails/report/summary.rb
114
116
  - lib/guardrails/stimulus_audit.rb