ui_guardrails 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f34f9ff613f4b10edd128972a3ee8b268e82fee79abc499d7554d48bf834eec
4
- data.tar.gz: 937199143fb681851d6493dc666ce9a381d4db83b247059e00e9b0dd91b8a901
3
+ metadata.gz: a78df8763f35470a40ab6c8c8dcc6daf1351d35e733772a33afde5d1b7af1a54
4
+ data.tar.gz: a1f8aa3366f9a350673c55ff4200716e6c37db2f777592b914ada3236f67682e
5
5
  SHA512:
6
- metadata.gz: 8e6d87b9e1bac27c0f79f941343855806c0fb3a3c55b0191cfeb1e184a0b4b5cc6b09eb88ce4df2512ac646c1572f528da269dedacc56fd269f8a4b3ac7630eb
7
- data.tar.gz: d50217306f64e883988ad9184adde4d02e274fc8262560aeab3b8a0c9eecd573bae847006c0a3383222020ab3d9281ba96d1720ea9019f17c3491bf21fcf08e1
6
+ metadata.gz: f16927ab59ea1e9e89fe51203d3a0ca0f14331956fdcce4ca1e957642579129ba143651d066d9bb763f85fb4b2f3e5afd93a657a9266550b8435e0159478c659
7
+ data.tar.gz: 7f4dfebecd72b184a84258ca90f48f978c603eb01739d3217f8cad8b24d0ab7991f2d8a79dc53834e7e67002b0da74219cf68e3aee0ca60c344febb1e3e722e0
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.2.0 — V0 + V1 + V2 complete, published on [RubyGems.org as `ui_guardrails`](https://rubygems.org/gems/ui_guardrails). The Ruby module stays `Guardrails` (so `require "guardrails"` is unchanged) — only the gem package name on rubygems carries the `ui_` prefix, to clear RubyGems' similarity rule against the unrelated [`guard-rails`](https://rubygems.org/gems/guard-rails) gem. See [`doc/ROADMAP.md`](doc/ROADMAP.md) for status, [`CHANGELOG.md`](CHANGELOG.md) for the full naming rationale.
7
+ **Current release:** 1.3.0 — V0 + V1 + V2 complete, published on [RubyGems.org as `ui_guardrails`](https://rubygems.org/gems/ui_guardrails). The Ruby module stays `Guardrails` (so `require "guardrails"` is unchanged) — only the gem package name on rubygems carries the `ui_` prefix, to clear RubyGems' similarity rule against the unrelated [`guard-rails`](https://rubygems.org/gems/guard-rails) gem. See [`doc/ROADMAP.md`](doc/ROADMAP.md) for status, [`CHANGELOG.md`](CHANGELOG.md) for the full naming rationale.
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 the rake task source for the exact shape.
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
 
@@ -204,6 +220,8 @@ The JSON payload has a `summary:` block with finding counts per category plus pe
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. |
206
222
  | `FORMAT=json` | Emit one JSON document to stdout (all other audit output is suppressed). |
223
+ | `FORMAT=html` / `OUTPUT=path` | Write the self-contained HTML report (default `tmp/guardrails/audit.html`). |
224
+ | `GUARDRAILS_EDITOR=cmd` | Editor `guardrails:tui` opens locations in. Falls back to `$VISUAL`, then `$EDITOR`. |
207
225
  | `FORCE=1` | Bypass `init`'s refuse-to-overwrite default. |
208
226
  | `AXE_JSON=path` | Fold axe-core findings into the unified report. |
209
227
  | `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 }`. |
@@ -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 " Element-level a11y rules answerable from view source — missing alt text,"
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 ""
@@ -4,6 +4,7 @@ require "json"
4
4
  require "pathname"
5
5
  require "set"
6
6
  require_relative "report/style"
7
+ require_relative "report/finding"
7
8
 
8
9
  module Guardrails
9
10
  # Consumes axe-core JSON output and folds the findings into Guardrails'
@@ -64,8 +65,33 @@ module Guardrails
64
65
  findings.any? { |f| @failing_impacts.include?(f.impact.to_s) }
65
66
  end
66
67
 
68
+ # Detector-agnostic view of `findings` (see Report::Finding). These
69
+ # come from a live page, not a source file, so there are no
70
+ # locations — url and selector ride along as details. Severity is
71
+ # per finding, mapped from axe's impact.
72
+ def categories(findings)
73
+ return [] if findings.empty?
74
+
75
+ [Report::Category.new(
76
+ name: "a11y (deep)", severity: :error, framing: FRAMING.join(" "),
77
+ findings: findings.map { |f|
78
+ Report::Finding.new(
79
+ category: "a11y (deep)", severity: impact_to_severity(f.impact),
80
+ title: "[#{f.impact || 'unknown'}] #{f.rule}: #{f.description}",
81
+ suggestion: f.help_url && "see #{f.help_url}",
82
+ details: [["url", f.url], ["selector", f.selector], ["help", f.help_url]].reject { |_, v| v.nil? }
83
+ )
84
+ }
85
+ )]
86
+ end
87
+
67
88
  private
68
89
 
90
+ FRAMING = [
91
+ "Runtime accessibility issues axe-core caught against your live pages.",
92
+ "Each links to dequeuniversity.com for the canonical remediation."
93
+ ].freeze
94
+
69
95
  def parse_input
70
96
  raw = @input.is_a?(Hash) || @input.is_a?(Array) ? @input : JSON.parse(File.read(@input.to_s, encoding: Encoding::UTF_8))
71
97
  parse(raw)
@@ -110,8 +136,7 @@ module Guardrails
110
136
  :error,
111
137
  "a11y deep (#{findings.length} #{noun} from axe-core)"
112
138
  )
113
- @output.puts " Runtime accessibility issues axe-core caught against your live pages."
114
- @output.puts " Each links to dequeuniversity.com for the canonical remediation."
139
+ FRAMING.each { |line| @output.puts " #{line}" }
115
140
 
116
141
  grouped.each do |url, page_findings|
117
142
  @output.puts ""
@@ -6,6 +6,7 @@ require "stringio"
6
6
  require "yaml"
7
7
  require_relative "erb_parser"
8
8
  require_relative "report/style"
9
+ require_relative "report/finding"
9
10
 
10
11
  module Guardrails
11
12
  class Audit
@@ -88,6 +89,32 @@ module Guardrails
88
89
  remaining
89
90
  end
90
91
 
92
+ # Detector-agnostic view of `violations` (see Report::Finding) for
93
+ # front-ends that list and link findings rather than print them.
94
+ # One Category per violation type, in the text report's order.
95
+ def categories(violations)
96
+ by_type = violations.group_by(&:type)
97
+ (TYPE_ORDER + (by_type.keys - TYPE_ORDER)).filter_map do |type|
98
+ list = by_type[type]
99
+ next if list.nil? || list.empty?
100
+
101
+ severity = SEVERITY_FOR_TYPE.fetch(type, :warning)
102
+ auto_fix = AUTO_FIXABLE_TYPES.include?(type)
103
+ Report::Category.new(
104
+ name: type.to_s, severity: severity, framing: FRAMING_FOR_TYPE[type], auto_fix: auto_fix,
105
+ findings: list.map { |v|
106
+ Report::Finding.new(
107
+ category: type.to_s, severity: severity, auto_fix: auto_fix,
108
+ title: "#{type}: #{format_value(v)}",
109
+ suggestion: suggestion_for_violation(v),
110
+ locations: [Report::Location.new(file: v.file, line: v.line, column: v.column)],
111
+ snippet: v.snippet
112
+ )
113
+ }
114
+ )
115
+ end
116
+ end
117
+
91
118
  private
92
119
 
93
120
  def load_audit_config
@@ -459,10 +486,8 @@ module Guardrails
459
486
  # (a suggestion-shaped warning) and a11y (errors but grouped
460
487
  # separately because A11yAudit owns them — the audit rake task
461
488
  # 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
489
  by_type = violations.group_by(&:type)
465
- ordered = type_order + (by_type.keys - type_order)
490
+ ordered = TYPE_ORDER + (by_type.keys - TYPE_ORDER)
466
491
 
467
492
  ordered.each do |type|
468
493
  list = by_type[type] || []
@@ -472,6 +497,9 @@ module Guardrails
472
497
  end
473
498
  end
474
499
 
500
+ TYPE_ORDER = %i[inline_style raw_color tailwind_arbitrary helper_recommended
501
+ image_alt button_name link_name input_label].freeze
502
+
475
503
  SEVERITY_FOR_TYPE = {
476
504
  inline_style: :warning,
477
505
  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 " The same multi-class list applied to the same tag in many places —"
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 " These element subtrees repeat #{@min_occurrences}+ times across your views and"
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 " Templates with >= #{@threshold} structural similarity. Likely duplicates;"
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