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.
@@ -0,0 +1,237 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="generator" content="ui_guardrails <%= h Guardrails::VERSION %>">
7
+ <title>Guardrails audit — <%= h project_name %></title>
8
+ <style>
9
+ :root {
10
+ color-scheme: light dark;
11
+ --bg: #fbfaf8; --surface: #ffffff; --ink: #1d1c1a; --muted: #6b6760; --line: #e4e0d8;
12
+ --code-bg: #f4f1ec; --accent: #2b5fd9;
13
+ --error: #c0322b; --error-bg: #fbeceb;
14
+ --warning: #9a6300; --warning-bg: #fbf3e0;
15
+ --suggestion: #1f6f78; --suggestion-bg: #e6f3f4;
16
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
17
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
18
+ }
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ --bg: #161514; --surface: #1f1e1c; --ink: #ece9e3; --muted: #9a958c; --line: #33312d;
22
+ --code-bg: #262422; --accent: #8fb0ff;
23
+ --error: #ff8a80; --error-bg: #3a1f1d;
24
+ --warning: #f0c060; --warning-bg: #36290f;
25
+ --suggestion: #7fd0d8; --suggestion-bg: #14302f;
26
+ }
27
+ }
28
+ * { box-sizing: border-box; }
29
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.55 var(--sans); }
30
+ a { color: var(--accent); }
31
+ code, pre, .loc, .mono { font-family: var(--mono); font-size: 13px; }
32
+ .wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
33
+
34
+ header.top { position: sticky; top: 0; z-index: 2; background: var(--bg); border-bottom: 1px solid var(--line); }
35
+ header.top .wrap { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; padding-top: 12px; padding-bottom: 12px; }
36
+ h1 { font-size: 17px; margin: 0; font-weight: 650; letter-spacing: -0.01em; }
37
+ h1 span { color: var(--muted); font-weight: 400; }
38
+ .controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-left: auto; }
39
+ input[type=search], select {
40
+ font: inherit; font-size: 14px; color: var(--ink); background: var(--surface);
41
+ border: 1px solid var(--line); border-radius: 6px; padding: 5px 9px;
42
+ }
43
+ input[type=search] { width: 220px; }
44
+ .toggle {
45
+ font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; border-radius: 999px; padding: 3px 11px;
46
+ border: 1px solid transparent; color: var(--sev); background: var(--sev-bg);
47
+ }
48
+ .toggle[aria-pressed=false] { background: transparent; border-color: var(--line); color: var(--muted); text-decoration: line-through; }
49
+ .error { --sev: var(--error); --sev-bg: var(--error-bg); }
50
+ .warning { --sev: var(--warning); --sev-bg: var(--warning-bg); }
51
+ .suggestion { --sev: var(--suggestion); --sev-bg: var(--suggestion-bg); }
52
+
53
+ main { padding: 24px 0 56px; }
54
+ .meta { color: var(--muted); font-size: 13px; margin: 0 0 20px; }
55
+
56
+ table.rollup { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
57
+ .rollup th { text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sev); background: var(--sev-bg); padding: 7px 14px; }
58
+ .rollup td { padding: 7px 14px; border-top: 1px solid var(--line); }
59
+ .rollup td.count { text-align: right; font-variant-numeric: tabular-nums; width: 1%; white-space: nowrap; }
60
+ .rollup td.flag { color: var(--muted); font-size: 13px; width: 1%; white-space: nowrap; }
61
+ .rollup a { color: inherit; text-decoration: none; font-weight: 550; }
62
+ .rollup a:hover { text-decoration: underline; }
63
+
64
+ section.category { margin-top: 40px; scroll-margin-top: 70px; }
65
+ section.category h2 { font-size: 16px; margin: 0 0 4px; display: flex; align-items: baseline; gap: 10px; }
66
+ .badge { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sev); background: var(--sev-bg); border-radius: 4px; padding: 2px 7px; }
67
+ .tally { color: var(--muted); font-weight: 400; font-size: 14px; }
68
+ .framing { color: var(--muted); margin: 0 0 14px; max-width: 70ch; }
69
+
70
+ .finding { background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--sev); border-radius: 6px; padding: 11px 14px; margin-top: 8px; }
71
+ .finding h3 { font: 600 13.5px/1.45 var(--mono); margin: 0; overflow-wrap: anywhere; }
72
+ .suggest { margin: 5px 0 0; }
73
+ .suggest::before { content: "→ "; color: var(--suggestion); font-weight: 700; }
74
+ .locs { list-style: none; margin: 7px 0 0; padding: 0; }
75
+ .loc { color: var(--muted); overflow-wrap: anywhere; }
76
+ a.loc[href] { color: var(--accent); }
77
+ details.more summary { cursor: pointer; color: var(--muted); font-size: 13px; margin-top: 3px; }
78
+ pre { background: var(--code-bg); border-radius: 5px; padding: 8px 11px; margin: 9px 0 0; overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
79
+ dl.details { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; margin: 8px 0 0; font-size: 13px; }
80
+ dl.details dt { color: var(--muted); }
81
+ dl.details dd { margin: 0; overflow-wrap: anywhere; }
82
+ footer.credit { border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; text-align: center; padding: 22px 0 36px; }
83
+ footer.credit a { color: inherit; font-weight: 600; }
84
+ footer.credit a:hover { color: var(--accent); }
85
+ .clean, .nomatch { text-align: center; color: var(--muted); padding: 60px 0; }
86
+ [hidden] { display: none !important; }
87
+ </style>
88
+ </head>
89
+ <body>
90
+ <header class="top">
91
+ <div class="wrap">
92
+ <h1>Guardrails audit <span>— <%= h project_name %></span></h1>
93
+ <div class="controls">
94
+ <%- Summary::SEVERITY_ORDER.each do |severity| -%>
95
+ <%- next if severity_count(severity).zero? -%>
96
+ <button type="button" class="toggle <%= severity %>" data-severity="<%= severity %>" aria-pressed="true"><%= h SEVERITY_LABEL[severity] %>s · <%= severity_count(severity) %></button>
97
+ <%- end -%>
98
+ <input type="search" id="filter" placeholder="Filter findings…" aria-label="Filter findings">
99
+ <select id="editor" aria-label="Open file locations in">
100
+ <option value="">Links: off</option>
101
+ <%- EDITOR_URLS.each_key do |name| -%>
102
+ <option><%= h name %></option>
103
+ <%- end -%>
104
+ </select>
105
+ </div>
106
+ </div>
107
+ </header>
108
+
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 %><% if muted_note %> · <strong><%= h muted_note %></strong><% end %></p>
111
+
112
+ <%- if @categories.empty? -%>
113
+ <p class="clean">✓ No findings — the audit is clean.</p>
114
+ <%- else -%>
115
+ <table class="rollup">
116
+ <%- severity_groups.each do |severity, group| -%>
117
+ <tbody class="<%= severity %>" data-severity="<%= severity %>">
118
+ <tr><th colspan="3"><%= h SEVERITY_LABEL[severity] %>s — <%= pluralize(group.sum(&:count), "finding") %></th></tr>
119
+ <%- group.each do |category| -%>
120
+ <tr>
121
+ <td><a href="#<%= anchor(category) %>"><%= h category.name %></a></td>
122
+ <td class="flag"><%= "auto-fix available" if category.auto_fix %></td>
123
+ <td class="count"><%= category.count %></td>
124
+ </tr>
125
+ <%- end -%>
126
+ </tbody>
127
+ <%- end -%>
128
+ </table>
129
+
130
+ <%- @categories.each do |category| -%>
131
+ <section class="category <%= category.severity %>" id="<%= anchor(category) %>" data-severity="<%= category.severity %>">
132
+ <h2><span class="badge"><%= h SEVERITY_LABEL[category.severity] %></span> <%= h category.name %> <span class="tally"><span class="shown"><%= category.count %></span> of <%= category.count %></span></h2>
133
+ <%- if category.framing -%>
134
+ <p class="framing"><%= h category.framing %></p>
135
+ <%- end -%>
136
+ <%- category.findings.each do |finding| -%>
137
+ <article class="finding <%= finding.severity %>" data-severity="<%= finding.severity %>" data-search="<%= h search_text(finding) %>">
138
+ <h3><%= h finding.title %></h3>
139
+ <%- if finding.suggestion -%>
140
+ <p class="suggest"><%= h finding.suggestion %></p>
141
+ <%- end -%>
142
+ <%- unless finding.locations.empty? -%>
143
+ <%- shown, rest = finding.locations.first(8), finding.locations.drop(8) -%>
144
+ <ul class="locs">
145
+ <%- shown.each do |location| -%>
146
+ <li><a class="loc" data-path="<%= h absolute(location) %>" data-line="<%= h location.line %>" data-column="<%= h location.column %>"><%= h location %></a></li>
147
+ <%- end -%>
148
+ </ul>
149
+ <%- unless rest.empty? -%>
150
+ <details class="more">
151
+ <summary>… and <%= rest.length %> more</summary>
152
+ <ul class="locs">
153
+ <%- rest.each do |location| -%>
154
+ <li><a class="loc" data-path="<%= h absolute(location) %>" data-line="<%= h location.line %>" data-column="<%= h location.column %>"><%= h location %></a></li>
155
+ <%- end -%>
156
+ </ul>
157
+ </details>
158
+ <%- end -%>
159
+ <%- end -%>
160
+ <%- if finding.snippet -%>
161
+ <pre><code><%= h finding.snippet %></code></pre>
162
+ <%- end -%>
163
+ <%- unless finding.details.empty? -%>
164
+ <dl class="details">
165
+ <%- finding.details.each do |label, value| -%>
166
+ <dt><%= h label %></dt><dd class="mono"><%= h value %></dd>
167
+ <%- end -%>
168
+ </dl>
169
+ <%- end -%>
170
+ </article>
171
+ <%- end -%>
172
+ </section>
173
+ <%- end -%>
174
+ <p class="nomatch" hidden>No findings match the current filter.</p>
175
+ <%- end -%>
176
+ </main>
177
+
178
+ <footer class="credit">
179
+ <div class="wrap">ui-guardrails — built by <a href="https://meticulous.com" rel="noopener">Meticulous</a> with love</div>
180
+ </footer>
181
+
182
+ <script>
183
+ (function () {
184
+ var EDITORS = <%= editor_urls_json %>;
185
+ var filter = document.getElementById("filter");
186
+ var editor = document.getElementById("editor");
187
+ var toggles = [].slice.call(document.querySelectorAll(".toggle"));
188
+ var off = {};
189
+
190
+ function apply() {
191
+ var needle = filter.value.trim().toLowerCase();
192
+ var any = false;
193
+ [].forEach.call(document.querySelectorAll("section.category"), function (section) {
194
+ var shown = 0;
195
+ [].forEach.call(section.querySelectorAll(".finding"), function (el) {
196
+ var visible = !off[el.dataset.severity] && (!needle || el.dataset.search.indexOf(needle) !== -1);
197
+ el.hidden = !visible;
198
+ if (visible) shown++;
199
+ });
200
+ section.hidden = shown === 0;
201
+ section.querySelector(".shown").textContent = shown;
202
+ if (shown) any = true;
203
+ });
204
+ var nomatch = document.querySelector(".nomatch");
205
+ if (nomatch) nomatch.hidden = any;
206
+ }
207
+
208
+ function link() {
209
+ var template = EDITORS[editor.value];
210
+ [].forEach.call(document.querySelectorAll("a.loc"), function (a) {
211
+ if (!template) { a.removeAttribute("href"); return; }
212
+ a.href = template
213
+ .replace("{path}", encodeURI(a.dataset.path))
214
+ .replace("{line}", a.dataset.line || "1")
215
+ .replace("{column}", a.dataset.column || "1");
216
+ });
217
+ }
218
+
219
+ toggles.forEach(function (button) {
220
+ button.addEventListener("click", function () {
221
+ var severity = button.dataset.severity;
222
+ off[severity] = !off[severity];
223
+ button.setAttribute("aria-pressed", String(!off[severity]));
224
+ apply();
225
+ });
226
+ });
227
+ filter.addEventListener("input", apply);
228
+ editor.addEventListener("change", function () {
229
+ try { localStorage.setItem("guardrails-editor", editor.value); } catch (e) {}
230
+ link();
231
+ });
232
+ try { editor.value = localStorage.getItem("guardrails-editor") || ""; } catch (e) {}
233
+ link();
234
+ })();
235
+ </script>
236
+ </body>
237
+ </html>
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "json"
6
+ require "pathname"
7
+ require_relative "finding"
8
+ require_relative "summary"
9
+ require_relative "../version"
10
+
11
+ module Guardrails
12
+ module Report
13
+ # The audit as one self-contained HTML file: no server, no asset
14
+ # pipeline, no network. Everything (CSS, the small filter script)
15
+ # is inline, so the file works opened from disk, attached to a PR,
16
+ # or published as a CI artifact.
17
+ #
18
+ # Renders from Report::Category / Report::Finding, the same data
19
+ # the TUI browses — the two are views of one audit run.
20
+ class Html
21
+ DEFAULT_PATH = "tmp/guardrails/audit.html"
22
+ TEMPLATE = File.expand_path("html/template.html.erb", __dir__)
23
+
24
+ # "Open in editor" link schemes, chosen client-side and kept in
25
+ # localStorage — the report can't know which editor its reader
26
+ # uses, and a CI artifact is read by more than one person.
27
+ EDITOR_URLS = {
28
+ "VS Code" => "vscode://file/{path}:{line}:{column}",
29
+ "Cursor" => "cursor://file/{path}:{line}:{column}",
30
+ "Zed" => "zed://file/{path}:{line}:{column}",
31
+ "TextMate" => "txmt://open?url=file://{path}&line={line}&column={column}",
32
+ "RubyMine" => "x-mine://open?file={path}&line={line}&column={column}"
33
+ }.freeze
34
+
35
+ SEVERITY_LABEL = { error: "Error", warning: "Warning", suggestion: "Suggestion" }.freeze
36
+
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
41
+ @categories = categories
42
+ @root = Pathname(root).expand_path
43
+ @generated_at = generated_at
44
+ end
45
+
46
+ def render
47
+ ERB.new(File.read(TEMPLATE, encoding: Encoding::UTF_8), trim_mode: "-").result(binding)
48
+ end
49
+
50
+ # Writes the report and returns the absolute path written.
51
+ def write(path = DEFAULT_PATH)
52
+ target = Pathname(path)
53
+ target = @root.join(target) unless target.absolute?
54
+ FileUtils.mkdir_p(target.dirname)
55
+ File.write(target, render)
56
+ target
57
+ end
58
+
59
+ private
60
+
61
+ def h(text)
62
+ ERB::Util.html_escape(text.to_s)
63
+ end
64
+
65
+ def findings
66
+ @categories.flat_map(&:findings)
67
+ end
68
+
69
+ def project_name
70
+ @root.basename.to_s
71
+ end
72
+
73
+ def severity_groups
74
+ Summary::SEVERITY_ORDER.filter_map do |severity|
75
+ group = @categories.select { |c| c.severity == severity }
76
+ [severity, group] unless group.empty?
77
+ end
78
+ end
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
+
86
+ def severity_count(severity)
87
+ findings.count { |f| f.severity == severity }
88
+ end
89
+
90
+ def anchor(category)
91
+ "cat-#{category.name.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-|-\z/, '')}"
92
+ end
93
+
94
+ def absolute(location)
95
+ @root.join(location.file).to_s
96
+ end
97
+
98
+ # Lowercased text the client-side filter matches against.
99
+ def search_text(finding)
100
+ [finding.category, finding.title, finding.suggestion, *finding.files].compact.join(" ").downcase
101
+ end
102
+
103
+ # JSON embedded in a <script> must not be able to close it.
104
+ def editor_urls_json
105
+ JSON.generate(EDITOR_URLS).gsub("</", '<\/')
106
+ end
107
+
108
+ def pluralize(count, singular, plural = "#{singular}s")
109
+ "#{count} #{count == 1 ? singular : plural}"
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "pathname"
5
+ require_relative "../audit"
6
+ require_relative "../stimulus_audit"
7
+ require_relative "../partial_similarity"
8
+ require_relative "../view_component_audit"
9
+ require_relative "../a11y_audit"
10
+ require_relative "../cross_codebase_patterns"
11
+ require_relative "../class_itis"
12
+ require_relative "../a11y_deep"
13
+ require_relative "../visual_diff"
14
+ require_relative "summary"
15
+ require_relative "finding"
16
+ require_relative "severity"
17
+ require_relative "../configuration"
18
+
19
+ module Guardrails
20
+ module Report
21
+ # One full audit pass: runs every detector once and holds the
22
+ # results so any number of front-ends (text report, JSON, and
23
+ # anything interactive) can render from the same data without
24
+ # re-running detectors or re-deriving the summary.
25
+ #
26
+ # Detectors still print their own per-section text; Run captures
27
+ # that into `body` rather than letting it hit the terminal, because
28
+ # the summary has to print first and needs every detector's count.
29
+ #
30
+ # `new` takes plain arguments so it's callable from specs and
31
+ # non-rake entry points; `from_env` is the one place the audit's
32
+ # env-var surface is parsed, shared by every task that runs one.
33
+ class Run
34
+ TRUTHY = %w[1 true yes].freeze
35
+
36
+ attr_reader :violations, :stimulus, :similarity, :view_components,
37
+ :a11y, :patterns, :classitis, :a11y_deep, :visual_diff, :min_severity
38
+
39
+ # Builds a Run from the documented env vars (SUGGEST, APPLY,
40
+ # AXE_JSON, VISUAL_DIFF*, SIMILARITY_THRESHOLD, PATTERN_*,
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.
44
+ def self.from_env(root:, style: nil, env: ENV)
45
+ # Visual-diff is opt-in (baselines need deliberate setup). Enabled
46
+ # when either VISUAL_DIFF=1 is set in the env (sidecar mode) or
47
+ # Guardrails.configuration.visual_diff.enabled was flipped on by a
48
+ # Rails initializer (embedded mode). Env overrides Configuration.
49
+ visual_diff_on = truthy?(env["VISUAL_DIFF"]) || Guardrails.configuration.visual_diff.enabled
50
+ # Strip + reject blank env values — an empty VISUAL_DIFF_DIR would
51
+ # otherwise be applied as snap_diff_dir = "" and glob from the repo
52
+ # root (potentially scanning the whole tree).
53
+ if (dir = env["VISUAL_DIFF_DIR"]) && !dir.strip.empty?
54
+ Guardrails.configure { |c| c.visual_diff.snap_diff_dir = dir.strip }
55
+ end
56
+ if (thr = env["VISUAL_DIFF_THRESHOLD"]) && !thr.strip.empty?
57
+ Guardrails.configure { |c| c.visual_diff.threshold = thr.strip }
58
+ end
59
+
60
+ similarity = {}
61
+ similarity[:threshold] = env["SIMILARITY_THRESHOLD"].to_f if env["SIMILARITY_THRESHOLD"]
62
+ patterns = {}
63
+ patterns[:min_size] = env["PATTERN_MIN_SIZE"].to_i if env["PATTERN_MIN_SIZE"]
64
+ patterns[:min_occurrences] = env["PATTERN_MIN_OCCURRENCES"].to_i if env["PATTERN_MIN_OCCURRENCES"]
65
+ classitis = {}
66
+ classitis[:min_classes] = env["CLASSITIS_MIN_CLASSES"].to_i if env["CLASSITIS_MIN_CLASSES"]
67
+ classitis[:min_occurrences] = env["CLASSITIS_MIN_OCCURRENCES"].to_i if env["CLASSITIS_MIN_OCCURRENCES"]
68
+
69
+ new(root: root, style: style,
70
+ suggest: truthy?(env["SUGGEST"]), apply: truthy?(env["APPLY"]),
71
+ axe_json: env["AXE_JSON"], visual_diff: visual_diff_on,
72
+ similarity: similarity, patterns: patterns, classitis: classitis,
73
+ min_severity: Severity.parse(env["SEVERITY"]))
74
+ end
75
+
76
+ def self.truthy?(value)
77
+ TRUTHY.include?(value&.downcase)
78
+ end
79
+
80
+ # `style` should be bound to the *real* output stream, not the
81
+ # capture sink: detectors write into a StringIO but must make
82
+ # ANSI decisions against the terminal, or the report body prints
83
+ # plain while the summary around it is colored. Pass nil to get
84
+ # uncolored output (what JSON mode wants — the body is discarded).
85
+ def initialize(root:, style: nil, suggest: false, apply: false,
86
+ axe_json: nil, visual_diff: false,
87
+ similarity: {}, patterns: {}, classitis: {},
88
+ min_severity: Severity::DEFAULT)
89
+ @min_severity = min_severity
90
+ @root = Pathname(root)
91
+ @style = style
92
+ @suggest = suggest
93
+ @apply = apply
94
+ @axe_json = axe_json
95
+ @visual_diff_on = visual_diff
96
+ @similarity_opts = similarity
97
+ @pattern_opts = patterns
98
+ @classitis_opts = classitis
99
+ @sink = StringIO.new
100
+ end
101
+
102
+ def call
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
115
+ @a11y = detect(A11yAudit.new(**common))
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
121
+ @a11y_deep = @a11y_deep_runner ? detect(@a11y_deep_runner) : []
122
+ @visual_diff_runner = @visual_diff_on ? VisualDiff.new(**common) : nil
123
+ @visual_diff = @visual_diff_runner ? detect(@visual_diff_runner) : []
124
+ self
125
+ end
126
+
127
+ # Severities this run didn't look at (empty by default).
128
+ def muted_severities
129
+ Severity.muted(@min_severity)
130
+ end
131
+
132
+ # Every finding as detector-agnostic data (see Report::Finding),
133
+ # grouped by category and ordered errors → warnings →
134
+ # suggestions, biggest category first — the same order the
135
+ # summary rollup uses. Category names match `summary_entries`.
136
+ def categories
137
+ @categories ||= @detected
138
+ .flat_map { |detector, result| detector.categories(result) }
139
+ .sort_by
140
+ .with_index { |c, i| [Summary::SEVERITY_ORDER.index(c.severity), -c.count, i] }
141
+ end
142
+
143
+ def findings
144
+ categories.flat_map(&:findings)
145
+ end
146
+
147
+ # The per-detector sections, exactly as the detectors printed them.
148
+ def body
149
+ @sink.string
150
+ end
151
+
152
+ def summary_entries
153
+ [
154
+ entry("raw_color", count_type(:raw_color), :error, auto_fix: true),
155
+ entry("tailwind_arbitrary", count_type(:tailwind_arbitrary), :error, auto_fix: true),
156
+ entry("inline_style", count_type(:inline_style), :warning),
157
+ entry("helper_recommended", count_type(:helper_recommended), :warning),
158
+ entry("a11y (static)", a11y.length, :error),
159
+ entry("a11y (deep)", a11y_deep.length, :error),
160
+ entry("stimulus orphaned", stimulus.orphaned.length, :warning),
161
+ entry("stimulus dead", stimulus.dead.length, :warning),
162
+ entry("missing previews", view_components.missing_previews.length, :warning),
163
+ entry("orphan slots", view_components.orphan_slots.length, :warning),
164
+ entry("visual diff", visual_diff.length, :error),
165
+ entry("similar partials", similarity.length, :suggestion,
166
+ unit: "pairs", action: "consider deduplicating"),
167
+ entry("cross-codebase patterns", patterns.length, :suggestion,
168
+ unit: "candidates", action: "consider extracting partials"),
169
+ entry("class-itis", classitis.length, :suggestion,
170
+ unit: "clusters", action: "consider extracting component / @apply")
171
+ ]
172
+ end
173
+
174
+ # FORMAT=json payload. Shape is a public contract — unchanged
175
+ # since 1.0.0.
176
+ def to_h
177
+ {
178
+ summary: {
179
+ violations: violations.length,
180
+ stimulus_orphaned: stimulus.orphaned.length,
181
+ stimulus_dead: stimulus.dead.length,
182
+ similar_partials: similarity.length,
183
+ missing_previews: view_components.missing_previews.length,
184
+ orphan_slots: view_components.orphan_slots.length,
185
+ a11y: a11y.length,
186
+ a11y_deep: a11y_deep.length,
187
+ patterns: patterns.length,
188
+ classitis: classitis.length,
189
+ visual_diff: visual_diff.length
190
+ },
191
+ violations: violations.map(&:to_h),
192
+ stimulus: { orphaned: stimulus.orphaned, dead: stimulus.dead },
193
+ similar_partials: similarity.map(&:to_h),
194
+ view_components: {
195
+ missing_previews: view_components.missing_previews,
196
+ orphan_slots: view_components.orphan_slots.map(&:to_h)
197
+ },
198
+ a11y: a11y.map(&:to_h),
199
+ a11y_deep: a11y_deep.map(&:to_h),
200
+ patterns: patterns.map { |p|
201
+ { fingerprint: p.fingerprint, shape: p.shape, size: p.size, count: p.count, occurrences: p.occurrences.map(&:to_h) }
202
+ },
203
+ classitis: classitis.map { |c|
204
+ { tag: c.tag, classes: c.classes, count: c.count, occurrences: c.occurrences.map(&:to_h) }
205
+ },
206
+ visual_diff: visual_diff.map(&:to_h)
207
+ }
208
+ end
209
+
210
+ # Exit-code contract: any error or warning fails; the suggestion-
211
+ # tier pattern / class-itis detectors don't. Deep a11y and visual
212
+ # diff only fail when a finding crosses their configured
213
+ # threshold, and are no-ops when not opted into.
214
+ def failing?
215
+ violations.any? || stimulus.violations? || similarity.any? ||
216
+ view_components.violations? || a11y.any? ||
217
+ (@a11y_deep_runner&.any_failing?(a11y_deep) || false) ||
218
+ (@visual_diff_runner&.any_failing?(visual_diff) || false)
219
+ end
220
+
221
+ private
222
+
223
+ # Runs a detector and remembers the pair, so `categories` can ask
224
+ # each detector to normalize its own result later — only
225
+ # front-ends that want findings-as-data pay for building them.
226
+ def detect(detector)
227
+ result = detector.run
228
+ (@detected ||= []) << [detector, result]
229
+ result
230
+ end
231
+
232
+ def wanted?(severity)
233
+ Severity.include?(severity, @min_severity)
234
+ end
235
+
236
+ def common
237
+ { root: @root, output: @sink, style: @style }
238
+ end
239
+
240
+ def count_type(type)
241
+ violations.count { |v| v.type == type }
242
+ end
243
+
244
+ def entry(category, count, severity, **rest)
245
+ Summary::Entry.new(category: category, count: count, severity: severity, **rest)
246
+ end
247
+ end
248
+ end
249
+ 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