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 +4 -4
- data/README.md +59 -3
- data/doc/ROADMAP.md +22 -2
- data/lib/guardrails/a11y_deep.rb +5 -2
- data/lib/guardrails/audit.rb +10 -2
- data/lib/guardrails/icons/emoji_scan.rb +367 -0
- data/lib/guardrails/icons.rb +171 -8
- data/lib/guardrails/init/config_writer.rb +39 -9
- data/lib/guardrails/init.rb +15 -0
- data/lib/guardrails/report/html/template.html.erb +1 -1
- data/lib/guardrails/report/html.rb +10 -1
- data/lib/guardrails/report/run.rb +36 -11
- data/lib/guardrails/report/severity.rb +43 -0
- data/lib/guardrails/tokens.rb +29 -7
- data/lib/guardrails/tui.rb +1 -1
- data/lib/guardrails/version.rb +1 -1
- data/lib/tasks/guardrails.rake +31 -6
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b29232255e89ade665ffe0aa02661183fbf71c5918c53704d8241293be757454
|
|
4
|
+
data.tar.gz: 5d3cf78b2091bb6e781f6bd563976c9bc1c583a8269309ce897ed90d8eae3b3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
4
|
-
**Last updated:** 2026-
|
|
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 |
|
data/lib/guardrails/a11y_deep.rb
CHANGED
|
@@ -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
|
data/lib/guardrails/audit.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/guardrails/icons.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
65
|
-
report_inline_svgs(violations)
|
|
142
|
+
inline_violations = audit_inline_svgs
|
|
66
143
|
dead_report = report_dead_icons
|
|
67
|
-
|
|
68
|
-
|
|
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"
|
data/lib/guardrails/init.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 =
|
|
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
|
data/lib/guardrails/tokens.rb
CHANGED
|
@@ -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 =
|
|
63
|
-
return [] unless file
|
|
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
|
-
"
|
|
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 =
|
|
225
|
-
tailwind_source = tailwind_path
|
|
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
|
|
data/lib/guardrails/tui.rb
CHANGED
|
@@ -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)}"
|
data/lib/guardrails/version.rb
CHANGED
data/lib/tasks/guardrails.rake
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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.
|
|
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
|