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.
@@ -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 %></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,104 @@
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
+ def initialize(categories:, root:, generated_at: Time.now)
38
+ @categories = categories
39
+ @root = Pathname(root).expand_path
40
+ @generated_at = generated_at
41
+ end
42
+
43
+ def render
44
+ ERB.new(File.read(TEMPLATE, encoding: Encoding::UTF_8), trim_mode: "-").result(binding)
45
+ end
46
+
47
+ # Writes the report and returns the absolute path written.
48
+ def write(path = DEFAULT_PATH)
49
+ target = Pathname(path)
50
+ target = @root.join(target) unless target.absolute?
51
+ FileUtils.mkdir_p(target.dirname)
52
+ File.write(target, render)
53
+ target
54
+ end
55
+
56
+ private
57
+
58
+ def h(text)
59
+ ERB::Util.html_escape(text.to_s)
60
+ end
61
+
62
+ def findings
63
+ @categories.flat_map(&:findings)
64
+ end
65
+
66
+ def project_name
67
+ @root.basename.to_s
68
+ end
69
+
70
+ def severity_groups
71
+ Summary::SEVERITY_ORDER.filter_map do |severity|
72
+ group = @categories.select { |c| c.severity == severity }
73
+ [severity, group] unless group.empty?
74
+ end
75
+ end
76
+
77
+ def severity_count(severity)
78
+ findings.count { |f| f.severity == severity }
79
+ end
80
+
81
+ def anchor(category)
82
+ "cat-#{category.name.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-|-\z/, '')}"
83
+ end
84
+
85
+ def absolute(location)
86
+ @root.join(location.file).to_s
87
+ end
88
+
89
+ # Lowercased text the client-side filter matches against.
90
+ def search_text(finding)
91
+ [finding.category, finding.title, finding.suggestion, *finding.files].compact.join(" ").downcase
92
+ end
93
+
94
+ # JSON embedded in a <script> must not be able to close it.
95
+ def editor_urls_json
96
+ JSON.generate(EDITOR_URLS).gsub("</", '<\/')
97
+ end
98
+
99
+ def pluralize(count, singular, plural = "#{singular}s")
100
+ "#{count} #{count == 1 ? singular : plural}"
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,224 @@
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 "../configuration"
17
+
18
+ module Guardrails
19
+ module Report
20
+ # One full audit pass: runs every detector once and holds the
21
+ # results so any number of front-ends (text report, JSON, and
22
+ # anything interactive) can render from the same data without
23
+ # re-running detectors or re-deriving the summary.
24
+ #
25
+ # Detectors still print their own per-section text; Run captures
26
+ # that into `body` rather than letting it hit the terminal, because
27
+ # the summary has to print first and needs every detector's count.
28
+ #
29
+ # `new` takes plain arguments so it's callable from specs and
30
+ # non-rake entry points; `from_env` is the one place the audit's
31
+ # env-var surface is parsed, shared by every task that runs one.
32
+ class Run
33
+ TRUTHY = %w[1 true yes].freeze
34
+
35
+ attr_reader :violations, :stimulus, :similarity, :view_components,
36
+ :a11y, :patterns, :classitis, :a11y_deep, :visual_diff
37
+
38
+ # Builds a Run from the documented env vars (SUGGEST, APPLY,
39
+ # AXE_JSON, VISUAL_DIFF*, SIMILARITY_THRESHOLD, PATTERN_*,
40
+ # CLASSITIS_*).
41
+ def self.from_env(root:, style: nil, env: ENV)
42
+ # Visual-diff is opt-in (baselines need deliberate setup). Enabled
43
+ # when either VISUAL_DIFF=1 is set in the env (sidecar mode) or
44
+ # Guardrails.configuration.visual_diff.enabled was flipped on by a
45
+ # Rails initializer (embedded mode). Env overrides Configuration.
46
+ visual_diff_on = truthy?(env["VISUAL_DIFF"]) || Guardrails.configuration.visual_diff.enabled
47
+ # Strip + reject blank env values — an empty VISUAL_DIFF_DIR would
48
+ # otherwise be applied as snap_diff_dir = "" and glob from the repo
49
+ # root (potentially scanning the whole tree).
50
+ if (dir = env["VISUAL_DIFF_DIR"]) && !dir.strip.empty?
51
+ Guardrails.configure { |c| c.visual_diff.snap_diff_dir = dir.strip }
52
+ end
53
+ if (thr = env["VISUAL_DIFF_THRESHOLD"]) && !thr.strip.empty?
54
+ Guardrails.configure { |c| c.visual_diff.threshold = thr.strip }
55
+ end
56
+
57
+ similarity = {}
58
+ similarity[:threshold] = env["SIMILARITY_THRESHOLD"].to_f if env["SIMILARITY_THRESHOLD"]
59
+ patterns = {}
60
+ patterns[:min_size] = env["PATTERN_MIN_SIZE"].to_i if env["PATTERN_MIN_SIZE"]
61
+ patterns[:min_occurrences] = env["PATTERN_MIN_OCCURRENCES"].to_i if env["PATTERN_MIN_OCCURRENCES"]
62
+ classitis = {}
63
+ classitis[:min_classes] = env["CLASSITIS_MIN_CLASSES"].to_i if env["CLASSITIS_MIN_CLASSES"]
64
+ classitis[:min_occurrences] = env["CLASSITIS_MIN_OCCURRENCES"].to_i if env["CLASSITIS_MIN_OCCURRENCES"]
65
+
66
+ new(root: root, style: style,
67
+ suggest: truthy?(env["SUGGEST"]), apply: truthy?(env["APPLY"]),
68
+ axe_json: env["AXE_JSON"], visual_diff: visual_diff_on,
69
+ similarity: similarity, patterns: patterns, classitis: classitis)
70
+ end
71
+
72
+ def self.truthy?(value)
73
+ TRUTHY.include?(value&.downcase)
74
+ end
75
+
76
+ # `style` should be bound to the *real* output stream, not the
77
+ # capture sink: detectors write into a StringIO but must make
78
+ # ANSI decisions against the terminal, or the report body prints
79
+ # plain while the summary around it is colored. Pass nil to get
80
+ # uncolored output (what JSON mode wants — the body is discarded).
81
+ def initialize(root:, style: nil, suggest: false, apply: false,
82
+ axe_json: nil, visual_diff: false,
83
+ similarity: {}, patterns: {}, classitis: {})
84
+ @root = Pathname(root)
85
+ @style = style
86
+ @suggest = suggest
87
+ @apply = apply
88
+ @axe_json = axe_json
89
+ @visual_diff_on = visual_diff
90
+ @similarity_opts = similarity
91
+ @pattern_opts = patterns
92
+ @classitis_opts = classitis
93
+ @sink = StringIO.new
94
+ end
95
+
96
+ def call
97
+ @violations = detect(Audit.new(**common, suggest: @suggest, apply: @apply, format: :text))
98
+ @stimulus = detect(StimulusAudit.new(**common))
99
+ @similarity = detect(PartialSimilarity.new(**common, **@similarity_opts))
100
+ @view_components = detect(ViewComponentAudit.new(**common))
101
+ @a11y = detect(A11yAudit.new(**common))
102
+ @patterns = detect(CrossCodebasePatterns.new(**common, **@pattern_opts))
103
+ @classitis = detect(ClassItis.new(**common, **@classitis_opts))
104
+ @a11y_deep_runner = @axe_json ? A11yDeep.new(input: @axe_json, output: @sink, style: @style) : nil
105
+ @a11y_deep = @a11y_deep_runner ? detect(@a11y_deep_runner) : []
106
+ @visual_diff_runner = @visual_diff_on ? VisualDiff.new(**common) : nil
107
+ @visual_diff = @visual_diff_runner ? detect(@visual_diff_runner) : []
108
+ self
109
+ end
110
+
111
+ # Every finding as detector-agnostic data (see Report::Finding),
112
+ # grouped by category and ordered errors → warnings →
113
+ # suggestions, biggest category first — the same order the
114
+ # summary rollup uses. Category names match `summary_entries`.
115
+ def categories
116
+ @categories ||= @detected
117
+ .flat_map { |detector, result| detector.categories(result) }
118
+ .sort_by
119
+ .with_index { |c, i| [Summary::SEVERITY_ORDER.index(c.severity), -c.count, i] }
120
+ end
121
+
122
+ def findings
123
+ categories.flat_map(&:findings)
124
+ end
125
+
126
+ # The per-detector sections, exactly as the detectors printed them.
127
+ def body
128
+ @sink.string
129
+ end
130
+
131
+ def summary_entries
132
+ [
133
+ entry("raw_color", count_type(:raw_color), :error, auto_fix: true),
134
+ entry("tailwind_arbitrary", count_type(:tailwind_arbitrary), :error, auto_fix: true),
135
+ entry("inline_style", count_type(:inline_style), :warning),
136
+ entry("helper_recommended", count_type(:helper_recommended), :warning),
137
+ entry("a11y (static)", a11y.length, :error),
138
+ entry("a11y (deep)", a11y_deep.length, :error),
139
+ entry("stimulus orphaned", stimulus.orphaned.length, :warning),
140
+ entry("stimulus dead", stimulus.dead.length, :warning),
141
+ entry("missing previews", view_components.missing_previews.length, :warning),
142
+ entry("orphan slots", view_components.orphan_slots.length, :warning),
143
+ entry("visual diff", visual_diff.length, :error),
144
+ entry("similar partials", similarity.length, :suggestion,
145
+ unit: "pairs", action: "consider deduplicating"),
146
+ entry("cross-codebase patterns", patterns.length, :suggestion,
147
+ unit: "candidates", action: "consider extracting partials"),
148
+ entry("class-itis", classitis.length, :suggestion,
149
+ unit: "clusters", action: "consider extracting component / @apply")
150
+ ]
151
+ end
152
+
153
+ # FORMAT=json payload. Shape is a public contract — unchanged
154
+ # since 1.0.0.
155
+ def to_h
156
+ {
157
+ summary: {
158
+ violations: violations.length,
159
+ stimulus_orphaned: stimulus.orphaned.length,
160
+ stimulus_dead: stimulus.dead.length,
161
+ similar_partials: similarity.length,
162
+ missing_previews: view_components.missing_previews.length,
163
+ orphan_slots: view_components.orphan_slots.length,
164
+ a11y: a11y.length,
165
+ a11y_deep: a11y_deep.length,
166
+ patterns: patterns.length,
167
+ classitis: classitis.length,
168
+ visual_diff: visual_diff.length
169
+ },
170
+ violations: violations.map(&:to_h),
171
+ stimulus: { orphaned: stimulus.orphaned, dead: stimulus.dead },
172
+ similar_partials: similarity.map(&:to_h),
173
+ view_components: {
174
+ missing_previews: view_components.missing_previews,
175
+ orphan_slots: view_components.orphan_slots.map(&:to_h)
176
+ },
177
+ a11y: a11y.map(&:to_h),
178
+ a11y_deep: a11y_deep.map(&:to_h),
179
+ patterns: patterns.map { |p|
180
+ { fingerprint: p.fingerprint, shape: p.shape, size: p.size, count: p.count, occurrences: p.occurrences.map(&:to_h) }
181
+ },
182
+ classitis: classitis.map { |c|
183
+ { tag: c.tag, classes: c.classes, count: c.count, occurrences: c.occurrences.map(&:to_h) }
184
+ },
185
+ visual_diff: visual_diff.map(&:to_h)
186
+ }
187
+ end
188
+
189
+ # Exit-code contract: any error or warning fails; the suggestion-
190
+ # tier pattern / class-itis detectors don't. Deep a11y and visual
191
+ # diff only fail when a finding crosses their configured
192
+ # threshold, and are no-ops when not opted into.
193
+ def failing?
194
+ violations.any? || stimulus.violations? || similarity.any? ||
195
+ view_components.violations? || a11y.any? ||
196
+ (@a11y_deep_runner&.any_failing?(a11y_deep) || false) ||
197
+ (@visual_diff_runner&.any_failing?(visual_diff) || false)
198
+ end
199
+
200
+ private
201
+
202
+ # Runs a detector and remembers the pair, so `categories` can ask
203
+ # each detector to normalize its own result later — only
204
+ # front-ends that want findings-as-data pay for building them.
205
+ def detect(detector)
206
+ result = detector.run
207
+ (@detected ||= []) << [detector, result]
208
+ result
209
+ end
210
+
211
+ def common
212
+ { root: @root, output: @sink, style: @style }
213
+ end
214
+
215
+ def count_type(type)
216
+ violations.count { |v| v.type == type }
217
+ end
218
+
219
+ def entry(category, count, severity, **rest)
220
+ Summary::Entry.new(category: category, count: count, severity: severity, **rest)
221
+ end
222
+ end
223
+ end
224
+ end