ui_guardrails 1.2.0 → 1.4.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 +30 -2
- data/doc/ROADMAP.md +20 -2
- data/lib/guardrails/a11y_audit.rb +26 -3
- data/lib/guardrails/a11y_deep.rb +32 -4
- data/lib/guardrails/audit.rb +41 -5
- data/lib/guardrails/class_itis.rb +30 -3
- data/lib/guardrails/cross_codebase_patterns.rb +30 -3
- data/lib/guardrails/partial_similarity.rb +30 -3
- data/lib/guardrails/report/finding.rb +63 -0
- data/lib/guardrails/report/html/template.html.erb +237 -0
- data/lib/guardrails/report/html.rb +113 -0
- data/lib/guardrails/report/run.rb +249 -0
- data/lib/guardrails/report/severity.rb +43 -0
- data/lib/guardrails/stimulus_audit.rb +88 -12
- data/lib/guardrails/tui/editor.rb +71 -0
- data/lib/guardrails/tui/keys.rb +101 -0
- data/lib/guardrails/tui/screen.rb +361 -0
- data/lib/guardrails/tui/state.rb +356 -0
- data/lib/guardrails/tui.rb +154 -0
- data/lib/guardrails/version.rb +1 -1
- data/lib/guardrails/view_component_audit.rb +57 -7
- data/lib/guardrails/visual_diff.rb +36 -6
- data/lib/tasks/guardrails.rake +51 -191
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: da306d3808653d6bd263a867d1ba0aaf159a2c3a515a6e1284276cc69e34cc8c
|
|
4
|
+
data.tar.gz: c895cef92d7cc7acf0be8ddf97079a51a9753aa434223fc3ce3e5fe28125cc39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c250a84dfd1ae1ce7ccf39e9e617ede812facb0dfa529bf53ef7bfe2ffd8812327d47409bbaafa4b21a712ae32a6d565ef9fa2c83b5802314793375f5b3208f
|
|
7
|
+
data.tar.gz: 6e97b1ec55de42f6083ee6590c85599445e310d0a8c9bc8d12bce1dec41f2c3becadb1a9955b4b799112e52b754657f0b6bdb0fe3bf3db036a119b0278d8eea7
|
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
|
|
|
@@ -92,6 +92,7 @@ APPLY=1 bundle exec rake guardrails:audit
|
|
|
92
92
|
|---|---|
|
|
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
|
+
| `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). |
|
|
95
96
|
| `guardrails:icons` | Generates an SVG sprite from `app/assets/images/icons/`, flags inline `<svg>` in views, reports unused icons. |
|
|
96
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. |
|
|
97
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. |
|
|
@@ -195,7 +196,22 @@ Every task prints a human-readable text report by default and exits 1 when viola
|
|
|
195
196
|
FORMAT=json bundle exec rake guardrails:audit > findings.json
|
|
196
197
|
```
|
|
197
198
|
|
|
198
|
-
The JSON payload has a `summary:` block with finding counts per category plus per-detector arrays — see
|
|
199
|
+
The JSON payload has a `summary:` block with finding counts per category plus per-detector arrays — see `Guardrails::Report::Run#to_h` for the exact shape.
|
|
200
|
+
|
|
201
|
+
### Browsing findings
|
|
202
|
+
|
|
203
|
+
On a real codebase the text report is long (one of our test apps produces 981 findings). Two ways to navigate it instead of scrolling it:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
bundle exec rake guardrails:tui # interactive, in the terminal
|
|
207
|
+
FORMAT=html bundle exec rake guardrails:audit # one self-contained HTML file
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**`guardrails:tui`** opens on the same severity roll-up the text report leads with. `⏎` drills in — category → findings → a single finding with its suggestion, every location, and the source around it. `tab` regroups the whole thing by file (the files with the most findings first), `/` filters across category, title, suggestion, and path, `o` opens the highlighted location in your editor at the right line, `w` writes and opens the HTML report, `r` re-runs the audit, `?` lists the rest. It takes the same env vars as `guardrails:audit` except `APPLY` and `SUGGEST` — browsing never rewrites files. No new dependencies: it's built on Ruby's bundled `io/console`.
|
|
211
|
+
|
|
212
|
+
The editor is `$GUARDRAILS_EDITOR`, then `$VISUAL`, then `$EDITOR`. VS Code-family, Zed, Sublime, TextMate, JetBrains, Helix, and vim/nvim/nano/emacs all open at the finding's line; anything else gets the file path. With no editor set, it falls back to `open` / `xdg-open`.
|
|
213
|
+
|
|
214
|
+
**`FORMAT=html`** writes `tmp/guardrails/audit.html` (override with `OUTPUT=path`): the roll-up, every finding with its suggestion, locations, and snippet, a live filter, and severity toggles. Everything is inline — no server, no assets, no network — so it works opened from disk or uploaded as a CI artifact. File locations become editor links once you pick your editor from the menu in the header (remembered per browser). Note the file embeds absolute paths from the machine that generated it; that's what makes the editor links work.
|
|
199
215
|
|
|
200
216
|
### Common env vars
|
|
201
217
|
|
|
@@ -203,7 +219,10 @@ The JSON payload has a `summary:` block with finding counts per category plus pe
|
|
|
203
219
|
|---|---|
|
|
204
220
|
| `SUGGEST=1` | Write the markdown checklist alongside the text report. |
|
|
205
221
|
| `APPLY=1` | Auto-fix raw_color + tailwind_arbitrary where tokens match. |
|
|
222
|
+
| `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. |
|
|
206
223
|
| `FORMAT=json` | Emit one JSON document to stdout (all other audit output is suppressed). |
|
|
224
|
+
| `FORMAT=html` / `OUTPUT=path` | Write the self-contained HTML report (default `tmp/guardrails/audit.html`). |
|
|
225
|
+
| `GUARDRAILS_EDITOR=cmd` | Editor `guardrails:tui` opens locations in. Falls back to `$VISUAL`, then `$EDITOR`. |
|
|
207
226
|
| `FORCE=1` | Bypass `init`'s refuse-to-overwrite default. |
|
|
208
227
|
| `AXE_JSON=path` | Fold axe-core findings into the unified report. |
|
|
209
228
|
| `VISUAL_DIFF=1` | Fold visual-diff findings into `guardrails:audit`. Embedded installs can flip this on permanently via `Guardrails.configure { \|c\| c.visual_diff.enabled = true }`. |
|
|
@@ -224,6 +243,15 @@ The audit task is a single shell command:
|
|
|
224
243
|
run: bundle exec rake guardrails:audit
|
|
225
244
|
```
|
|
226
245
|
|
|
246
|
+
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:
|
|
247
|
+
|
|
248
|
+
```yaml
|
|
249
|
+
- name: Guardrails audit (errors only)
|
|
250
|
+
run: bundle exec rake guardrails:audit SEVERITY=error
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`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.
|
|
254
|
+
|
|
227
255
|
For richer integration:
|
|
228
256
|
|
|
229
257
|
```yaml
|
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.4.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).
|
|
4
|
+
**Last updated:** 2026-09-19
|
|
5
5
|
|
|
6
6
|
## Context
|
|
7
7
|
|
|
@@ -116,6 +116,24 @@ 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
|
+
| 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. |
|
|
133
|
+
| 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. |
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
119
137
|
## Decisions Captured
|
|
120
138
|
|
|
121
139
|
| Question | Decision |
|
|
@@ -4,6 +4,7 @@ require "pathname"
|
|
|
4
4
|
require "set"
|
|
5
5
|
require_relative "erb_parser"
|
|
6
6
|
require_relative "report/style"
|
|
7
|
+
require_relative "report/finding"
|
|
7
8
|
|
|
8
9
|
module Guardrails
|
|
9
10
|
# Static a11y checks that don't require a browser — element-level rules
|
|
@@ -43,8 +44,32 @@ module Guardrails
|
|
|
43
44
|
findings
|
|
44
45
|
end
|
|
45
46
|
|
|
47
|
+
# Detector-agnostic view of `findings` (see Report::Finding).
|
|
48
|
+
def categories(findings)
|
|
49
|
+
return [] if findings.empty?
|
|
50
|
+
|
|
51
|
+
[Report::Category.new(
|
|
52
|
+
name: "a11y (static)", severity: :error, framing: FRAMING.join(" "),
|
|
53
|
+
findings: findings.map { |f|
|
|
54
|
+
Report::Finding.new(
|
|
55
|
+
category: "a11y (static)", severity: :error,
|
|
56
|
+
title: "#{f.rule}: #{f.snippet.to_s[0, 60]}",
|
|
57
|
+
suggestion: SUGGESTION_FOR_RULE[f.rule.to_s],
|
|
58
|
+
locations: [Report::Location.new(file: f.file, line: f.line, column: f.column)],
|
|
59
|
+
snippet: f.snippet
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
)]
|
|
63
|
+
end
|
|
64
|
+
|
|
46
65
|
private
|
|
47
66
|
|
|
67
|
+
FRAMING = [
|
|
68
|
+
"Element-level a11y rules answerable from view source — missing alt text,",
|
|
69
|
+
"unnamed buttons, unlabeled inputs, link without name. Full WCAG coverage",
|
|
70
|
+
"needs runtime checks; layer axe-core via AXE_JSON= for that."
|
|
71
|
+
].freeze
|
|
72
|
+
|
|
48
73
|
def view_files
|
|
49
74
|
SCAN_PATTERNS
|
|
50
75
|
.flat_map { |pattern| Dir.glob(@root.join(pattern)) }
|
|
@@ -249,9 +274,7 @@ module Guardrails
|
|
|
249
274
|
noun = findings.length == 1 ? "issue" : "issues"
|
|
250
275
|
@output.puts ""
|
|
251
276
|
@output.puts @style.section_heading(:error, "a11y (#{findings.length} static #{noun})")
|
|
252
|
-
@output.puts "
|
|
253
|
-
@output.puts " unnamed buttons, unlabeled inputs, link without name. Full WCAG coverage"
|
|
254
|
-
@output.puts " needs runtime checks; layer axe-core via AXE_JSON= for that."
|
|
277
|
+
FRAMING.each { |line| @output.puts " #{line}" }
|
|
255
278
|
|
|
256
279
|
findings.each do |f|
|
|
257
280
|
@output.puts ""
|
data/lib/guardrails/a11y_deep.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "json"
|
|
|
4
4
|
require "pathname"
|
|
5
5
|
require "set"
|
|
6
6
|
require_relative "report/style"
|
|
7
|
+
require_relative "report/finding"
|
|
8
|
+
require_relative "report/severity"
|
|
7
9
|
|
|
8
10
|
module Guardrails
|
|
9
11
|
# Consumes axe-core JSON output and folds the findings into Guardrails'
|
|
@@ -38,7 +40,9 @@ module Guardrails
|
|
|
38
40
|
# `failing_impacts:` if your rule pack emits custom severities.
|
|
39
41
|
DEFAULT_FAILING_IMPACTS = %w[minor moderate serious critical].freeze
|
|
40
42
|
|
|
41
|
-
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
|
|
42
46
|
@input = input
|
|
43
47
|
@output = output
|
|
44
48
|
@failing_impacts = Set.new(failing_impacts.map(&:to_s))
|
|
@@ -46,7 +50,7 @@ module Guardrails
|
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
def run
|
|
49
|
-
findings = parse_input
|
|
53
|
+
findings = parse_input.select { |f| Report::Severity.include?(impact_to_severity(f.impact), @min_severity) }
|
|
50
54
|
print_report(findings)
|
|
51
55
|
findings
|
|
52
56
|
end
|
|
@@ -64,8 +68,33 @@ module Guardrails
|
|
|
64
68
|
findings.any? { |f| @failing_impacts.include?(f.impact.to_s) }
|
|
65
69
|
end
|
|
66
70
|
|
|
71
|
+
# Detector-agnostic view of `findings` (see Report::Finding). These
|
|
72
|
+
# come from a live page, not a source file, so there are no
|
|
73
|
+
# locations — url and selector ride along as details. Severity is
|
|
74
|
+
# per finding, mapped from axe's impact.
|
|
75
|
+
def categories(findings)
|
|
76
|
+
return [] if findings.empty?
|
|
77
|
+
|
|
78
|
+
[Report::Category.new(
|
|
79
|
+
name: "a11y (deep)", severity: :error, framing: FRAMING.join(" "),
|
|
80
|
+
findings: findings.map { |f|
|
|
81
|
+
Report::Finding.new(
|
|
82
|
+
category: "a11y (deep)", severity: impact_to_severity(f.impact),
|
|
83
|
+
title: "[#{f.impact || 'unknown'}] #{f.rule}: #{f.description}",
|
|
84
|
+
suggestion: f.help_url && "see #{f.help_url}",
|
|
85
|
+
details: [["url", f.url], ["selector", f.selector], ["help", f.help_url]].reject { |_, v| v.nil? }
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
)]
|
|
89
|
+
end
|
|
90
|
+
|
|
67
91
|
private
|
|
68
92
|
|
|
93
|
+
FRAMING = [
|
|
94
|
+
"Runtime accessibility issues axe-core caught against your live pages.",
|
|
95
|
+
"Each links to dequeuniversity.com for the canonical remediation."
|
|
96
|
+
].freeze
|
|
97
|
+
|
|
69
98
|
def parse_input
|
|
70
99
|
raw = @input.is_a?(Hash) || @input.is_a?(Array) ? @input : JSON.parse(File.read(@input.to_s, encoding: Encoding::UTF_8))
|
|
71
100
|
parse(raw)
|
|
@@ -110,8 +139,7 @@ module Guardrails
|
|
|
110
139
|
:error,
|
|
111
140
|
"a11y deep (#{findings.length} #{noun} from axe-core)"
|
|
112
141
|
)
|
|
113
|
-
@output.puts "
|
|
114
|
-
@output.puts " Each links to dequeuniversity.com for the canonical remediation."
|
|
142
|
+
FRAMING.each { |line| @output.puts " #{line}" }
|
|
115
143
|
|
|
116
144
|
grouped.each do |url, page_findings|
|
|
117
145
|
@output.puts ""
|
data/lib/guardrails/audit.rb
CHANGED
|
@@ -6,6 +6,8 @@ require "stringio"
|
|
|
6
6
|
require "yaml"
|
|
7
7
|
require_relative "erb_parser"
|
|
8
8
|
require_relative "report/style"
|
|
9
|
+
require_relative "report/finding"
|
|
10
|
+
require_relative "report/severity"
|
|
9
11
|
|
|
10
12
|
module Guardrails
|
|
11
13
|
class Audit
|
|
@@ -70,7 +72,9 @@ module Guardrails
|
|
|
70
72
|
flood-color lighting-color stop-color
|
|
71
73
|
].freeze
|
|
72
74
|
|
|
73
|
-
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
|
|
74
78
|
@root = Pathname(root)
|
|
75
79
|
@output = output
|
|
76
80
|
@suggest = suggest
|
|
@@ -81,13 +85,44 @@ module Guardrails
|
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
def run
|
|
84
|
-
|
|
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
|
|
85
94
|
print_report(violations)
|
|
86
95
|
remaining = @apply ? apply_auto_fixes(violations) : violations
|
|
87
96
|
write_suggestions(remaining) if @suggest
|
|
88
97
|
remaining
|
|
89
98
|
end
|
|
90
99
|
|
|
100
|
+
# Detector-agnostic view of `violations` (see Report::Finding) for
|
|
101
|
+
# front-ends that list and link findings rather than print them.
|
|
102
|
+
# One Category per violation type, in the text report's order.
|
|
103
|
+
def categories(violations)
|
|
104
|
+
by_type = violations.group_by(&:type)
|
|
105
|
+
(TYPE_ORDER + (by_type.keys - TYPE_ORDER)).filter_map do |type|
|
|
106
|
+
list = by_type[type]
|
|
107
|
+
next if list.nil? || list.empty?
|
|
108
|
+
|
|
109
|
+
severity = SEVERITY_FOR_TYPE.fetch(type, :warning)
|
|
110
|
+
auto_fix = AUTO_FIXABLE_TYPES.include?(type)
|
|
111
|
+
Report::Category.new(
|
|
112
|
+
name: type.to_s, severity: severity, framing: FRAMING_FOR_TYPE[type], auto_fix: auto_fix,
|
|
113
|
+
findings: list.map { |v|
|
|
114
|
+
Report::Finding.new(
|
|
115
|
+
category: type.to_s, severity: severity, auto_fix: auto_fix,
|
|
116
|
+
title: "#{type}: #{format_value(v)}",
|
|
117
|
+
suggestion: suggestion_for_violation(v),
|
|
118
|
+
locations: [Report::Location.new(file: v.file, line: v.line, column: v.column)],
|
|
119
|
+
snippet: v.snippet
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
91
126
|
private
|
|
92
127
|
|
|
93
128
|
def load_audit_config
|
|
@@ -459,10 +494,8 @@ module Guardrails
|
|
|
459
494
|
# (a suggestion-shaped warning) and a11y (errors but grouped
|
|
460
495
|
# separately because A11yAudit owns them — the audit rake task
|
|
461
496
|
# threads them in via this same method).
|
|
462
|
-
type_order = %i[inline_style raw_color tailwind_arbitrary helper_recommended
|
|
463
|
-
image_alt button_name link_name input_label]
|
|
464
497
|
by_type = violations.group_by(&:type)
|
|
465
|
-
ordered =
|
|
498
|
+
ordered = TYPE_ORDER + (by_type.keys - TYPE_ORDER)
|
|
466
499
|
|
|
467
500
|
ordered.each do |type|
|
|
468
501
|
list = by_type[type] || []
|
|
@@ -472,6 +505,9 @@ module Guardrails
|
|
|
472
505
|
end
|
|
473
506
|
end
|
|
474
507
|
|
|
508
|
+
TYPE_ORDER = %i[inline_style raw_color tailwind_arbitrary helper_recommended
|
|
509
|
+
image_alt button_name link_name input_label].freeze
|
|
510
|
+
|
|
475
511
|
SEVERITY_FOR_TYPE = {
|
|
476
512
|
inline_style: :warning,
|
|
477
513
|
raw_color: :error,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require_relative "erb_parser"
|
|
5
5
|
require_relative "report/style"
|
|
6
|
+
require_relative "report/finding"
|
|
6
7
|
|
|
7
8
|
module Guardrails
|
|
8
9
|
# Finds repeating "class soup" — the same long class list applied to
|
|
@@ -98,8 +99,36 @@ module Guardrails
|
|
|
98
99
|
.sort_by { |c| [-c.count, -c.class_count] }
|
|
99
100
|
end
|
|
100
101
|
|
|
102
|
+
# Detector-agnostic view of `clusters` (see Report::Finding). One
|
|
103
|
+
# finding per cluster, carrying every occurrence and the full
|
|
104
|
+
# class list (the text report truncates both for terminal width).
|
|
105
|
+
def categories(clusters)
|
|
106
|
+
return [] if clusters.empty?
|
|
107
|
+
|
|
108
|
+
[Report::Category.new(
|
|
109
|
+
name: "class-itis", severity: :suggestion, framing: framing_lines.join(" "),
|
|
110
|
+
findings: clusters.map { |cluster|
|
|
111
|
+
Report::Finding.new(
|
|
112
|
+
category: "class-itis", severity: :suggestion,
|
|
113
|
+
title: "<#{cluster.tag}> with #{cluster.class_count} classes, #{cluster.count} occurrences",
|
|
114
|
+
suggestion: suggestion_for(cluster),
|
|
115
|
+
locations: cluster.occurrences.map { |occ| Report::Location.new(file: occ.file, line: occ.line, column: occ.column) },
|
|
116
|
+
details: [["class", cluster.classes.join(" ")]]
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
)]
|
|
120
|
+
end
|
|
121
|
+
|
|
101
122
|
private
|
|
102
123
|
|
|
124
|
+
def framing_lines
|
|
125
|
+
[
|
|
126
|
+
"The same multi-class list applied to the same tag in many places —",
|
|
127
|
+
"classic AI-paste pattern. Consider extracting a shared component or",
|
|
128
|
+
"an @apply rule. Threshold: >= #{@min_classes} classes, >= #{@min_occurrences} occurrences."
|
|
129
|
+
]
|
|
130
|
+
end
|
|
131
|
+
|
|
103
132
|
def view_files
|
|
104
133
|
paths = VIEW_PATTERNS.flat_map { |g| Dir.glob(@root.join(g)) }.map { |p| Pathname(p) }.uniq
|
|
105
134
|
paths.reject { |p| ignored?(p) }
|
|
@@ -174,9 +203,7 @@ module Guardrails
|
|
|
174
203
|
:suggestion,
|
|
175
204
|
"class-itis (#{clusters.length} #{noun}, #{total_occurrences} occurrences)"
|
|
176
205
|
)
|
|
177
|
-
@output.puts "
|
|
178
|
-
@output.puts " classic AI-paste pattern. Consider extracting a shared component or"
|
|
179
|
-
@output.puts " an @apply rule. Threshold: >= #{@min_classes} classes, >= #{@min_occurrences} occurrences."
|
|
206
|
+
framing_lines.each { |line| @output.puts " #{line}" }
|
|
180
207
|
|
|
181
208
|
clusters.each do |cluster|
|
|
182
209
|
@output.puts ""
|
|
@@ -5,6 +5,7 @@ require "digest"
|
|
|
5
5
|
require "set"
|
|
6
6
|
require_relative "erb_parser"
|
|
7
7
|
require_relative "report/style"
|
|
8
|
+
require_relative "report/finding"
|
|
8
9
|
|
|
9
10
|
module Guardrails
|
|
10
11
|
# Finds recurring structural patterns across the codebase — element
|
|
@@ -98,8 +99,36 @@ module Guardrails
|
|
|
98
99
|
dedupe_nested(patterns)
|
|
99
100
|
end
|
|
100
101
|
|
|
102
|
+
# Detector-agnostic view of `patterns` (see Report::Finding). One
|
|
103
|
+
# finding per shape, carrying every occurrence — the text report
|
|
104
|
+
# caps the list for terminal width; this doesn't.
|
|
105
|
+
def categories(patterns)
|
|
106
|
+
return [] if patterns.empty?
|
|
107
|
+
|
|
108
|
+
[Report::Category.new(
|
|
109
|
+
name: "cross-codebase patterns", severity: :suggestion, framing: framing_lines.join(" "),
|
|
110
|
+
findings: patterns.map { |pattern|
|
|
111
|
+
Report::Finding.new(
|
|
112
|
+
category: "cross-codebase patterns", severity: :suggestion,
|
|
113
|
+
title: "shape: #{truncate_shape(pattern.shape)} (#{pattern.size} elements, #{pattern.count} occurrences)",
|
|
114
|
+
suggestion: suggestion_for(pattern),
|
|
115
|
+
locations: pattern.occurrences.map { |occ| Report::Location.new(file: occ.file, line: occ.line, column: occ.column) },
|
|
116
|
+
details: [["shape", pattern.shape]]
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
)]
|
|
120
|
+
end
|
|
121
|
+
|
|
101
122
|
private
|
|
102
123
|
|
|
124
|
+
def framing_lines
|
|
125
|
+
[
|
|
126
|
+
"These element subtrees repeat #{@min_occurrences}+ times across your views and",
|
|
127
|
+
"components. Each is a candidate for extracting into a shared partial or",
|
|
128
|
+
"ViewComponent. Threshold: >= #{@min_size} elements, >= #{@min_occurrences} occurrences."
|
|
129
|
+
]
|
|
130
|
+
end
|
|
131
|
+
|
|
103
132
|
# Drop redundant inner shapes. When a table repeats N times, three
|
|
104
133
|
# patterns end up with identical counts:
|
|
105
134
|
#
|
|
@@ -222,9 +251,7 @@ module Guardrails
|
|
|
222
251
|
:suggestion,
|
|
223
252
|
"cross-codebase patterns (#{patterns.length} #{noun}, #{total_occurrences} occurrences)"
|
|
224
253
|
)
|
|
225
|
-
@output.puts "
|
|
226
|
-
@output.puts " components. Each is a candidate for extracting into a shared partial or"
|
|
227
|
-
@output.puts " ViewComponent. Threshold: >= #{@min_size} elements, >= #{@min_occurrences} occurrences."
|
|
254
|
+
framing_lines.each { |line| @output.puts " #{line}" }
|
|
228
255
|
|
|
229
256
|
patterns.each do |pattern|
|
|
230
257
|
@output.puts ""
|
|
@@ -4,6 +4,7 @@ require "pathname"
|
|
|
4
4
|
require "set"
|
|
5
5
|
require_relative "erb_parser"
|
|
6
6
|
require_relative "report/style"
|
|
7
|
+
require_relative "report/finding"
|
|
7
8
|
|
|
8
9
|
module Guardrails
|
|
9
10
|
class PartialSimilarity
|
|
@@ -176,8 +177,36 @@ module Guardrails
|
|
|
176
177
|
groups.sort_by { |g| -g[:files].size }
|
|
177
178
|
end
|
|
178
179
|
|
|
180
|
+
# Detector-agnostic view of `findings` (see Report::Finding). One
|
|
181
|
+
# finding per pair — the unit the summary counts — rather than per
|
|
182
|
+
# connected group the way the text report condenses them.
|
|
183
|
+
def categories(findings)
|
|
184
|
+
return [] if findings.empty?
|
|
185
|
+
|
|
186
|
+
[Report::Category.new(
|
|
187
|
+
name: "similar partials", severity: :suggestion, framing: framing_lines.join(" "),
|
|
188
|
+
findings: findings.map { |f|
|
|
189
|
+
Report::Finding.new(
|
|
190
|
+
category: "similar partials", severity: :suggestion,
|
|
191
|
+
title: "#{format('%.2f', f.score)} similar: #{f.file_a} ↔ #{f.file_b}",
|
|
192
|
+
suggestion: suggestion_for_pair({ score_max: f.score }),
|
|
193
|
+
locations: [Report::Location.new(file: f.file_a), Report::Location.new(file: f.file_b)],
|
|
194
|
+
details: [["tags", "#{f.tag_count_a} / #{f.tag_count_b}"]]
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
)]
|
|
198
|
+
end
|
|
199
|
+
|
|
179
200
|
private
|
|
180
201
|
|
|
202
|
+
def framing_lines
|
|
203
|
+
[
|
|
204
|
+
"Templates with >= #{@threshold} structural similarity. Likely duplicates;",
|
|
205
|
+
"consider extracting the common shape into a partial or parameterizing",
|
|
206
|
+
"one with locals to subsume the others."
|
|
207
|
+
]
|
|
208
|
+
end
|
|
209
|
+
|
|
181
210
|
def collect_partials
|
|
182
211
|
PARTIAL_PATTERNS
|
|
183
212
|
.flat_map { |pattern| Dir.glob(@root.join(pattern)) }
|
|
@@ -212,9 +241,7 @@ module Guardrails
|
|
|
212
241
|
:suggestion,
|
|
213
242
|
"similar partials (#{groups.length} #{group_noun}, #{findings.length} pairs, #{total_files} files)"
|
|
214
243
|
)
|
|
215
|
-
@output.puts "
|
|
216
|
-
@output.puts " consider extracting the common shape into a partial or parameterizing"
|
|
217
|
-
@output.puts " one with locals to subsume the others."
|
|
244
|
+
framing_lines.each { |line| @output.puts " #{line}" }
|
|
218
245
|
|
|
219
246
|
groups.each do |group|
|
|
220
247
|
@output.puts ""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Guardrails
|
|
4
|
+
module Report
|
|
5
|
+
# Where a finding lives. `line` / `column` are nil for whole-file
|
|
6
|
+
# findings (a dead Stimulus controller, a component with no preview).
|
|
7
|
+
Location = Struct.new(:file, :line, :column, keyword_init: true) do
|
|
8
|
+
def to_s
|
|
9
|
+
[file, line, column].compact.join(":")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# One detector-agnostic finding. Every detector has its own result
|
|
14
|
+
# struct shaped around what it measures (Violation, Cluster,
|
|
15
|
+
# Pattern, …); Finding is the common denominator that front-ends
|
|
16
|
+
# which list, filter, and link findings render from, so they never
|
|
17
|
+
# need to know which detector produced what.
|
|
18
|
+
#
|
|
19
|
+
# `locations` is plural because the suggestion-tier detectors
|
|
20
|
+
# report one finding spanning many places (a class list repeated
|
|
21
|
+
# in 9 files is one finding, not 9). `details` is an ordered list
|
|
22
|
+
# of [label, value] pairs for detector-specific extras — class
|
|
23
|
+
# list, axe help URL, diff image path.
|
|
24
|
+
Finding = Struct.new(:category, :severity, :title, :suggestion, :locations,
|
|
25
|
+
:snippet, :details, :auto_fix, keyword_init: true) do
|
|
26
|
+
def locations
|
|
27
|
+
self[:locations] || []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def details
|
|
31
|
+
self[:details] || []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def location
|
|
35
|
+
locations.first
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def files
|
|
39
|
+
locations.map(&:file).compact.uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h
|
|
43
|
+
{
|
|
44
|
+
category: category, severity: severity, title: title, suggestion: suggestion,
|
|
45
|
+
locations: locations.map(&:to_h), snippet: snippet,
|
|
46
|
+
details: details.to_h, auto_fix: auto_fix || false
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A named group of findings plus the framing paragraph that
|
|
52
|
+
# explains what the rule catches. `name` matches the category on
|
|
53
|
+
# the corresponding Summary::Entry, so the rollup and the detail
|
|
54
|
+
# views line up. `severity` is the category's headline severity;
|
|
55
|
+
# individual findings may differ (deep a11y maps axe impact per
|
|
56
|
+
# finding).
|
|
57
|
+
Category = Struct.new(:name, :severity, :framing, :findings, :auto_fix, keyword_init: true) do
|
|
58
|
+
def count
|
|
59
|
+
findings.length
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|