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 +4 -4
- data/README.md +20 -2
- data/lib/guardrails/a11y_audit.rb +26 -3
- data/lib/guardrails/a11y_deep.rb +27 -2
- data/lib/guardrails/audit.rb +31 -3
- 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 +104 -0
- data/lib/guardrails/report/run.rb +224 -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 +41 -192
- metadata +10 -1
|
@@ -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
|