@scantrix/cli 1.0.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.
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/dist/astConfigParser.js +308 -0
- package/dist/astRuleHelpers.js +1451 -0
- package/dist/auditConfig.js +81 -0
- package/dist/ciExtractor.js +327 -0
- package/dist/cli.js +156 -0
- package/dist/configExtractor.js +261 -0
- package/dist/cypressExtractor.js +217 -0
- package/dist/diffTracker.js +310 -0
- package/dist/report.js +1904 -0
- package/dist/sarifFormatter.js +88 -0
- package/dist/scanResult.js +45 -0
- package/dist/scanner.js +3519 -0
- package/dist/scoring.js +206 -0
- package/dist/sinks/index.js +29 -0
- package/dist/sinks/jsonSink.js +28 -0
- package/dist/sinks/types.js +2 -0
- package/docs/high-res-icon.svg +26 -0
- package/docs/scantrix-logo-light.svg +64 -0
- package/docs/scantrix-logo.svg +64 -0
- package/package.json +55 -0
package/dist/report.js
ADDED
|
@@ -0,0 +1,1904 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.writeReportArtifacts = writeReportArtifacts;
|
|
7
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const markdown_it_1 = __importDefault(require("markdown-it"));
|
|
10
|
+
const sarifFormatter_1 = require("./sarifFormatter");
|
|
11
|
+
const diffTracker_1 = require("./diffTracker");
|
|
12
|
+
const scoring_1 = require("./scoring");
|
|
13
|
+
function topFiles(evidence, topN = 3) {
|
|
14
|
+
const counts = new Map();
|
|
15
|
+
for (const e of evidence)
|
|
16
|
+
counts.set(e.file, (counts.get(e.file) ?? 0) + 1);
|
|
17
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN);
|
|
18
|
+
}
|
|
19
|
+
function formatMs(ms) {
|
|
20
|
+
if (typeof ms !== "number" || !Number.isFinite(ms))
|
|
21
|
+
return undefined;
|
|
22
|
+
return `${ms} ms`;
|
|
23
|
+
}
|
|
24
|
+
function pushIf(md, label, value) {
|
|
25
|
+
if (value === undefined || value === null)
|
|
26
|
+
return;
|
|
27
|
+
if (typeof value === "string" && value.trim().length === 0)
|
|
28
|
+
return;
|
|
29
|
+
md.push(`- ${label}: ${value}`);
|
|
30
|
+
}
|
|
31
|
+
function stripHtmlTags(input) {
|
|
32
|
+
return input.replace(/<[^>]+>/g, "").trim();
|
|
33
|
+
}
|
|
34
|
+
function linkifyUrlsInPlainText(text) {
|
|
35
|
+
// Linkify only http(s) URLs.
|
|
36
|
+
// Keep it conservative: stop at whitespace or obvious HTML delimiters.
|
|
37
|
+
const urlRegex = /https?:\/\/[^\s<>"]+/g;
|
|
38
|
+
return text.replace(urlRegex, (rawUrl) => {
|
|
39
|
+
// Trim trailing punctuation that commonly follows URLs in prose.
|
|
40
|
+
let url = rawUrl;
|
|
41
|
+
while (/[),.;:!?]$/.test(url))
|
|
42
|
+
url = url.slice(0, -1);
|
|
43
|
+
const trailing = rawUrl.slice(url.length);
|
|
44
|
+
return `<a href="${url}">${url}</a>${trailing}`;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function linkifyUrlsInHtmlTextNodes(html) {
|
|
48
|
+
// Only linkify inside text nodes (not inside tags/attrs), and avoid
|
|
49
|
+
// linkifying inside an existing <a>...</a>.
|
|
50
|
+
const parts = html.split(/(<[^>]+>)/g);
|
|
51
|
+
let inAnchor = false;
|
|
52
|
+
for (let i = 0; i < parts.length; i++) {
|
|
53
|
+
const part = parts[i];
|
|
54
|
+
if (!part)
|
|
55
|
+
continue;
|
|
56
|
+
if (part.startsWith("<")) {
|
|
57
|
+
if (/^<\s*a\b/i.test(part))
|
|
58
|
+
inAnchor = true;
|
|
59
|
+
else if (/^<\s*\/\s*a\b/i.test(part))
|
|
60
|
+
inAnchor = false;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!inAnchor)
|
|
64
|
+
parts[i] = linkifyUrlsInPlainText(part);
|
|
65
|
+
}
|
|
66
|
+
return parts.join("");
|
|
67
|
+
}
|
|
68
|
+
function linkifyHttpUrlsInRenderedHtml(htmlBody) {
|
|
69
|
+
// markdown-it does not linkify inside raw HTML blocks. Our report intentionally
|
|
70
|
+
// uses HTML blocks for layout, so we post-process the rendered HTML and
|
|
71
|
+
// linkify only in text nodes.
|
|
72
|
+
return linkifyUrlsInHtmlTextNodes(htmlBody);
|
|
73
|
+
}
|
|
74
|
+
function escapeHtml(input) {
|
|
75
|
+
return input
|
|
76
|
+
.replace(/&/g, "&")
|
|
77
|
+
.replace(/</g, "<")
|
|
78
|
+
.replace(/>/g, ">")
|
|
79
|
+
.replace(/\"/g, """)
|
|
80
|
+
.replace(/'/g, "'");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Transforms a recommendation string into proper Markdown so that
|
|
84
|
+
* markdown-it renders it with visible paragraph breaks and code blocks
|
|
85
|
+
* instead of collapsing everything into a single paragraph.
|
|
86
|
+
*
|
|
87
|
+
* - Consecutive indented lines (≥2 leading spaces) → fenced code block
|
|
88
|
+
* - Non-indented lines → separated by blank lines (paragraph breaks)
|
|
89
|
+
*/
|
|
90
|
+
function formatRecommendation(rec) {
|
|
91
|
+
const lines = rec.split("\n");
|
|
92
|
+
if (lines.length <= 1)
|
|
93
|
+
return rec;
|
|
94
|
+
const out = [];
|
|
95
|
+
let inCode = false;
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const isIndented = /^\s{2,}/.test(line);
|
|
98
|
+
if (isIndented && !inCode) {
|
|
99
|
+
out.push("");
|
|
100
|
+
out.push("```");
|
|
101
|
+
out.push(line.trimStart());
|
|
102
|
+
inCode = true;
|
|
103
|
+
}
|
|
104
|
+
else if (isIndented && inCode) {
|
|
105
|
+
out.push(line.trimStart());
|
|
106
|
+
}
|
|
107
|
+
else if (!isIndented && inCode) {
|
|
108
|
+
out.push("```");
|
|
109
|
+
out.push("");
|
|
110
|
+
out.push(line);
|
|
111
|
+
inCode = false;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
if (out.length > 0)
|
|
115
|
+
out.push("");
|
|
116
|
+
out.push(line);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (inCode)
|
|
120
|
+
out.push("```");
|
|
121
|
+
return out.join("\n");
|
|
122
|
+
}
|
|
123
|
+
function toEmailFriendlyMarkdown(markdown) {
|
|
124
|
+
// Email clients often don't support <details>/<summary>. Flatten them.
|
|
125
|
+
// Keep the summary text as a bold line.
|
|
126
|
+
const flattened = markdown
|
|
127
|
+
.replace(/\r\n/g, "\n")
|
|
128
|
+
.replace(/<details>\s*\n/gi, "")
|
|
129
|
+
.replace(/\n<\/details>\s*\n/gi, "\n\n")
|
|
130
|
+
.replace(/<summary>([\s\S]*?)<\/summary>\s*\n/gi, (_m, summary) => {
|
|
131
|
+
const label = stripHtmlTags(String(summary));
|
|
132
|
+
return `\n**${label}**\n\n`;
|
|
133
|
+
});
|
|
134
|
+
// Centering wrappers don't help in email; keep content simple.
|
|
135
|
+
const out = [];
|
|
136
|
+
let centerDepth = 0;
|
|
137
|
+
for (const line of flattened.split("\n")) {
|
|
138
|
+
if (/^\s*<div\s+align="center">\s*$/i.test(line)) {
|
|
139
|
+
centerDepth += 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (centerDepth > 0 && /^\s*<\/div>\s*$/i.test(line)) {
|
|
143
|
+
centerDepth -= 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
out.push(line);
|
|
147
|
+
}
|
|
148
|
+
return out.join("\n");
|
|
149
|
+
}
|
|
150
|
+
function wrapHtmlDocument(title, bodyHtml, flavor = "full", opts = {}) {
|
|
151
|
+
if (flavor === "email") {
|
|
152
|
+
return wrapEmailDocument(title, bodyHtml);
|
|
153
|
+
}
|
|
154
|
+
/* ── Enterprise report design system ─────────────────────────────── */
|
|
155
|
+
const css = `
|
|
156
|
+
/* ── Design Tokens ── */
|
|
157
|
+
:root {
|
|
158
|
+
--color-bg: #f5f6fa;
|
|
159
|
+
--color-surface: #ffffff;
|
|
160
|
+
--color-text: #0f172a;
|
|
161
|
+
--color-text-secondary: #1e293b;
|
|
162
|
+
--color-muted: #94a3b8;
|
|
163
|
+
--color-border: #e2e8f0;
|
|
164
|
+
--color-border-light: #4F7BE3;
|
|
165
|
+
--color-accent: #4F7BE3;
|
|
166
|
+
--color-accent-light: #eef2ff;
|
|
167
|
+
--color-accent-hover: #386dca;
|
|
168
|
+
|
|
169
|
+
--severity-high: #dc2626;
|
|
170
|
+
--severity-high-bg: #fef2f2;
|
|
171
|
+
--severity-high-border: #fecaca;
|
|
172
|
+
--severity-medium: #d97706;
|
|
173
|
+
--severity-medium-bg: #fffbeb;
|
|
174
|
+
--severity-medium-border: #fde68a;
|
|
175
|
+
--severity-low: #2563eb;
|
|
176
|
+
--severity-low-bg: #eff6ff;
|
|
177
|
+
--severity-low-border: #bfdbfe;
|
|
178
|
+
|
|
179
|
+
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
180
|
+
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
181
|
+
|
|
182
|
+
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
|
|
183
|
+
--space-5: 20px; --space-6: 24px; --space-8: 32px; --space-10: 40px;
|
|
184
|
+
|
|
185
|
+
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 12px;
|
|
186
|
+
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
|
187
|
+
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.04);
|
|
188
|
+
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08), 0 4px 6px rgba(0,0,0,0.03);
|
|
189
|
+
--sidebar-w: 224px;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ── Reset & Base ── */
|
|
193
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
194
|
+
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; scroll-padding-top: 24px; }
|
|
195
|
+
body {
|
|
196
|
+
font-family: var(--font-sans); color: var(--color-text); line-height: 1.6;
|
|
197
|
+
margin: 0; padding: 0; background: var(--color-bg); font-size: 14px;
|
|
198
|
+
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ── Layout ── */
|
|
202
|
+
.report-layout { display: flex; min-height: 100vh; }
|
|
203
|
+
|
|
204
|
+
/* ── Sidebar ── */
|
|
205
|
+
.sidebar {
|
|
206
|
+
position: fixed; top: 0; left: 0; bottom: 0; width: var(--sidebar-w);
|
|
207
|
+
background: #0f172a; border-right: 1px solid #334155;
|
|
208
|
+
padding: var(--space-6) 0; overflow-y: auto; z-index: 10;
|
|
209
|
+
display: flex; flex-direction: column;
|
|
210
|
+
}
|
|
211
|
+
.sidebar-brand { padding: 0 var(--space-5); margin-bottom: var(--space-5); }
|
|
212
|
+
.sidebar-brand img { max-width: 190px; height: auto; }
|
|
213
|
+
.sidebar-brand .brand-logo { display: block; color: #e2e8f0; }
|
|
214
|
+
.sidebar-brand .brand-logo svg { width: 190px; height: auto; display: block; }
|
|
215
|
+
.sidebar-brand .brand-text {
|
|
216
|
+
font-size: 15px; font-weight: 700; color: #e2e8f0; letter-spacing: -0.02em;
|
|
217
|
+
}
|
|
218
|
+
.sidebar-label {
|
|
219
|
+
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
|
|
220
|
+
color: #64748b; padding: var(--space-2) var(--space-5) var(--space-1);
|
|
221
|
+
}
|
|
222
|
+
.sidebar-nav { list-style: none; margin: 0; padding: 0; flex: 1; }
|
|
223
|
+
.sidebar-nav li { margin: 0; padding: 0; }
|
|
224
|
+
.sidebar-nav a {
|
|
225
|
+
display: block; padding: 6px var(--space-5); color: #94a3b8;
|
|
226
|
+
font-size: 13px; font-weight: 500; text-decoration: none;
|
|
227
|
+
border-left: 6px solid transparent; transition: all 120ms ease;
|
|
228
|
+
}
|
|
229
|
+
.sidebar-nav a:hover { color: #e2e8f0; background: #283548; }
|
|
230
|
+
.sidebar-nav a.active {
|
|
231
|
+
color: #4F7BE3; border-left-color: #4F7BE3;
|
|
232
|
+
background: rgba(59, 130, 246, 0.1); font-weight: 600;
|
|
233
|
+
}
|
|
234
|
+
.sidebar-actions {
|
|
235
|
+
padding: var(--space-3) var(--space-5); border-top: 1px solid #334155; margin-top: auto;
|
|
236
|
+
}
|
|
237
|
+
.sidebar-actions button:not(.theme-toggle-track) {
|
|
238
|
+
display: block; width: 100%; padding: 6px var(--space-3); margin-bottom: 6px;
|
|
239
|
+
border: 1px solid #020617; border-radius: var(--radius-md);
|
|
240
|
+
background: #1e293b; color: #e2e8f0;
|
|
241
|
+
font-size: 12px; font-family: inherit; cursor: pointer; text-align: left;
|
|
242
|
+
transition: all 120ms ease;
|
|
243
|
+
}
|
|
244
|
+
.sidebar-actions button:not(.theme-toggle-track):hover { background: #283548; color: #e2e8f0; }
|
|
245
|
+
|
|
246
|
+
/* ── Main Content ── */
|
|
247
|
+
.report-main {
|
|
248
|
+
margin-left: var(--sidebar-w); flex: 1; min-width: 0;
|
|
249
|
+
padding: var(--space-8) var(--space-10);
|
|
250
|
+
}
|
|
251
|
+
.report-content { max-width: 1060px; margin: 0 auto; }
|
|
252
|
+
|
|
253
|
+
/* ── Typography ── */
|
|
254
|
+
h1, h2, h3, h4 { line-height: 1.3; font-weight: 700; color: var(--color-text); }
|
|
255
|
+
h1 { font-size: 1.75rem; margin: 0 0 var(--space-2); letter-spacing: -0.02em; }
|
|
256
|
+
h2 {
|
|
257
|
+
font-size: 1.25rem; margin: var(--space-6) 0 var(--space-3);
|
|
258
|
+
padding-bottom: 0; border-bottom: none;
|
|
259
|
+
}
|
|
260
|
+
h3 { font-size: 1.05rem; margin: var(--space-6) 0 var(--space-3); }
|
|
261
|
+
h4 { font-size: 0.92rem; margin: var(--space-4) 0 var(--space-2); }
|
|
262
|
+
|
|
263
|
+
p { margin: var(--space-2) 0; }
|
|
264
|
+
ul, ol { padding-left: var(--space-5); margin: var(--space-2) 0; }
|
|
265
|
+
li { margin: var(--space-1) 0; }
|
|
266
|
+
|
|
267
|
+
hr { border: 0; height: 1px; background: var(--color-border); margin: var(--space-8) 0; }
|
|
268
|
+
a { color: var(--color-accent); text-decoration: none; }
|
|
269
|
+
a:hover { text-decoration: underline; }
|
|
270
|
+
a:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-radius: 2px; }
|
|
271
|
+
|
|
272
|
+
/* ── Code ── */
|
|
273
|
+
code {
|
|
274
|
+
font-family: var(--font-mono); font-size: 0.84em;
|
|
275
|
+
background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: var(--radius-sm);
|
|
276
|
+
padding: 1px 5px; color: #000b25;
|
|
277
|
+
}
|
|
278
|
+
pre {
|
|
279
|
+
background: #000b25; color: #e2e8f0; padding: var(--space-4); overflow-x: auto;
|
|
280
|
+
border-radius: var(--radius-lg); border: 1px solid #1e293b;
|
|
281
|
+
margin: var(--space-4) 0; font-size: 13px; line-height: 1.5;
|
|
282
|
+
}
|
|
283
|
+
pre code { color: inherit; background: transparent; border: 0; padding: 0; }
|
|
284
|
+
|
|
285
|
+
/* ── Blockquotes ── */
|
|
286
|
+
blockquote {
|
|
287
|
+
margin: var(--space-4) 0; padding: var(--space-3) var(--space-5) var(--space-3) var(--space-6);
|
|
288
|
+
border-left: 3px solid var(--color-accent); background: var(--color-accent-light);
|
|
289
|
+
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
|
290
|
+
color: var(--color-text-secondary); font-size: 0.9em;
|
|
291
|
+
}
|
|
292
|
+
details.finding-card blockquote { margin-left: var(--space-2); }
|
|
293
|
+
blockquote p { margin: var(--space-1) 0; max-width: none; }
|
|
294
|
+
|
|
295
|
+
img { max-width: 100%; height: auto; }
|
|
296
|
+
|
|
297
|
+
/* ── Tables ── */
|
|
298
|
+
table {
|
|
299
|
+
width: 100%; margin: var(--space-4) 0; border: 1px solid var(--color-border);
|
|
300
|
+
border-radius: var(--radius-lg); border-collapse: separate; border-spacing: 0;
|
|
301
|
+
overflow: hidden; background: var(--color-surface); font-size: 13px;
|
|
302
|
+
}
|
|
303
|
+
th, td {
|
|
304
|
+
padding: var(--space-2) var(--space-3); text-align: left;
|
|
305
|
+
border-bottom: 1px solid var(--color-border); vertical-align: top;
|
|
306
|
+
line-height: 1.3;
|
|
307
|
+
}
|
|
308
|
+
td:last-child { white-space: nowrap; }
|
|
309
|
+
th {
|
|
310
|
+
background: #f8fafc; font-weight: 600; font-size: 11px;
|
|
311
|
+
text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-text-secondary);
|
|
312
|
+
position: sticky; top: 0; z-index: 1;
|
|
313
|
+
}
|
|
314
|
+
tbody tr:last-child td { border-bottom: 0; }
|
|
315
|
+
tbody tr:hover td { background: #f1f5f9; }
|
|
316
|
+
|
|
317
|
+
/* ── Grade Card ── */
|
|
318
|
+
.grade-card {
|
|
319
|
+
display: inline-flex; align-items: center; gap: var(--space-4);
|
|
320
|
+
background: var(--color-surface); border: 1px solid var(--color-border);
|
|
321
|
+
border-radius: var(--radius-xl); padding: var(--space-4) var(--space-6);
|
|
322
|
+
box-shadow: var(--shadow-md); margin: var(--space-4) 0;
|
|
323
|
+
}
|
|
324
|
+
.grade-letter {
|
|
325
|
+
font-size: 3rem; font-weight: 800; line-height: 1; min-width: 56px; text-align: center;
|
|
326
|
+
}
|
|
327
|
+
.grade-a { color: #059669; } .grade-b { color: #10b981; }
|
|
328
|
+
.grade-c { color: #d97706; } .grade-d { color: #dc2626; } .grade-f { color: #991b1b; }
|
|
329
|
+
.grade-detail { display: flex; flex-direction: column; gap: 2px; }
|
|
330
|
+
.grade-level { font-weight: 600; font-size: 0.95rem; }
|
|
331
|
+
.grade-desc { font-size: 0.85rem; color: var(--color-text-secondary); }
|
|
332
|
+
|
|
333
|
+
/* ── Stat Grid ── */
|
|
334
|
+
.stat-grid {
|
|
335
|
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
336
|
+
gap: var(--space-4); margin: var(--space-4) 0;
|
|
337
|
+
}
|
|
338
|
+
.stat-card {
|
|
339
|
+
display: flex; flex-direction: column; padding: var(--space-4) var(--space-5);
|
|
340
|
+
background: var(--color-surface); border: 1px solid var(--color-border);
|
|
341
|
+
border-radius: var(--radius-lg); box-shadow: var(--shadow-sm);
|
|
342
|
+
}
|
|
343
|
+
.stat-label {
|
|
344
|
+
font-size: 11px; font-weight: 600; color: var(--color-muted);
|
|
345
|
+
text-transform: uppercase; letter-spacing: 0.04em;
|
|
346
|
+
}
|
|
347
|
+
.stat-value { font-size: 1.75rem; font-weight: 700; margin-top: var(--space-1); line-height: 1.2; }
|
|
348
|
+
.stat-high { color: var(--severity-high); }
|
|
349
|
+
.stat-medium { color: var(--severity-medium); }
|
|
350
|
+
.stat-low { color: var(--severity-low); }
|
|
351
|
+
|
|
352
|
+
/* ── Severity Badges ── */
|
|
353
|
+
.badge {
|
|
354
|
+
display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px;
|
|
355
|
+
font-size: 11px; font-weight: 600; letter-spacing: 0.02em;
|
|
356
|
+
white-space: nowrap; vertical-align: middle;
|
|
357
|
+
}
|
|
358
|
+
.badge-high { background: var(--severity-high-bg); color: var(--severity-high); border: 1px solid var(--severity-high-border); }
|
|
359
|
+
.badge-medium { background: var(--severity-medium-bg); color: var(--severity-medium); border: 1px solid var(--severity-medium-border); }
|
|
360
|
+
.badge-low { background: var(--severity-low-bg); color: var(--severity-low); border: 1px solid var(--severity-low-border); }
|
|
361
|
+
.escalation-tag { font-size: 10px; color: #d97706; cursor: help; margin-left: 2px; vertical-align: middle; }
|
|
362
|
+
|
|
363
|
+
/* ── Findings: Details/Summary ── */
|
|
364
|
+
details.finding-card {
|
|
365
|
+
margin: var(--space-3) 0; border: 1px solid var(--color-border);
|
|
366
|
+
border-radius: var(--radius-lg); background: var(--color-surface);
|
|
367
|
+
overflow: hidden; box-shadow: var(--shadow-sm); transition: box-shadow 150ms ease;
|
|
368
|
+
}
|
|
369
|
+
details.finding-card:hover { box-shadow: var(--shadow-md); }
|
|
370
|
+
details.finding-card[data-severity="high"] { border-left: 3px solid var(--severity-high); }
|
|
371
|
+
details.finding-card[data-severity="medium"] { border-left: 3px solid var(--severity-medium); }
|
|
372
|
+
details.finding-card[data-severity="low"] { border-left: 3px solid var(--severity-low); }
|
|
373
|
+
|
|
374
|
+
details.finding-card summary {
|
|
375
|
+
cursor: pointer; padding: var(--space-3) var(--space-4) var(--space-3) var(--space-8);
|
|
376
|
+
list-style: none; position: relative; font-size: 0.9rem;
|
|
377
|
+
display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap;
|
|
378
|
+
user-select: none;
|
|
379
|
+
}
|
|
380
|
+
details.finding-card summary::-webkit-details-marker,
|
|
381
|
+
details.finding-card summary::marker { display: none; }
|
|
382
|
+
details.finding-card summary::before {
|
|
383
|
+
content: ""; position: absolute; left: 14px; top: 50%;
|
|
384
|
+
width: 6px; height: 6px; border-right: 2px solid var(--color-muted);
|
|
385
|
+
border-bottom: 2px solid var(--color-muted);
|
|
386
|
+
transform: translateY(-50%) rotate(-45deg); transition: transform 150ms ease;
|
|
387
|
+
}
|
|
388
|
+
details.finding-card[open] summary::before { transform: translateY(-60%) rotate(45deg); }
|
|
389
|
+
details.finding-card summary:hover { background: #f8fafc; }
|
|
390
|
+
details.finding-card[open] summary {
|
|
391
|
+
background: #f1f5f9; border-bottom: 1px solid var(--color-border);
|
|
392
|
+
}
|
|
393
|
+
details.finding-card > *:not(summary) { padding: var(--space-3) var(--space-4); }
|
|
394
|
+
|
|
395
|
+
/* Legacy details (config, CI, etc.) */
|
|
396
|
+
details:not(.finding-card) {
|
|
397
|
+
margin: var(--space-3) 0; border: 1px solid var(--color-border);
|
|
398
|
+
border-radius: var(--radius-lg); background: var(--color-surface); overflow: hidden;
|
|
399
|
+
}
|
|
400
|
+
details:not(.finding-card) summary {
|
|
401
|
+
cursor: pointer; padding: var(--space-3) var(--space-4) var(--space-3) var(--space-8);
|
|
402
|
+
list-style: none; position: relative; font-weight: 500;
|
|
403
|
+
}
|
|
404
|
+
details:not(.finding-card) summary::-webkit-details-marker,
|
|
405
|
+
details:not(.finding-card) summary::marker { display: none; }
|
|
406
|
+
details:not(.finding-card) summary::before {
|
|
407
|
+
content: ""; position: absolute; left: 14px; top: 50%;
|
|
408
|
+
width: 6px; height: 6px; border-right: 2px solid var(--color-muted);
|
|
409
|
+
border-bottom: 2px solid var(--color-muted);
|
|
410
|
+
transform: translateY(-50%) rotate(-45deg); transition: transform 150ms ease;
|
|
411
|
+
}
|
|
412
|
+
details:not(.finding-card)[open] summary::before { transform: translateY(-60%) rotate(45deg); }
|
|
413
|
+
details:not(.finding-card) summary:hover { background: #f8fafc; }
|
|
414
|
+
details:not(.finding-card)[open] summary { border-bottom: 1px solid var(--color-border); }
|
|
415
|
+
details:not(.finding-card) > *:not(summary) { padding: var(--space-3) var(--space-6); }
|
|
416
|
+
details:not(.finding-card) > table {
|
|
417
|
+
width: calc(100% - var(--space-6) * 2); margin: var(--space-3) var(--space-6);
|
|
418
|
+
padding: 0; box-sizing: border-box;
|
|
419
|
+
}
|
|
420
|
+
details:not(.finding-card) > ul,
|
|
421
|
+
details:not(.finding-card) > p + ul { padding-left: calc(var(--space-6) + var(--space-5)); padding-right: var(--space-6); }
|
|
422
|
+
|
|
423
|
+
/* ── Finding sub-blocks ── */
|
|
424
|
+
.finding-block {
|
|
425
|
+
margin: var(--space-3) 0; border: 1px solid var(--color-border);
|
|
426
|
+
border-radius: var(--radius-md); background: var(--color-surface); overflow: hidden;
|
|
427
|
+
}
|
|
428
|
+
.finding-block-title {
|
|
429
|
+
padding: var(--space-2) var(--space-3); font-weight: 600; font-size: 12px;
|
|
430
|
+
text-transform: uppercase; letter-spacing: 0.03em; color: var(--color-text-secondary);
|
|
431
|
+
background: #f8fafc; border-bottom: 1px solid var(--color-border);
|
|
432
|
+
}
|
|
433
|
+
.finding-block-body { padding: var(--space-3) var(--space-4); font-size: 0.9em; }
|
|
434
|
+
.finding-block-body p { margin: var(--space-2) 0; }
|
|
435
|
+
.finding-block-body ul { margin: var(--space-2) 0; padding-left: var(--space-5); }
|
|
436
|
+
.finding-block-body li { margin: var(--space-1) 0; }
|
|
437
|
+
.finding-block-body table { margin: 0; font-size: 12px; table-layout: fixed; }
|
|
438
|
+
.finding-block-body table th:first-child,
|
|
439
|
+
.finding-block-body table td:first-child { width: 30%; }
|
|
440
|
+
.finding-block-body table td:last-child { text-align: left; white-space: normal; }
|
|
441
|
+
.finding-block-body table td code { word-break: break-all; white-space: pre-wrap; }
|
|
442
|
+
|
|
443
|
+
/* ── Filter Bar ── */
|
|
444
|
+
.filter-bar {
|
|
445
|
+
display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
|
|
446
|
+
margin: var(--space-4) 0 var(--space-6); padding: var(--space-3) var(--space-4);
|
|
447
|
+
background: var(--color-surface); border: 1px solid var(--color-border);
|
|
448
|
+
border-radius: var(--radius-lg);
|
|
449
|
+
}
|
|
450
|
+
.filter-bar label {
|
|
451
|
+
font-size: 12px; font-weight: 600; color: var(--color-muted);
|
|
452
|
+
text-transform: uppercase; letter-spacing: 0.04em;
|
|
453
|
+
}
|
|
454
|
+
.filter-btn {
|
|
455
|
+
padding: var(--space-1) var(--space-3); border: 1px solid var(--color-border);
|
|
456
|
+
border-radius: 9999px; background: var(--color-surface); color: var(--color-text-secondary);
|
|
457
|
+
font-size: 12px; font-weight: 500; font-family: inherit; cursor: pointer;
|
|
458
|
+
transition: all 120ms ease;
|
|
459
|
+
}
|
|
460
|
+
.filter-btn:hover { background: var(--color-border-light); }
|
|
461
|
+
.filter-btn.active { background: var(--color-accent); color: #fff; border-color: var(--color-accent); }
|
|
462
|
+
.filter-search {
|
|
463
|
+
margin-left: auto; padding: var(--space-1) var(--space-3);
|
|
464
|
+
border: 1px solid var(--color-border); border-radius: var(--radius-md);
|
|
465
|
+
font-size: 13px; font-family: inherit; color: var(--color-text);
|
|
466
|
+
background: var(--color-surface); min-width: 180px;
|
|
467
|
+
}
|
|
468
|
+
.filter-search::placeholder { color: var(--color-muted); }
|
|
469
|
+
.filter-search:focus {
|
|
470
|
+
outline: none; border-color: var(--color-accent);
|
|
471
|
+
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/* ── Report Footer ── */
|
|
475
|
+
.report-footer-logo { display: block; margin: var(--space-3) auto; max-width: 200px; width: 100%; height: auto; }
|
|
476
|
+
.report-footer-text { margin: var(--space-2) 0; color: var(--color-muted); font-size: 0.9em; text-align: center; }
|
|
477
|
+
|
|
478
|
+
/* ── Back to Top ── */
|
|
479
|
+
.back-to-top {
|
|
480
|
+
position: fixed; bottom: var(--space-6); right: var(--space-6);
|
|
481
|
+
width: 40px; height: 40px; border-radius: 50%;
|
|
482
|
+
border: 1px solid var(--color-border); background: var(--color-surface);
|
|
483
|
+
color: var(--color-text-secondary); font-size: 18px; cursor: pointer;
|
|
484
|
+
box-shadow: var(--shadow-md); opacity: 0; transform: translateY(10px);
|
|
485
|
+
transition: opacity 200ms ease, transform 200ms ease;
|
|
486
|
+
z-index: 20; display: flex; align-items: center; justify-content: center;
|
|
487
|
+
}
|
|
488
|
+
.back-to-top.visible { opacity: 1; transform: translateY(0); }
|
|
489
|
+
.back-to-top:hover { background: var(--color-accent); color: #fff; border-color: var(--color-accent); }
|
|
490
|
+
|
|
491
|
+
.hidden { display: none !important; }
|
|
492
|
+
|
|
493
|
+
/* ── Section Cards ── */
|
|
494
|
+
.section-card {
|
|
495
|
+
background: #e2e8f0;
|
|
496
|
+
border: 1px solid var(--color-border);
|
|
497
|
+
border-radius: var(--radius-xl);
|
|
498
|
+
padding: var(--space-6);
|
|
499
|
+
margin: var(--space-3) 0 var(--space-4);
|
|
500
|
+
box-shadow: var(--shadow-sm);
|
|
501
|
+
}
|
|
502
|
+
.section-card > *:first-child { margin-top: 0; }
|
|
503
|
+
.section-card > *:last-child { margin-bottom: 0; }
|
|
504
|
+
.section-card hr { display: none; }
|
|
505
|
+
|
|
506
|
+
/* ── Theme Toggle ── */
|
|
507
|
+
.theme-toggle {
|
|
508
|
+
display: flex; align-items: center; gap: var(--space-3);
|
|
509
|
+
padding: 0 0 var(--space-2); margin-bottom: var(--space-2);
|
|
510
|
+
}
|
|
511
|
+
.theme-toggle-track {
|
|
512
|
+
position: relative; width: 40px; min-width: 40px; max-width: 40px;
|
|
513
|
+
height: 22px; flex-shrink: 0;
|
|
514
|
+
background: #94a3b8; border-radius: 9999px;
|
|
515
|
+
cursor: pointer; transition: background 200ms ease;
|
|
516
|
+
border: none; padding: 0; margin: 0;
|
|
517
|
+
}
|
|
518
|
+
.theme-toggle-track::after {
|
|
519
|
+
content: ''; position: absolute; top: 3px; left: 3px;
|
|
520
|
+
width: 16px; height: 16px; border-radius: 50%;
|
|
521
|
+
background: #ffffff; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
|
522
|
+
transition: transform 200ms ease;
|
|
523
|
+
}
|
|
524
|
+
[data-theme="dark"] .theme-toggle-track { background: var(--color-accent); }
|
|
525
|
+
[data-theme="dark"] .theme-toggle-track::after { transform: translateX(18px); background: #1e293b; }
|
|
526
|
+
.theme-toggle-label {
|
|
527
|
+
font-size: 12px; color: #94a3b8; user-select: none;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/* ── Report Meta ── */
|
|
531
|
+
.report-meta {
|
|
532
|
+
display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-5);
|
|
533
|
+
padding: var(--space-3) 0; margin: var(--space-2) 0 var(--space-4);
|
|
534
|
+
font-size: 13px; color: var(--color-text-secondary);
|
|
535
|
+
border-bottom: 3px solid #4F7BE3;
|
|
536
|
+
}
|
|
537
|
+
.report-meta .meta-item { white-space: nowrap; }
|
|
538
|
+
.report-meta code { font-size: 12px; }
|
|
539
|
+
|
|
540
|
+
/* ── Hero Card ── */
|
|
541
|
+
.hero-card {
|
|
542
|
+
background: var(--color-surface); border: 1px solid var(--color-border);
|
|
543
|
+
border-radius: var(--radius-xl); padding: var(--space-6);
|
|
544
|
+
box-shadow: var(--shadow-md); margin: var(--space-4) 0;
|
|
545
|
+
}
|
|
546
|
+
.hero-grade {
|
|
547
|
+
display: flex; align-items: center; gap: var(--space-4);
|
|
548
|
+
padding-bottom: var(--space-5); border-bottom: 2px solid #94a3b8;
|
|
549
|
+
margin-bottom: var(--space-5);
|
|
550
|
+
}
|
|
551
|
+
.hero-stats {
|
|
552
|
+
display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-4);
|
|
553
|
+
}
|
|
554
|
+
.hero-stats .stat-card { box-shadow: none; border: 1px solid var(--color-border-light); }
|
|
555
|
+
.hero-footnote {
|
|
556
|
+
margin-top: var(--space-4); padding-top: var(--space-3);
|
|
557
|
+
border-top: 2px solid #94a3b8;
|
|
558
|
+
font-size: 12px; color: var(--color-muted); font-style: italic;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/* ── Severity Matrix ── */
|
|
562
|
+
.severity-matrix table { font-size: 13px; }
|
|
563
|
+
.severity-matrix td:not(:first-child),
|
|
564
|
+
.severity-matrix th:not(:first-child) { text-align: center; width: 80px; }
|
|
565
|
+
.severity-matrix td:first-child { font-weight: 500; }
|
|
566
|
+
|
|
567
|
+
/* ── Effort Badges ── */
|
|
568
|
+
.effort-quick { background: #ecfdf5; color: #059669; border: 1px solid #a7f3d0; }
|
|
569
|
+
.effort-strategic { background: #eff6ff; color: #2563eb; border: 1px solid #bfdbfe; }
|
|
570
|
+
|
|
571
|
+
/* ── Config Summary ── */
|
|
572
|
+
.config-summary { font-size: 13px; color: var(--color-text-secondary); margin: var(--space-2) 0; }
|
|
573
|
+
|
|
574
|
+
/* ── Filter Count ── */
|
|
575
|
+
.filter-count { font-size: 12px; color: var(--color-muted); margin: 0 0 var(--space-3); min-height: 16px; }
|
|
576
|
+
|
|
577
|
+
/* ── Stat Card Severity Tinting ── */
|
|
578
|
+
.stat-card-high { border-left: 3px solid var(--severity-high); }
|
|
579
|
+
.stat-card-medium { border-left: 3px solid var(--severity-medium); }
|
|
580
|
+
.stat-card-low { border-left: 3px solid var(--severity-low); }
|
|
581
|
+
|
|
582
|
+
/* ── Dark Theme ── */
|
|
583
|
+
[data-theme="dark"] {
|
|
584
|
+
--color-bg: #020617;
|
|
585
|
+
--color-surface: #1e293b;
|
|
586
|
+
--color-text: #cccccc;
|
|
587
|
+
--color-text-secondary: #94a3b8;
|
|
588
|
+
--color-muted: #64748b;
|
|
589
|
+
--color-border: #334155;
|
|
590
|
+
--color-border-light: #4F7BE3;
|
|
591
|
+
--color-accent: #4F7BE3;
|
|
592
|
+
--color-accent-light: rgba(99, 102, 241, 0.1);
|
|
593
|
+
--color-accent-hover: #6366f1;
|
|
594
|
+
--severity-high-bg: rgba(220, 38, 38, 0.15);
|
|
595
|
+
--severity-high-border: rgba(220, 38, 38, 0.3);
|
|
596
|
+
--severity-medium-bg: rgba(217, 119, 6, 0.15);
|
|
597
|
+
--severity-medium-border: rgba(217, 119, 6, 0.3);
|
|
598
|
+
--severity-low-bg: rgba(37, 99, 235, 0.15);
|
|
599
|
+
--severity-low-border: rgba(37, 99, 235, 0.3);
|
|
600
|
+
--shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
|
|
601
|
+
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.3);
|
|
602
|
+
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.5), 0 4px 6px rgba(0,0,0,0.3);
|
|
603
|
+
}
|
|
604
|
+
[data-theme="dark"] code { background: #334155; border-color: #475569; color: #e2e8f0; }
|
|
605
|
+
[data-theme="dark"] pre { background: #0a0f1a; border-color: #1e293b; }
|
|
606
|
+
[data-theme="dark"] blockquote { background: rgba(99, 102, 241, 0.08); border-left-color: var(--color-accent); }
|
|
607
|
+
[data-theme="dark"] table th { background: #1e293b; }
|
|
608
|
+
[data-theme="dark"] table td { background: var(--color-surface); }
|
|
609
|
+
[data-theme="dark"] table tr:hover td { background: rgba(255,255,255,0.04); }
|
|
610
|
+
[data-theme="dark"] tbody tr:hover td { background: #283548; }
|
|
611
|
+
[data-theme="dark"] details.finding-card summary:hover { background: #283548; }
|
|
612
|
+
[data-theme="dark"] details.finding-card[open] summary { background: #1e293b; border-bottom-color: var(--color-border); }
|
|
613
|
+
[data-theme="dark"] details:not(.finding-card) summary:hover { background: #283548; }
|
|
614
|
+
[data-theme="dark"] .filter-btn:hover { background: #283548; }
|
|
615
|
+
[data-theme="dark"] .finding-block-title { background: #1e293b; }
|
|
616
|
+
[data-theme="dark"] .filter-search { background: var(--color-surface); color: var(--color-text); border-color: var(--color-border); }
|
|
617
|
+
[data-theme="dark"] .filter-search:focus { box-shadow: 0 0 0 3px rgba(129, 140, 248, 0.2); }
|
|
618
|
+
[data-theme="dark"] .hero-card { border-color: var(--color-border); }
|
|
619
|
+
[data-theme="dark"] .hero-stats .stat-card { border-color: var(--color-border); }
|
|
620
|
+
[data-theme="dark"] .effort-quick { background: rgba(5, 150, 105, 0.12); color: #34d399; border-color: rgba(5, 150, 105, 0.3); }
|
|
621
|
+
[data-theme="dark"] .effort-strategic { background: rgba(37, 99, 235, 0.12); color: #60a5fa; border-color: rgba(37, 99, 235, 0.3); }
|
|
622
|
+
[data-theme="dark"] .section-card { background: #162033; }
|
|
623
|
+
[data-theme="dark"] .config-summary { color: var(--color-muted); }
|
|
624
|
+
|
|
625
|
+
/* ── Print ── */
|
|
626
|
+
@media print {
|
|
627
|
+
.sidebar, .back-to-top, .filter-bar, .sidebar-actions, .theme-toggle { display: none !important; }
|
|
628
|
+
.report-main { margin-left: 0 !important; padding: 0 !important; }
|
|
629
|
+
body { background: #fff; font-size: 12px; color: #0f172a; }
|
|
630
|
+
.report-layout { display: block; }
|
|
631
|
+
details { break-inside: avoid; }
|
|
632
|
+
details[open] summary { break-after: avoid; }
|
|
633
|
+
table { break-inside: avoid; }
|
|
634
|
+
h2, h3 { break-after: avoid; }
|
|
635
|
+
.finding-block { break-inside: avoid; }
|
|
636
|
+
a { color: inherit !important; }
|
|
637
|
+
pre { white-space: pre-wrap; word-break: break-all; }
|
|
638
|
+
.grade-card { box-shadow: none; border: 2px solid #e2e8f0; }
|
|
639
|
+
.section-card { box-shadow: none; border: 1px solid #e2e8f0; background: #e2e8f0; }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/* ── Responsive ── */
|
|
643
|
+
@media (max-width: 768px) {
|
|
644
|
+
.sidebar { display: none; }
|
|
645
|
+
.report-main { margin-left: 0; padding: var(--space-4); }
|
|
646
|
+
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
|
647
|
+
.filter-bar { flex-direction: column; align-items: stretch; }
|
|
648
|
+
.filter-search { margin-left: 0; min-width: auto; }
|
|
649
|
+
.section-card { padding: var(--space-4); }
|
|
650
|
+
.hero-stats { grid-template-columns: repeat(2, 1fr); }
|
|
651
|
+
.report-meta { gap: var(--space-1) var(--space-3); }
|
|
652
|
+
}
|
|
653
|
+
`;
|
|
654
|
+
/* ── Minimal vanilla JS for interactivity ──────────────────────── */
|
|
655
|
+
const js = `(function(){
|
|
656
|
+
'use strict';
|
|
657
|
+
// ── Build sidebar nav from h2 headings ──
|
|
658
|
+
var main=document.getElementById('report-main');
|
|
659
|
+
var nav=document.querySelector('.sidebar-nav');
|
|
660
|
+
if(!main||!nav) return;
|
|
661
|
+
var headings=main.querySelectorAll('h2');
|
|
662
|
+
var sections=[];
|
|
663
|
+
headings.forEach(function(h,i){
|
|
664
|
+
var raw=h.textContent||'';
|
|
665
|
+
// strip leading emoji/symbol characters
|
|
666
|
+
var text=raw.replace(/^[\\s\\u2600-\\u27BF\\uD83C-\\uDBFF\\uDC00-\\uDFFF\\uFE00-\\uFE0F]+/u,'').trim();
|
|
667
|
+
if(!text) text=raw.trim();
|
|
668
|
+
var id='section-'+i;
|
|
669
|
+
h.id=id;
|
|
670
|
+
sections.push({id:id,el:h});
|
|
671
|
+
var li=document.createElement('li');
|
|
672
|
+
var a=document.createElement('a');
|
|
673
|
+
a.href='#'+id;
|
|
674
|
+
a.textContent=text;
|
|
675
|
+
a.dataset.section=id;
|
|
676
|
+
li.appendChild(a);
|
|
677
|
+
nav.appendChild(li);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// ── Scroll-spy ──
|
|
681
|
+
var activeId=null;
|
|
682
|
+
function spy(){
|
|
683
|
+
var y=window.scrollY+100;
|
|
684
|
+
var cur=null;
|
|
685
|
+
for(var i=sections.length-1;i>=0;i--){
|
|
686
|
+
if(sections[i].el.offsetTop<=y){cur=sections[i].id;break;}
|
|
687
|
+
}
|
|
688
|
+
if(cur===activeId) return;
|
|
689
|
+
if(activeId){var p=nav.querySelector('a[data-section=\"'+activeId+'\"]');if(p)p.classList.remove('active');}
|
|
690
|
+
if(cur){var n=nav.querySelector('a[data-section=\"'+cur+'\"]');if(n)n.classList.add('active');}
|
|
691
|
+
activeId=cur;
|
|
692
|
+
}
|
|
693
|
+
window.addEventListener('scroll',spy,{passive:true});
|
|
694
|
+
spy();
|
|
695
|
+
|
|
696
|
+
// ── Wrap section content in cards ──
|
|
697
|
+
for(var s=0;s<sections.length;s++){
|
|
698
|
+
var hEl=sections[s].el;
|
|
699
|
+
var card=document.createElement('div');
|
|
700
|
+
card.className='section-card';
|
|
701
|
+
var nextH=sections[s+1]?sections[s+1].el:null;
|
|
702
|
+
if(hEl.nextSibling){hEl.parentNode.insertBefore(card,hEl.nextSibling);}
|
|
703
|
+
else{hEl.parentNode.appendChild(card);}
|
|
704
|
+
while(card.nextSibling){
|
|
705
|
+
var nx=card.nextSibling;
|
|
706
|
+
if(nx===nextH)break;
|
|
707
|
+
if(nx.nodeType===1&&nx.tagName==='HR'){nx.parentNode.removeChild(nx);continue;}
|
|
708
|
+
card.appendChild(nx);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── Back to top ──
|
|
713
|
+
var btt=document.getElementById('back-to-top');
|
|
714
|
+
if(btt){
|
|
715
|
+
window.addEventListener('scroll',function(){btt.classList.toggle('visible',window.scrollY>300);},{passive:true});
|
|
716
|
+
btt.addEventListener('click',function(){window.scrollTo({top:0,behavior:'smooth'});});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ── Expand / Collapse all ──
|
|
720
|
+
var expBtn=document.getElementById('btn-expand-all');
|
|
721
|
+
if(expBtn){
|
|
722
|
+
var open=false;
|
|
723
|
+
expBtn.addEventListener('click',function(){
|
|
724
|
+
open=!open;
|
|
725
|
+
document.querySelectorAll('details.finding-card').forEach(function(d){d.open=open;});
|
|
726
|
+
expBtn.textContent=open?'\\u229F Collapse all':'\\u229E Expand all';
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ── Theme toggle ──
|
|
731
|
+
var themeBtn=document.getElementById('theme-toggle');
|
|
732
|
+
if(themeBtn){
|
|
733
|
+
var html=document.documentElement;
|
|
734
|
+
themeBtn.addEventListener('click',function(){
|
|
735
|
+
var isDark=html.dataset.theme==='dark';
|
|
736
|
+
html.dataset.theme=isDark?'light':'dark';
|
|
737
|
+
localStorage.setItem('scantrix-theme',html.dataset.theme);
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ── Severity filter + search ──
|
|
742
|
+
var filterBtns=document.querySelectorAll('.filter-btn[data-severity]');
|
|
743
|
+
var searchBox=document.getElementById('finding-search');
|
|
744
|
+
var activeSev='all';
|
|
745
|
+
function applyFilters(){
|
|
746
|
+
var term=searchBox?searchBox.value.toLowerCase():'';
|
|
747
|
+
var cards=document.querySelectorAll('details.finding-card');
|
|
748
|
+
cards.forEach(function(f){
|
|
749
|
+
var sev=f.dataset.severity||'';
|
|
750
|
+
var txt=(f.querySelector('summary')||{}).textContent||'';
|
|
751
|
+
var sevOk=activeSev==='all'||sev===activeSev;
|
|
752
|
+
var txtOk=!term||txt.toLowerCase().indexOf(term)!==-1;
|
|
753
|
+
f.classList.toggle('hidden',!(sevOk&&txtOk));
|
|
754
|
+
});
|
|
755
|
+
// also hide/show severity group headings if all children hidden
|
|
756
|
+
document.querySelectorAll('h3').forEach(function(h){
|
|
757
|
+
var next=h.nextElementSibling;
|
|
758
|
+
if(!next||!next.classList.contains('finding-card')) return;
|
|
759
|
+
var anyVisible=false;
|
|
760
|
+
var el=h.nextElementSibling;
|
|
761
|
+
while(el&&el.tagName==='DETAILS'){
|
|
762
|
+
if(!el.classList.contains('hidden')) anyVisible=true;
|
|
763
|
+
el=el.nextElementSibling;
|
|
764
|
+
}
|
|
765
|
+
h.classList.toggle('hidden',!anyVisible);
|
|
766
|
+
});
|
|
767
|
+
var vc=0;
|
|
768
|
+
cards.forEach(function(c){if(!c.classList.contains('hidden'))vc++;});
|
|
769
|
+
var fc=document.getElementById('filter-count');
|
|
770
|
+
if(fc){
|
|
771
|
+
if(activeSev==='all'&&!term) fc.textContent='';
|
|
772
|
+
else fc.textContent='Showing '+vc+' of '+cards.length+' findings';
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
filterBtns.forEach(function(b){
|
|
776
|
+
b.addEventListener('click',function(){
|
|
777
|
+
activeSev=b.dataset.severity;
|
|
778
|
+
filterBtns.forEach(function(x){x.classList.toggle('active',x===b);});
|
|
779
|
+
applyFilters();
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
if(searchBox) searchBox.addEventListener('input',applyFilters);
|
|
783
|
+
})();`;
|
|
784
|
+
return `<!doctype html>
|
|
785
|
+
<html lang="en">
|
|
786
|
+
<head>
|
|
787
|
+
<meta charset="utf-8"/>
|
|
788
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
789
|
+
<title>${title}</title>${opts.faviconSvgDataUri ? `\n<link rel="icon" type="image/svg+xml" href="${opts.faviconSvgDataUri}"/>` : ''}
|
|
790
|
+
<style>${css}</style>
|
|
791
|
+
<script>(function(){var t=localStorage.getItem('scantrix-theme');if(t){document.documentElement.dataset.theme=t;}else if(window.matchMedia&&window.matchMedia('(prefers-color-scheme:dark)').matches){document.documentElement.dataset.theme='dark';}})();</script>
|
|
792
|
+
</head>
|
|
793
|
+
<body>
|
|
794
|
+
<div class="report-layout">
|
|
795
|
+
<nav class="sidebar" aria-label="Report navigation">
|
|
796
|
+
<div class="sidebar-brand">${opts.sidebarLogoSvg ? `<span class="brand-logo">${opts.sidebarLogoSvg}</span>` : '<span class="brand-text">Scantrix</span>'}</div>
|
|
797
|
+
<div class="sidebar-label">Sections</div>
|
|
798
|
+
<ul class="sidebar-nav" role="list"></ul>
|
|
799
|
+
<div class="sidebar-actions">
|
|
800
|
+
<div class="theme-toggle">
|
|
801
|
+
<button id="theme-toggle" class="theme-toggle-track" type="button" aria-label="Toggle theme"></button>
|
|
802
|
+
<span class="theme-toggle-label">Dark mode</span>
|
|
803
|
+
</div>
|
|
804
|
+
<button id="btn-expand-all" type="button">⊞ Expand all</button>
|
|
805
|
+
<button onclick="window.print()" type="button">🖨 Print report</button>
|
|
806
|
+
</div>
|
|
807
|
+
</nav>
|
|
808
|
+
<main id="report-main" class="report-main">
|
|
809
|
+
<div class="report-content">
|
|
810
|
+
${bodyHtml}
|
|
811
|
+
</div>
|
|
812
|
+
</main>
|
|
813
|
+
</div>
|
|
814
|
+
<button id="back-to-top" class="back-to-top" aria-label="Back to top" title="Back to top">↑</button>
|
|
815
|
+
<script>${js}</script>
|
|
816
|
+
</body>
|
|
817
|
+
</html>`;
|
|
818
|
+
}
|
|
819
|
+
/* ── Email‐friendly HTML wrapper (no sidebar / JS) ───────────────── */
|
|
820
|
+
function wrapEmailDocument(title, bodyHtml) {
|
|
821
|
+
const emailCss = `
|
|
822
|
+
:root {
|
|
823
|
+
--text: #1a1f36; --muted: #6b7280; --border: #e2e4e8;
|
|
824
|
+
--header: #111827; --accent: #4F7BE3;
|
|
825
|
+
}
|
|
826
|
+
body {
|
|
827
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
|
828
|
+
color: var(--text); line-height: 1.5; margin: 0; padding: 0;
|
|
829
|
+
background: #ffffff; font-size: 14px;
|
|
830
|
+
}
|
|
831
|
+
main { max-width: 720px; margin: 0 auto; padding: 16px; }
|
|
832
|
+
h1,h2,h3,h4 { line-height: 1.3; }
|
|
833
|
+
h1 { font-size: 1.5em; color: var(--header); }
|
|
834
|
+
h2 { font-size: 1.25em; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
|
|
835
|
+
h3 { font-size: 1.05em; } h4 { font-size: 0.95em; }
|
|
836
|
+
p { margin: 6px 0; } ul,ol { padding-left: 20px; } li { margin: 2px 0; }
|
|
837
|
+
hr { border:0; border-top: 1px solid var(--border); margin: 16px 0; }
|
|
838
|
+
a { color: var(--accent); text-decoration: none; }
|
|
839
|
+
code { font-family: Menlo,Consolas,monospace; font-size: 0.9em; background: #f3f4f6; padding: 0 3px; border-radius: 3px; }
|
|
840
|
+
pre { background: #f3f4f6; padding: 8px; border-radius: 4px; overflow-x: auto; font-size: 0.85em; }
|
|
841
|
+
pre code { background: transparent; padding: 0; }
|
|
842
|
+
table { width:100%; border-collapse: collapse; margin: 8px 0; font-size: 0.9em; }
|
|
843
|
+
th,td { border: 1px solid var(--border); padding: 6px 8px; text-align: left; }
|
|
844
|
+
th { background: #f9fafb; font-weight: 600; }
|
|
845
|
+
blockquote { margin: 8px 0; padding: 6px 12px; border-left: 3px solid #C7D2FE; background: #F3F4FF; border-radius: 4px; }
|
|
846
|
+
.report-footer-logo { display: block; margin: 10px auto; max-width: 200px; }
|
|
847
|
+
.report-footer-text { margin: 4px 0; color: var(--muted); font-size: 0.9em; text-align: center; }
|
|
848
|
+
.finding-block { margin: 8px 0; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
|
849
|
+
.finding-block-title { padding: 6px 10px; font-weight: 600; background: #f9fafb; border-bottom: 1px solid var(--border); font-size: 0.9em; }
|
|
850
|
+
.finding-block-body { padding: 8px 10px; font-size: 0.9em; }
|
|
851
|
+
.badge { display: inline-block; padding: 1px 6px; border-radius: 9999px; font-size: 11px; font-weight: 600; }
|
|
852
|
+
.badge-high { background: #fef2f2; color: #dc2626; } .badge-medium { background: #fffbeb; color: #d97706; } .badge-low { background: #eff6ff; color: #2563eb; }
|
|
853
|
+
.escalation-tag { font-size: 10px; color: #d97706; cursor: help; margin-left: 2px; }
|
|
854
|
+
img { max-width: 100%; }
|
|
855
|
+
`;
|
|
856
|
+
return `<!doctype html>
|
|
857
|
+
<html lang="en">
|
|
858
|
+
<head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
859
|
+
<title>${title}</title><style>${emailCss}</style></head>
|
|
860
|
+
<body><main>
|
|
861
|
+
${bodyHtml}
|
|
862
|
+
</main></body></html>`;
|
|
863
|
+
}
|
|
864
|
+
async function loadFooterLogoDataUri() {
|
|
865
|
+
// Prefer package-relative path, then fall back to cwd.
|
|
866
|
+
const candidates = [
|
|
867
|
+
path_1.default.join(__dirname, "..", "docs", "scantrix-logo-light.svg"),
|
|
868
|
+
path_1.default.join(process.cwd(), "docs", "scantrix-logo-light.svg"),
|
|
869
|
+
];
|
|
870
|
+
for (const p of candidates) {
|
|
871
|
+
try {
|
|
872
|
+
const bytes = await promises_1.default.readFile(p);
|
|
873
|
+
return `data:image/svg+xml;base64,${bytes.toString("base64")}`;
|
|
874
|
+
}
|
|
875
|
+
catch { /* try next */ }
|
|
876
|
+
}
|
|
877
|
+
return undefined;
|
|
878
|
+
}
|
|
879
|
+
async function loadSidebarLogoSvg() {
|
|
880
|
+
// Try package-relative docs/ first, then fall back to cwd docs/.
|
|
881
|
+
const candidates = [
|
|
882
|
+
path_1.default.join(__dirname, "..", "docs", "scantrix-logo.svg"),
|
|
883
|
+
path_1.default.join(__dirname, "..", "docs", "scantrix-logo-light.svg"),
|
|
884
|
+
path_1.default.join(process.cwd(), "docs", "scantrix-logo.svg"),
|
|
885
|
+
];
|
|
886
|
+
for (const p of candidates) {
|
|
887
|
+
try {
|
|
888
|
+
let svg = await promises_1.default.readFile(p, "utf-8");
|
|
889
|
+
// Strip XML declaration — not valid when inlined in HTML
|
|
890
|
+
svg = svg.replace(/<\?xml[^?]*\?>\s*/g, "").trim();
|
|
891
|
+
return svg;
|
|
892
|
+
}
|
|
893
|
+
catch { /* try next */ }
|
|
894
|
+
}
|
|
895
|
+
return undefined;
|
|
896
|
+
}
|
|
897
|
+
async function loadFaviconDataUri() {
|
|
898
|
+
const candidates = [
|
|
899
|
+
path_1.default.join(__dirname, "..", "docs", "high-res-icon.svg"),
|
|
900
|
+
path_1.default.join(process.cwd(), "docs", "high-res-icon.svg"),
|
|
901
|
+
path_1.default.join(__dirname, "..", "docs", "scantrix-icon.svg"),
|
|
902
|
+
path_1.default.join(process.cwd(), "docs", "scantrix-icon.svg"),
|
|
903
|
+
];
|
|
904
|
+
for (const p of candidates) {
|
|
905
|
+
try {
|
|
906
|
+
const bytes = await promises_1.default.readFile(p);
|
|
907
|
+
return `data:image/svg+xml;base64,${bytes.toString("base64")}`;
|
|
908
|
+
}
|
|
909
|
+
catch { /* try next */ }
|
|
910
|
+
}
|
|
911
|
+
return undefined;
|
|
912
|
+
}
|
|
913
|
+
function getTopActionItems(findings) {
|
|
914
|
+
const actions = [];
|
|
915
|
+
const highFindings = findings.filter(f => f.severity === "high").sort((a, b) => b.evidence.length - a.evidence.length);
|
|
916
|
+
// Prioritize high-severity items by impact
|
|
917
|
+
for (const f of highFindings) {
|
|
918
|
+
if (actions.length >= 3)
|
|
919
|
+
break;
|
|
920
|
+
if (f.findingId === "PW-FLAKE-001") {
|
|
921
|
+
const topFile = topFiles(f.evidence, 1)[0];
|
|
922
|
+
if (topFile) {
|
|
923
|
+
const fileName = topFile[0].split('/').pop() || topFile[0];
|
|
924
|
+
actions.push(`Remove ${topFile[1]} hard wait${topFile[1] > 1 ? 's' : ''} from ${fileName}`);
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
actions.push("Remove hard waits (waitForTimeout/setTimeout)");
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
else if (f.findingId === "PW-FLAKE-009") {
|
|
931
|
+
const n = f.totalOccurrences ?? f.evidence.length;
|
|
932
|
+
actions.push(`Remove ${n} debug pause${n > 1 ? 's' : ''} (page.pause)`);
|
|
933
|
+
}
|
|
934
|
+
else if (f.findingId === "PW-INSIGHT-001") {
|
|
935
|
+
actions.push("Fix root cause of flaky tests before relying on retries");
|
|
936
|
+
}
|
|
937
|
+
else if (f.findingId === "PW-INSIGHT-008") {
|
|
938
|
+
actions.push("Enable trace/video capture and publish test artifacts in CI");
|
|
939
|
+
}
|
|
940
|
+
else if (f.findingId === "PW-STABILITY-001") {
|
|
941
|
+
const n = f.totalOccurrences ?? f.evidence.length;
|
|
942
|
+
actions.push(`Add cleanup hooks to ${n} test file${n > 1 ? 's' : ''}`);
|
|
943
|
+
}
|
|
944
|
+
else if (f.findingId === "PW-STABILITY-003") {
|
|
945
|
+
const n = f.totalOccurrences ?? f.evidence.length;
|
|
946
|
+
actions.push(`Move page-based setup out of beforeAll in ${n} file${n > 1 ? 's' : ''}`);
|
|
947
|
+
}
|
|
948
|
+
else if (f.findingId === "CI-002") {
|
|
949
|
+
actions.push("Configure Playwright HTML report publishing in CI");
|
|
950
|
+
}
|
|
951
|
+
else if (f.findingId === "CI-007") {
|
|
952
|
+
actions.push(`Implement test sharding for faster CI (${f.evidence[0]?.snippet.match(/\d+/)?.[0] || 'many'} test files)`);
|
|
953
|
+
}
|
|
954
|
+
else if (f.findingId === "CI-009") {
|
|
955
|
+
actions.push("Publish test-results artifacts to debug retry failures");
|
|
956
|
+
}
|
|
957
|
+
else if (f.findingId === "ARCH-003") {
|
|
958
|
+
const n = f.totalOccurrences ?? f.evidence.length;
|
|
959
|
+
actions.push(`Extract shared code from ${n} test file${n > 1 ? 's' : ''} to utility modules`);
|
|
960
|
+
}
|
|
961
|
+
else if (f.findingId === "ARCH-005") {
|
|
962
|
+
actions.push("Move hardcoded credentials to environment variables");
|
|
963
|
+
}
|
|
964
|
+
else if (f.findingId === "DEP-002") {
|
|
965
|
+
const n = f.totalOccurrences ?? f.evidence.length;
|
|
966
|
+
actions.push(`Replace ${n} deprecated npm package${n > 1 ? 's' : ''} with supported alternatives`);
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
// Generic high-severity action
|
|
970
|
+
actions.push(f.recommendation.split('.')[0]);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
// Fill remaining slots with medium severity items
|
|
974
|
+
if (actions.length < 3) {
|
|
975
|
+
const mediumFindings = findings.filter(f => f.severity === "medium").sort((a, b) => (b.totalOccurrences ?? b.evidence.length) - (a.totalOccurrences ?? a.evidence.length));
|
|
976
|
+
for (const f of mediumFindings) {
|
|
977
|
+
if (actions.length >= 3)
|
|
978
|
+
break;
|
|
979
|
+
const n = f.totalOccurrences ?? f.evidence.length;
|
|
980
|
+
if (f.findingId === "PW-FLAKE-002" || f.findingId === "CY-FLAKE-003") {
|
|
981
|
+
actions.push(`Remove ${n} force-click pattern${n > 1 ? 's' : ''}`);
|
|
982
|
+
}
|
|
983
|
+
else if (f.findingId === "PW-STABILITY-001") {
|
|
984
|
+
actions.push(`Add cleanup hooks to ${n} test file${n > 1 ? 's' : ''}`);
|
|
985
|
+
}
|
|
986
|
+
else if (f.findingId === "PW-STABILITY-002") {
|
|
987
|
+
actions.push(`Organize ${n} test file${n > 1 ? 's' : ''} with describe blocks`);
|
|
988
|
+
}
|
|
989
|
+
else if (f.findingId === "CI-006") {
|
|
990
|
+
actions.push("Standardize WORKERS env across all CI pipelines");
|
|
991
|
+
}
|
|
992
|
+
else if (f.findingId === "PW-INSIGHT-002") {
|
|
993
|
+
actions.push("Review and reduce excessive test timeouts");
|
|
994
|
+
}
|
|
995
|
+
else if (f.findingId === "PW-INSIGHT-008") {
|
|
996
|
+
actions.push("Enable trace capture and artifact publishing");
|
|
997
|
+
}
|
|
998
|
+
else if (f.findingId === "CI-008") {
|
|
999
|
+
actions.push("Add --with-deps to playwright install command");
|
|
1000
|
+
}
|
|
1001
|
+
else if (f.findingId === "ARCH-001") {
|
|
1002
|
+
actions.push("Create BasePage class for consistent POM patterns");
|
|
1003
|
+
}
|
|
1004
|
+
else if (f.findingId === "ARCH-004") {
|
|
1005
|
+
actions.push(`Add proper TypeScript typing to ${n} fixture file${n > 1 ? 's' : ''}`);
|
|
1006
|
+
}
|
|
1007
|
+
else if (f.findingId === "PW-FLAKE-010") {
|
|
1008
|
+
actions.push(`Guard ${n} retrieval method${n > 1 ? 's' : ''} with web-first assertions`);
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
actions.push(f.recommendation.split('.')[0]);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return actions.slice(0, 3);
|
|
1016
|
+
}
|
|
1017
|
+
function getTopHotspotsAcrossFindings(findings) {
|
|
1018
|
+
// Aggregate all files across findings, weighted by severity
|
|
1019
|
+
const fileStats = new Map();
|
|
1020
|
+
for (const f of findings) {
|
|
1021
|
+
// Skip CI findings (they're pipeline-level, not code hotspots)
|
|
1022
|
+
if (f.findingId.startsWith("CI-") || f.findingId.startsWith("PW-INSIGHT-"))
|
|
1023
|
+
continue;
|
|
1024
|
+
const weight = (0, scoring_1.severityScore)(f.severity);
|
|
1025
|
+
for (const e of f.evidence) {
|
|
1026
|
+
// Normalize the file path to avoid path casing issues
|
|
1027
|
+
const normalizedFile = e.file.replace(/\\/g, "/");
|
|
1028
|
+
// Skip summary/meta evidence lines
|
|
1029
|
+
if (normalizedFile.includes("(CI summary)") || normalizedFile.includes("(correlation"))
|
|
1030
|
+
continue;
|
|
1031
|
+
const existing = fileStats.get(normalizedFile) || { count: 0, findingIds: new Set(), highCount: 0, mediumCount: 0, lowCount: 0 };
|
|
1032
|
+
existing.count += weight;
|
|
1033
|
+
existing.findingIds.add(f.findingId);
|
|
1034
|
+
if (f.severity === "high")
|
|
1035
|
+
existing.highCount++;
|
|
1036
|
+
else if (f.severity === "medium")
|
|
1037
|
+
existing.mediumCount++;
|
|
1038
|
+
else
|
|
1039
|
+
existing.lowCount++;
|
|
1040
|
+
fileStats.set(normalizedFile, existing);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// Convert to sorted array
|
|
1044
|
+
return [...fileStats.entries()]
|
|
1045
|
+
.map(([file, stats]) => ({
|
|
1046
|
+
file,
|
|
1047
|
+
issueCount: stats.count,
|
|
1048
|
+
findingTypes: [...stats.findingIds],
|
|
1049
|
+
highCount: stats.highCount,
|
|
1050
|
+
mediumCount: stats.mediumCount,
|
|
1051
|
+
lowCount: stats.lowCount,
|
|
1052
|
+
}))
|
|
1053
|
+
.sort((a, b) => b.issueCount - a.issueCount)
|
|
1054
|
+
.slice(0, 5);
|
|
1055
|
+
}
|
|
1056
|
+
function buildCategoryMatrix(findings) {
|
|
1057
|
+
const matrix = new Map();
|
|
1058
|
+
for (const f of findings) {
|
|
1059
|
+
const category = f.findingId.startsWith("PW-FLAKE") ? "Flakiness" :
|
|
1060
|
+
f.findingId.startsWith("PW-STABILITY") ? "Stability" :
|
|
1061
|
+
f.findingId.startsWith("PW-LOC") ? "Locators" :
|
|
1062
|
+
f.findingId.startsWith("PW-PERF") ? "Performance" :
|
|
1063
|
+
f.findingId.startsWith("PW-INSIGHT") ? "Insights" :
|
|
1064
|
+
f.findingId.startsWith("CI-") ? "CI/CD" :
|
|
1065
|
+
f.findingId.startsWith("ARCH-") ? "Architecture" :
|
|
1066
|
+
f.findingId.startsWith("DEP-") ? "Dependencies" :
|
|
1067
|
+
"Other";
|
|
1068
|
+
const entry = matrix.get(category) || { high: 0, medium: 0, low: 0 };
|
|
1069
|
+
if (f.severity === "high")
|
|
1070
|
+
entry.high++;
|
|
1071
|
+
else if (f.severity === "medium")
|
|
1072
|
+
entry.medium++;
|
|
1073
|
+
else
|
|
1074
|
+
entry.low++;
|
|
1075
|
+
matrix.set(category, entry);
|
|
1076
|
+
}
|
|
1077
|
+
return matrix;
|
|
1078
|
+
}
|
|
1079
|
+
function calculateRemediationTime(findings) {
|
|
1080
|
+
let toGoodMin = 0, toGoodMax = 0;
|
|
1081
|
+
let toAllMin = 0, toAllMax = 0;
|
|
1082
|
+
const highSeverity = findings.filter(f => f.severity === "high");
|
|
1083
|
+
const mediumSeverity = findings.filter(f => f.severity === "medium");
|
|
1084
|
+
const lowSeverity = findings.filter(f => f.severity === "low");
|
|
1085
|
+
// Time estimates per finding type (in hours)
|
|
1086
|
+
const timeEstimates = {
|
|
1087
|
+
"PW-FLAKE-001": { min: 1, max: 4 }, // Hard waits
|
|
1088
|
+
"PW-FLAKE-009": { min: 0.25, max: 0.5 }, // Debug pauses - simple removal
|
|
1089
|
+
"PW-FLAKE-002": { min: 0.5, max: 2 }, // Force clicks
|
|
1090
|
+
"CY-FLAKE-003": { min: 0.5, max: 2 }, // Cypress force clicks
|
|
1091
|
+
"PW-LOC-001": { min: 1, max: 4 }, // XPath selectors
|
|
1092
|
+
"PW-STABILITY-001": { min: 2, max: 4 }, // Missing hooks
|
|
1093
|
+
"PW-STABILITY-001a": { min: 0.5, max: 1 }, // beforeAll/afterAll info
|
|
1094
|
+
"PW-STABILITY-003": { min: 2, max: 6 }, // page fixture used in beforeAll
|
|
1095
|
+
"CI-005": { min: 0.5, max: 1 }, // Missing cache
|
|
1096
|
+
"CI-006": { min: 0.5, max: 1 }, // Inconsistent workers
|
|
1097
|
+
"CI-007": { min: 2, max: 4 }, // Missing sharding
|
|
1098
|
+
"PW-INSIGHT-001": { min: 2, max: 6 }, // Locator-timeout correlation
|
|
1099
|
+
"PW-INSIGHT-002": { min: 0.25, max: 0.5 }, // Excessive timeout
|
|
1100
|
+
"PW-INSIGHT-003": { min: 4, max: 8 }, // Complex test files
|
|
1101
|
+
"ARCH-001": { min: 2, max: 4 }, // Missing BasePage
|
|
1102
|
+
"ARCH-002": { min: 1, max: 4 }, // Public locators
|
|
1103
|
+
"ARCH-003": { min: 2, max: 4 }, // Test coupling
|
|
1104
|
+
"ARCH-004": { min: 1, max: 2 }, // Missing fixture typing
|
|
1105
|
+
"ARCH-005": { min: 0.5, max: 1 }, // Hardcoded credentials
|
|
1106
|
+
"ARCH-005a": { min: 1, max: 3 }, // Hardcoded emails
|
|
1107
|
+
"ARCH-006": { min: 4, max: 8 }, // No API mocking
|
|
1108
|
+
"ARCH-007": { min: 2, max: 4 }, // No custom error types
|
|
1109
|
+
"ARCH-008": { min: 1, max: 3 }, // Large inline test data
|
|
1110
|
+
"ARCH-009": { min: 0.5, max: 2 }, // Inline config overrides
|
|
1111
|
+
"PW-FLAKE-010": { min: 1, max: 3 }, // Retrieval methods - add guards
|
|
1112
|
+
"PW-FLAKE-003": { min: 0.5, max: 1 }, // test.only/skip
|
|
1113
|
+
"PW-FLAKE-004": { min: 1, max: 3 }, // networkidle
|
|
1114
|
+
"PW-STABILITY-004": { min: 2, max: 6 }, // Tests without assertions
|
|
1115
|
+
"PW-STABILITY-005": { min: 1, max: 2 }, // Browser resource leaks
|
|
1116
|
+
"PW-STABILITY-006": { min: 0.5, max: 2 }, // Too many assertions
|
|
1117
|
+
"SE-STABILITY-001": { min: 1, max: 2 }, // WebDriver resource leaks
|
|
1118
|
+
"ARCH-010": { min: 1, max: 3 }, // Hardcoded inline test data
|
|
1119
|
+
"PW-LOC-002": { min: 1, max: 3 }, // Deep CSS nesting
|
|
1120
|
+
"PW-LOC-003": { min: 0.5, max: 2 }, // nth/first/last
|
|
1121
|
+
"PW-PERF-003": { min: 1, max: 3 }, // networkidle + high timeout
|
|
1122
|
+
"CI-011": { min: 0.25, max: 0.5 }, // Missing playwright install
|
|
1123
|
+
"CI-012": { min: 0.25, max: 0.5 }, // CI env mismatch
|
|
1124
|
+
"CI-013": { min: 0.25, max: 0.5 }, // Pipeline timeout
|
|
1125
|
+
"CI-014": { min: 0.25, max: 0.5 }, // No reporter override
|
|
1126
|
+
"DEP-001": { min: 1, max: 4 }, // Outdated dependencies
|
|
1127
|
+
"DEP-002": { min: 1, max: 4 }, // Deprecated npm packages
|
|
1128
|
+
};
|
|
1129
|
+
// Calculate time to address high severity issues (to reach "Good" grade)
|
|
1130
|
+
for (const f of highSeverity) {
|
|
1131
|
+
const estimate = timeEstimates[f.findingId] ?? { min: 1, max: 3 };
|
|
1132
|
+
// Scale by file count for larger scope issues
|
|
1133
|
+
const fileCount = f.affectedFiles ?? new Set(f.evidence.map(e => e.file)).size;
|
|
1134
|
+
const scale = Math.min(Math.max(1, fileCount / 3), 3); // Cap scaling at 3x
|
|
1135
|
+
toGoodMin += estimate.min * scale;
|
|
1136
|
+
toGoodMax += estimate.max * scale;
|
|
1137
|
+
}
|
|
1138
|
+
// Calculate total time for all issues
|
|
1139
|
+
for (const f of [...highSeverity, ...mediumSeverity, ...lowSeverity]) {
|
|
1140
|
+
const estimate = timeEstimates[f.findingId] ?? { min: 1, max: 3 };
|
|
1141
|
+
const fileCount = f.affectedFiles ?? new Set(f.evidence.map(e => e.file)).size;
|
|
1142
|
+
const scale = Math.min(Math.max(1, fileCount / 3), 3);
|
|
1143
|
+
toAllMin += estimate.min * scale;
|
|
1144
|
+
toAllMax += estimate.max * scale;
|
|
1145
|
+
}
|
|
1146
|
+
return {
|
|
1147
|
+
toGoodGrade: { minHours: Math.round(toGoodMin), maxHours: Math.round(toGoodMax) },
|
|
1148
|
+
toAllResolved: { minHours: Math.round(toAllMin), maxHours: Math.round(toAllMax) },
|
|
1149
|
+
highPriorityCount: highSeverity.length,
|
|
1150
|
+
totalIssueCount: findings.length,
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
function formatTimeRange(min, max) {
|
|
1154
|
+
if (min === 0 && max === 0)
|
|
1155
|
+
return "No remediation needed";
|
|
1156
|
+
if (max <= 4)
|
|
1157
|
+
return `${min}-${max} hours`;
|
|
1158
|
+
if (max <= 16)
|
|
1159
|
+
return `${Math.round(min / 4)}-${Math.round(max / 4)} half-days`;
|
|
1160
|
+
return `${Math.round(min / 8)}-${Math.round(max / 8)} days`;
|
|
1161
|
+
}
|
|
1162
|
+
function categorizeFindings(findings) {
|
|
1163
|
+
const quickWins = [];
|
|
1164
|
+
const largerEfforts = [];
|
|
1165
|
+
for (const f of findings) {
|
|
1166
|
+
const fileCount = f.affectedFiles ?? new Set(f.evidence.map(e => e.file)).size;
|
|
1167
|
+
const occurrences = f.totalOccurrences ?? f.evidence.length;
|
|
1168
|
+
// Categorize based on finding type and scope
|
|
1169
|
+
switch (f.findingId) {
|
|
1170
|
+
case "PW-FLAKE-002": // Force clicks - usually few occurrences, easy fix
|
|
1171
|
+
case "CY-FLAKE-003": // Cypress force clicks
|
|
1172
|
+
if (occurrences <= 5) {
|
|
1173
|
+
quickWins.push({
|
|
1174
|
+
finding: `Fix ${occurrences} force-click${occurrences > 1 ? 's' : ''}`,
|
|
1175
|
+
effort: "~30 min",
|
|
1176
|
+
impact: "Reduces hidden UI issues",
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
else {
|
|
1180
|
+
largerEfforts.push({
|
|
1181
|
+
finding: `Fix ${occurrences} force-clicks across ${fileCount} files`,
|
|
1182
|
+
effort: "2-4 hours",
|
|
1183
|
+
impact: "Reduces hidden UI issues",
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
break;
|
|
1187
|
+
case "PW-LOC-001": // XPath selectors
|
|
1188
|
+
if (fileCount <= 2) {
|
|
1189
|
+
quickWins.push({
|
|
1190
|
+
finding: `Refactor ${occurrences} XPath selectors in ${fileCount} file${fileCount > 1 ? 's' : ''}`,
|
|
1191
|
+
effort: "1-2 hours",
|
|
1192
|
+
impact: "More maintainable locators",
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
largerEfforts.push({
|
|
1197
|
+
finding: `Refactor ${occurrences} XPath selectors across ${fileCount} files`,
|
|
1198
|
+
effort: "4-8 hours",
|
|
1199
|
+
impact: "More maintainable locators",
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
break;
|
|
1203
|
+
case "PW-FLAKE-001": // Hard waits
|
|
1204
|
+
if (occurrences <= 5) {
|
|
1205
|
+
quickWins.push({
|
|
1206
|
+
finding: `Remove ${occurrences} hard wait${occurrences > 1 ? 's' : ''}`,
|
|
1207
|
+
effort: "1-2 hours",
|
|
1208
|
+
impact: "Faster, more stable tests",
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
else {
|
|
1212
|
+
largerEfforts.push({
|
|
1213
|
+
finding: `Remove ${occurrences} hard waits across ${fileCount} files`,
|
|
1214
|
+
effort: "4-8 hours",
|
|
1215
|
+
impact: "Faster, more stable tests",
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
break;
|
|
1219
|
+
case "PW-FLAKE-009": // Debug pauses
|
|
1220
|
+
quickWins.push({
|
|
1221
|
+
finding: `Remove ${occurrences} debug pause${occurrences > 1 ? 's' : ''} (page.pause)`,
|
|
1222
|
+
effort: "~15 min",
|
|
1223
|
+
impact: "Prevents CI hangs from leftover debug pauses",
|
|
1224
|
+
});
|
|
1225
|
+
break;
|
|
1226
|
+
case "PW-FLAKE-010": // Retrieval methods
|
|
1227
|
+
if (occurrences <= 5) {
|
|
1228
|
+
quickWins.push({
|
|
1229
|
+
finding: `Add content guards to ${occurrences} retrieval method call${occurrences > 1 ? 's' : ''}`,
|
|
1230
|
+
effort: "1-2 hours",
|
|
1231
|
+
impact: "Prevents empty/stale text extraction",
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
else {
|
|
1235
|
+
largerEfforts.push({
|
|
1236
|
+
finding: `Add content guards to ${occurrences} retrieval calls across ${fileCount} files`,
|
|
1237
|
+
effort: "3-6 hours",
|
|
1238
|
+
impact: "Prevents empty/stale text extraction",
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
break;
|
|
1242
|
+
case "PW-STABILITY-001": // Missing hooks (high priority)
|
|
1243
|
+
largerEfforts.push({
|
|
1244
|
+
finding: `Add cleanup hooks to ${fileCount} test file${fileCount > 1 ? 's' : ''}`,
|
|
1245
|
+
effort: "2-4 hours",
|
|
1246
|
+
impact: "Better test isolation",
|
|
1247
|
+
});
|
|
1248
|
+
break;
|
|
1249
|
+
case "PW-STABILITY-003": // page fixture used in beforeAll
|
|
1250
|
+
largerEfforts.push({
|
|
1251
|
+
finding: `Move page-based setup out of beforeAll in ${fileCount} file${fileCount > 1 ? 's' : ''}`,
|
|
1252
|
+
effort: "2-6 hours",
|
|
1253
|
+
impact: "Prevents shared state + flaky cascades",
|
|
1254
|
+
});
|
|
1255
|
+
break;
|
|
1256
|
+
case "PW-STABILITY-001a": // beforeAll/afterAll only (low priority)
|
|
1257
|
+
// Skip - informational
|
|
1258
|
+
break;
|
|
1259
|
+
case "CI-006": // Inconsistent WORKERS
|
|
1260
|
+
quickWins.push({
|
|
1261
|
+
finding: "Standardize WORKERS env across pipelines",
|
|
1262
|
+
effort: "30 min",
|
|
1263
|
+
impact: "Consistent CI behavior",
|
|
1264
|
+
});
|
|
1265
|
+
break;
|
|
1266
|
+
case "CI-007": // Missing sharding
|
|
1267
|
+
largerEfforts.push({
|
|
1268
|
+
finding: "Implement test sharding in CI",
|
|
1269
|
+
effort: "2-4 hours",
|
|
1270
|
+
impact: "50-75% faster CI",
|
|
1271
|
+
});
|
|
1272
|
+
break;
|
|
1273
|
+
case "CI-005": // Missing cache
|
|
1274
|
+
quickWins.push({
|
|
1275
|
+
finding: "Add dependency caching to CI",
|
|
1276
|
+
effort: "30 min",
|
|
1277
|
+
impact: "Faster CI install phase",
|
|
1278
|
+
});
|
|
1279
|
+
break;
|
|
1280
|
+
case "PW-INSIGHT-002": // Excessive timeout
|
|
1281
|
+
quickWins.push({
|
|
1282
|
+
finding: "Reduce test timeout configuration",
|
|
1283
|
+
effort: "15 min",
|
|
1284
|
+
impact: "Faster failure feedback",
|
|
1285
|
+
});
|
|
1286
|
+
break;
|
|
1287
|
+
// Architecture findings
|
|
1288
|
+
case "ARCH-001": // Missing BasePage
|
|
1289
|
+
largerEfforts.push({
|
|
1290
|
+
finding: `Create BasePage pattern for ${fileCount} POM${fileCount > 1 ? 's' : ''}`,
|
|
1291
|
+
effort: "2-4 hours",
|
|
1292
|
+
impact: "Consistent POM patterns",
|
|
1293
|
+
});
|
|
1294
|
+
break;
|
|
1295
|
+
case "ARCH-002": // Public locators
|
|
1296
|
+
if (occurrences <= 3) {
|
|
1297
|
+
quickWins.push({
|
|
1298
|
+
finding: `Encapsulate locators in ${fileCount} POM${fileCount > 1 ? 's' : ''}`,
|
|
1299
|
+
effort: "1-2 hours",
|
|
1300
|
+
impact: "Better encapsulation",
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
else {
|
|
1304
|
+
largerEfforts.push({
|
|
1305
|
+
finding: `Encapsulate locators in ${fileCount} POMs`,
|
|
1306
|
+
effort: "4-8 hours",
|
|
1307
|
+
impact: "Better encapsulation",
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
break;
|
|
1311
|
+
case "ARCH-003": // Test coupling
|
|
1312
|
+
largerEfforts.push({
|
|
1313
|
+
finding: `Decouple ${fileCount} test file${fileCount > 1 ? 's' : ''} from test imports`,
|
|
1314
|
+
effort: "2-4 hours",
|
|
1315
|
+
impact: "Better test isolation",
|
|
1316
|
+
});
|
|
1317
|
+
break;
|
|
1318
|
+
case "ARCH-004": // Missing fixture typing
|
|
1319
|
+
if (fileCount <= 2) {
|
|
1320
|
+
quickWins.push({
|
|
1321
|
+
finding: `Add fixture typing to ${fileCount} file${fileCount > 1 ? 's' : ''}`,
|
|
1322
|
+
effort: "1 hour",
|
|
1323
|
+
impact: "Type safety in tests",
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
else {
|
|
1327
|
+
largerEfforts.push({
|
|
1328
|
+
finding: `Add fixture typing to ${fileCount} files`,
|
|
1329
|
+
effort: "2-4 hours",
|
|
1330
|
+
impact: "Type safety in tests",
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
break;
|
|
1334
|
+
case "ARCH-005": // Hardcoded credentials
|
|
1335
|
+
quickWins.push({
|
|
1336
|
+
finding: "Move credentials to env variables",
|
|
1337
|
+
effort: "30 min",
|
|
1338
|
+
impact: "Security + env flexibility",
|
|
1339
|
+
});
|
|
1340
|
+
break;
|
|
1341
|
+
case "ARCH-005a": // Hardcoded emails
|
|
1342
|
+
if (occurrences <= 10) {
|
|
1343
|
+
quickWins.push({
|
|
1344
|
+
finding: `Replace ${occurrences} hardcoded email${occurrences > 1 ? 's' : ''} with faker/factory`,
|
|
1345
|
+
effort: "1 hour",
|
|
1346
|
+
impact: "Fewer test conflicts",
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
else {
|
|
1350
|
+
largerEfforts.push({
|
|
1351
|
+
finding: `Replace ${occurrences} hardcoded emails with data factory`,
|
|
1352
|
+
effort: "2-4 hours",
|
|
1353
|
+
impact: "Fewer test conflicts",
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
break;
|
|
1357
|
+
case "ARCH-006": // No API mocking
|
|
1358
|
+
largerEfforts.push({
|
|
1359
|
+
finding: "Implement API mocking patterns",
|
|
1360
|
+
effort: "4-8 hours",
|
|
1361
|
+
impact: "Faster, isolated tests",
|
|
1362
|
+
});
|
|
1363
|
+
break;
|
|
1364
|
+
case "ARCH-007": // No custom error types
|
|
1365
|
+
largerEfforts.push({
|
|
1366
|
+
finding: `Create custom error types for ${fileCount} POM${fileCount > 1 ? 's' : ''}`,
|
|
1367
|
+
effort: "2-4 hours",
|
|
1368
|
+
impact: "Better error handling",
|
|
1369
|
+
});
|
|
1370
|
+
break;
|
|
1371
|
+
case "PW-STABILITY-005": // Browser resource leaks
|
|
1372
|
+
case "SE-STABILITY-001": // WebDriver resource leaks
|
|
1373
|
+
largerEfforts.push({
|
|
1374
|
+
finding: `Add resource cleanup to ${fileCount} test file${fileCount > 1 ? 's' : ''}`,
|
|
1375
|
+
effort: "1-2 hours",
|
|
1376
|
+
impact: "Prevent memory leaks + zombie processes",
|
|
1377
|
+
});
|
|
1378
|
+
break;
|
|
1379
|
+
case "PW-STABILITY-006": // Too many assertions per test
|
|
1380
|
+
quickWins.push({
|
|
1381
|
+
finding: `Split ${occurrences} over asserted test${occurrences > 1 ? 's' : ''}`,
|
|
1382
|
+
effort: "1-2 hours",
|
|
1383
|
+
impact: "Clearer test failures",
|
|
1384
|
+
});
|
|
1385
|
+
break;
|
|
1386
|
+
case "ARCH-010": // Hardcoded inline test data
|
|
1387
|
+
quickWins.push({
|
|
1388
|
+
finding: `Extract inline test data from ${fileCount} file${fileCount > 1 ? 's' : ''} to fixtures`,
|
|
1389
|
+
effort: "1-2 hours",
|
|
1390
|
+
impact: "Easier data maintenance",
|
|
1391
|
+
});
|
|
1392
|
+
break;
|
|
1393
|
+
case "DEP-001": // Outdated dependencies
|
|
1394
|
+
if (occurrences <= 3) {
|
|
1395
|
+
quickWins.push({
|
|
1396
|
+
finding: `Update ${occurrences} outdated Playwright package${occurrences > 1 ? 's' : ''}`,
|
|
1397
|
+
effort: "1-2 hours",
|
|
1398
|
+
impact: "Stay current, reduce flakiness",
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
else {
|
|
1402
|
+
largerEfforts.push({
|
|
1403
|
+
finding: `Update ${occurrences} outdated Playwright packages`,
|
|
1404
|
+
effort: "2-4 hours",
|
|
1405
|
+
impact: "Stay current, reduce flakiness",
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
break;
|
|
1409
|
+
case "DEP-002": // Deprecated npm packages
|
|
1410
|
+
largerEfforts.push({
|
|
1411
|
+
finding: `Replace ${occurrences} deprecated npm package${occurrences > 1 ? 's' : ''}`,
|
|
1412
|
+
effort: "1-4 hours",
|
|
1413
|
+
impact: "Avoid unmaintained/vulnerable deps",
|
|
1414
|
+
});
|
|
1415
|
+
break;
|
|
1416
|
+
default:
|
|
1417
|
+
break;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
return { quickWins: quickWins.slice(0, 4), largerEfforts: largerEfforts.slice(0, 4) };
|
|
1421
|
+
}
|
|
1422
|
+
async function writeReportArtifacts(outDir, results, options) {
|
|
1423
|
+
await promises_1.default.mkdir(outDir, { recursive: true });
|
|
1424
|
+
const footerLogoDataUri = await loadFooterLogoDataUri();
|
|
1425
|
+
const sidebarLogoSvg = await loadSidebarLogoSvg();
|
|
1426
|
+
const faviconSvgDataUri = await loadFaviconDataUri();
|
|
1427
|
+
const findingsSorted = [...results.findings].sort((a, b) => (0, scoring_1.severityScore)(b.severity) - (0, scoring_1.severityScore)(a.severity));
|
|
1428
|
+
const risk = (0, scoring_1.calculateRiskScore)(findingsSorted, {
|
|
1429
|
+
testFiles: results.inventory.testFiles,
|
|
1430
|
+
cypressTestFiles: results.inventory.cypressTestFiles,
|
|
1431
|
+
seleniumTestFiles: results.inventory.seleniumTestFiles,
|
|
1432
|
+
});
|
|
1433
|
+
const topActions = getTopActionItems(findingsSorted);
|
|
1434
|
+
const md = [];
|
|
1435
|
+
const highCount = findingsSorted.filter(f => f.severity === "high").length;
|
|
1436
|
+
const mediumCount = findingsSorted.filter(f => f.severity === "medium").length;
|
|
1437
|
+
const lowCount = findingsSorted.filter(f => f.severity === "low").length;
|
|
1438
|
+
const totalFindings = highCount + mediumCount + lowCount;
|
|
1439
|
+
// Professional Header
|
|
1440
|
+
md.push(`<div align="center">`);
|
|
1441
|
+
md.push(``);
|
|
1442
|
+
md.push(`# Automation Framework Audit Report`);
|
|
1443
|
+
md.push(``);
|
|
1444
|
+
md.push(`**Findings, Risks, and Recommendations**`);
|
|
1445
|
+
md.push(``);
|
|
1446
|
+
md.push(`</div>`);
|
|
1447
|
+
md.push(``);
|
|
1448
|
+
// Compact metadata bar
|
|
1449
|
+
md.push(`<div class="report-meta">`);
|
|
1450
|
+
md.push(`<span class="meta-item"><strong>Repository:</strong> <code>${results.repoDisplayName ?? results.repoPath.split(/[/\\]/).pop()}</code></span>`);
|
|
1451
|
+
md.push(`<span class="meta-item"><strong>Branch:</strong> <code>${results.gitBranch ?? 'unknown'}</code></span>`);
|
|
1452
|
+
md.push(`<span class="meta-item"><strong>Generated:</strong> ${new Date().toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short' })}</span>`);
|
|
1453
|
+
md.push(`<span class="meta-item"><strong>Playwright:</strong> ${results.inventory.playwrightVersion ?? 'Unknown'}</span>`);
|
|
1454
|
+
md.push(`<span class="meta-item"><strong>Files scanned:</strong> ${results.inventory.totalFiles}</span>`);
|
|
1455
|
+
md.push(`</div>`);
|
|
1456
|
+
md.push(``);
|
|
1457
|
+
// ── Section 1: EXECUTIVE SUMMARY ──
|
|
1458
|
+
md.push(`## EXECUTIVE SUMMARY`);
|
|
1459
|
+
md.push(``);
|
|
1460
|
+
const gradeLetter = risk.grade.toUpperCase();
|
|
1461
|
+
const gradeClass = `grade-${gradeLetter.toLowerCase()}`;
|
|
1462
|
+
md.push(`<div class="hero-card">`);
|
|
1463
|
+
md.push(`<div class="hero-grade">`);
|
|
1464
|
+
md.push(`<div class="grade-letter ${gradeClass}">${gradeLetter}</div>`);
|
|
1465
|
+
md.push(`<div class="grade-detail">`);
|
|
1466
|
+
md.push(`<div class="grade-level">${risk.level} (${risk.riskScore}/100)</div>`);
|
|
1467
|
+
md.push(`<div class="grade-desc">${risk.description}</div>`);
|
|
1468
|
+
md.push(`</div>`);
|
|
1469
|
+
md.push(`</div>`);
|
|
1470
|
+
md.push(`<div class="hero-stats">`);
|
|
1471
|
+
md.push(`<div class="stat-card"><div class="stat-label">Total Findings</div><div class="stat-value">${totalFindings}</div></div>`);
|
|
1472
|
+
md.push(`<div class="stat-card stat-card-high"><div class="stat-label">High</div><div class="stat-value stat-high">${highCount}</div></div>`);
|
|
1473
|
+
md.push(`<div class="stat-card stat-card-medium"><div class="stat-label">Medium</div><div class="stat-value stat-medium">${mediumCount}</div></div>`);
|
|
1474
|
+
md.push(`<div class="stat-card stat-card-low"><div class="stat-label">Low</div><div class="stat-value stat-low">${lowCount}</div></div>`);
|
|
1475
|
+
md.push(`</div>`);
|
|
1476
|
+
const densityNote = risk.densityInfo
|
|
1477
|
+
? `${risk.densityInfo.effectiveTestFiles} test files (${risk.densityInfo.densityFactor}× scale factor, ${risk.densityInfo.findingDensity} findings/file).`
|
|
1478
|
+
: "";
|
|
1479
|
+
const tooltipAttr = densityNote
|
|
1480
|
+
? ` title="${escapeHtml(densityNote)}"`
|
|
1481
|
+
: "";
|
|
1482
|
+
md.push(`<div class="hero-footnote">` +
|
|
1483
|
+
`Health score (0-100). Scale normalized based on findings per file density.` +
|
|
1484
|
+
(densityNote
|
|
1485
|
+
? ` <span class="info-tip"${tooltipAttr} aria-label="Density details">ⓘ</span>`
|
|
1486
|
+
: "") +
|
|
1487
|
+
`</div>`);
|
|
1488
|
+
md.push(`</div>`);
|
|
1489
|
+
md.push(``);
|
|
1490
|
+
// ── Section 2: RECOMMENDED ACTIONS (moved up) ──
|
|
1491
|
+
md.push(`## RECOMMENDED ACTIONS`);
|
|
1492
|
+
md.push(``);
|
|
1493
|
+
if (topActions.length > 0) {
|
|
1494
|
+
md.push(`### Top 3 Priority Items`);
|
|
1495
|
+
md.push(``);
|
|
1496
|
+
topActions.forEach((action, i) => {
|
|
1497
|
+
md.push(` **${i + 1}.** ${action}`);
|
|
1498
|
+
md.push(``);
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
// Consolidated effort table
|
|
1502
|
+
const { quickWins, largerEfforts } = categorizeFindings(findingsSorted);
|
|
1503
|
+
if (quickWins.length > 0 || largerEfforts.length > 0) {
|
|
1504
|
+
md.push(`### Effort Estimation`);
|
|
1505
|
+
md.push(``);
|
|
1506
|
+
md.push(`| Action Item | Effort | Time Estimate | Expected Impact |`);
|
|
1507
|
+
md.push(`|-------------|--------|---------------|-----------------|`);
|
|
1508
|
+
for (const qw of quickWins) {
|
|
1509
|
+
md.push(`| ${qw.finding} | <span class="badge effort-quick">Quick Win</span> | ${qw.effort} | ${qw.impact} |`);
|
|
1510
|
+
}
|
|
1511
|
+
for (const le of largerEfforts) {
|
|
1512
|
+
md.push(`| ${le.finding} | <span class="badge effort-strategic">Strategic</span> | ${le.effort} | ${le.impact} |`);
|
|
1513
|
+
}
|
|
1514
|
+
md.push(``);
|
|
1515
|
+
// Time Summary
|
|
1516
|
+
const timeEstimate = calculateRemediationTime(findingsSorted);
|
|
1517
|
+
md.push(`#### Remediation Time Summary`);
|
|
1518
|
+
md.push(``);
|
|
1519
|
+
md.push(`| Target | Estimated Time | Scope |`);
|
|
1520
|
+
md.push(`|--------|----------------|-------|`);
|
|
1521
|
+
if (risk.grade !== 'A' && risk.grade !== 'B') {
|
|
1522
|
+
md.push(`| **Reach Grade B (Good)** | ${formatTimeRange(timeEstimate.toGoodGrade.minHours, timeEstimate.toGoodGrade.maxHours)} | Address ${timeEstimate.highPriorityCount} high-severity finding${timeEstimate.highPriorityCount !== 1 ? 's' : ''} |`);
|
|
1523
|
+
}
|
|
1524
|
+
md.push(`| **Address All Issues** | ${formatTimeRange(timeEstimate.toAllResolved.minHours, timeEstimate.toAllResolved.maxHours)} | Resolve all ${timeEstimate.totalIssueCount} finding${timeEstimate.totalIssueCount !== 1 ? 's' : ''} |`);
|
|
1525
|
+
md.push(``);
|
|
1526
|
+
md.push(`> *Time estimates assume a single engineer familiar with the codebase. Actual time may vary based on complexity and team experience.*`);
|
|
1527
|
+
md.push(``);
|
|
1528
|
+
}
|
|
1529
|
+
// ── Section 3: RISK ASSESSMENT ──
|
|
1530
|
+
md.push(`## RISK ASSESSMENT`);
|
|
1531
|
+
md.push(``);
|
|
1532
|
+
const topHotspots = getTopHotspotsAcrossFindings(findingsSorted);
|
|
1533
|
+
if (topHotspots.length > 0) {
|
|
1534
|
+
md.push(`### Priority Files`);
|
|
1535
|
+
md.push(`> *These files have the highest concentration of issues and should be addressed first.*`);
|
|
1536
|
+
md.push(``);
|
|
1537
|
+
md.push(`| File | Issues Found |`);
|
|
1538
|
+
md.push(`|------|--------------|`);
|
|
1539
|
+
for (const hotspot of topHotspots) {
|
|
1540
|
+
const fileName = hotspot.file.split('/').pop() || hotspot.file;
|
|
1541
|
+
const badges = [];
|
|
1542
|
+
if (hotspot.highCount > 0)
|
|
1543
|
+
badges.push(`<span class="badge badge-high">${hotspot.highCount}</span>`);
|
|
1544
|
+
if (hotspot.mediumCount > 0)
|
|
1545
|
+
badges.push(`<span class="badge badge-medium">${hotspot.mediumCount}</span>`);
|
|
1546
|
+
if (hotspot.lowCount > 0)
|
|
1547
|
+
badges.push(`<span class="badge badge-low">${hotspot.lowCount}</span>`);
|
|
1548
|
+
md.push(`| \`${fileName}\` | ${badges.join(' ')} |`);
|
|
1549
|
+
}
|
|
1550
|
+
md.push(``);
|
|
1551
|
+
}
|
|
1552
|
+
// ── Section 4: FINDINGS OVERVIEW (severity×category matrix) ──
|
|
1553
|
+
md.push(`## FINDINGS OVERVIEW`);
|
|
1554
|
+
md.push(``);
|
|
1555
|
+
if (findingsSorted.length > 0) {
|
|
1556
|
+
const categoryMatrix = buildCategoryMatrix(findingsSorted);
|
|
1557
|
+
md.push(`<div class="severity-matrix">`);
|
|
1558
|
+
md.push(``);
|
|
1559
|
+
md.push(`| Category | High | Medium | Low | Total |`);
|
|
1560
|
+
md.push(`|----------|------|--------|-----|-------|`);
|
|
1561
|
+
for (const [cat, counts] of categoryMatrix) {
|
|
1562
|
+
const total = counts.high + counts.medium + counts.low;
|
|
1563
|
+
const hCell = counts.high > 0 ? `<span class="badge badge-high">${counts.high}</span>` : '\u2014';
|
|
1564
|
+
const mCell = counts.medium > 0 ? `<span class="badge badge-medium">${counts.medium}</span>` : '\u2014';
|
|
1565
|
+
const lCell = counts.low > 0 ? `<span class="badge badge-low">${counts.low}</span>` : '\u2014';
|
|
1566
|
+
md.push(`| ${cat} | ${hCell} | ${mCell} | ${lCell} | ${total} |`);
|
|
1567
|
+
}
|
|
1568
|
+
md.push(``);
|
|
1569
|
+
md.push(`</div>`);
|
|
1570
|
+
md.push(``);
|
|
1571
|
+
}
|
|
1572
|
+
// ── Section 5: DETAILED FINDINGS ──
|
|
1573
|
+
md.push(`## DETAILED FINDINGS`);
|
|
1574
|
+
md.push(``);
|
|
1575
|
+
if (!findingsSorted.length) {
|
|
1576
|
+
md.push(`> **Excellent!** No issues were detected by the current audit rules.`);
|
|
1577
|
+
md.push(``);
|
|
1578
|
+
}
|
|
1579
|
+
else {
|
|
1580
|
+
// Filter bar
|
|
1581
|
+
md.push(`<div class="filter-bar" role="toolbar" aria-label="Filter findings">`);
|
|
1582
|
+
md.push(`<label>Filter:</label>`);
|
|
1583
|
+
md.push(`<button class="filter-btn active" data-severity="all" type="button">All (${totalFindings})</button>`);
|
|
1584
|
+
if (highCount > 0)
|
|
1585
|
+
md.push(`<button class="filter-btn" data-severity="high" type="button">High (${highCount})</button>`);
|
|
1586
|
+
if (mediumCount > 0)
|
|
1587
|
+
md.push(`<button class="filter-btn" data-severity="medium" type="button">Medium (${mediumCount})</button>`);
|
|
1588
|
+
if (lowCount > 0)
|
|
1589
|
+
md.push(`<button class="filter-btn" data-severity="low" type="button">Low (${lowCount})</button>`);
|
|
1590
|
+
md.push(`<input type="search" class="filter-search" id="finding-search" placeholder="Search findings..." aria-label="Search findings"/>`);
|
|
1591
|
+
md.push(`</div>`);
|
|
1592
|
+
md.push(`<div class="filter-count" id="filter-count"></div>`);
|
|
1593
|
+
md.push(``);
|
|
1594
|
+
// Group findings by severity
|
|
1595
|
+
const highFindings = findingsSorted.filter(f => f.severity === "high");
|
|
1596
|
+
const mediumFindings = findingsSorted.filter(f => f.severity === "medium");
|
|
1597
|
+
const lowFindings = findingsSorted.filter(f => f.severity === "low");
|
|
1598
|
+
if (highFindings.length > 0) {
|
|
1599
|
+
md.push(`### 🔴 High Severity Issues`);
|
|
1600
|
+
md.push(``);
|
|
1601
|
+
for (const f of highFindings) {
|
|
1602
|
+
renderFinding(md, f);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
if (mediumFindings.length > 0) {
|
|
1606
|
+
md.push(`### 🟠 Medium Severity Issues`);
|
|
1607
|
+
md.push(``);
|
|
1608
|
+
for (const f of mediumFindings) {
|
|
1609
|
+
renderFinding(md, f);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
if (lowFindings.length > 0) {
|
|
1613
|
+
md.push(`### 🔵 Low Severity Issues`);
|
|
1614
|
+
md.push(``);
|
|
1615
|
+
for (const f of lowFindings) {
|
|
1616
|
+
renderFinding(md, f);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
// ── Section 6: CONFIGURATION OVERVIEW ──
|
|
1621
|
+
md.push(`## CONFIGURATION OVERVIEW`);
|
|
1622
|
+
md.push(``);
|
|
1623
|
+
// Playwright Config
|
|
1624
|
+
const pw = results.inventory.playwrightConfigSummary;
|
|
1625
|
+
const allConfigs = results.inventory.playwrightConfigPaths;
|
|
1626
|
+
if (pw) {
|
|
1627
|
+
md.push(`### Playwright Configuration`);
|
|
1628
|
+
md.push(``);
|
|
1629
|
+
// Config summary line
|
|
1630
|
+
const pwSummaryParts = [];
|
|
1631
|
+
if (pw.workers)
|
|
1632
|
+
pwSummaryParts.push(`Workers: ${pw.workers}`);
|
|
1633
|
+
if (pw.retries)
|
|
1634
|
+
pwSummaryParts.push(`Retries: ${pw.retries}`);
|
|
1635
|
+
if (pw.timeoutMs)
|
|
1636
|
+
pwSummaryParts.push(`Timeout: ${Math.round(pw.timeoutMs / 1000)}s`);
|
|
1637
|
+
if (pw.use?.trace)
|
|
1638
|
+
pwSummaryParts.push(`Trace: ${pw.use.trace}`);
|
|
1639
|
+
else
|
|
1640
|
+
pwSummaryParts.push(`Trace: off`);
|
|
1641
|
+
if (pwSummaryParts.length > 0) {
|
|
1642
|
+
md.push(`<p class="config-summary">${pwSummaryParts.join(' \u00b7 ')}</p>`);
|
|
1643
|
+
md.push(``);
|
|
1644
|
+
}
|
|
1645
|
+
if (allConfigs && allConfigs.length > 1) {
|
|
1646
|
+
md.push(`> **${allConfigs.length} config files detected** — showing details for the primary config. All configs listed below.`);
|
|
1647
|
+
md.push(``);
|
|
1648
|
+
}
|
|
1649
|
+
md.push(`<details>`);
|
|
1650
|
+
md.push(`<summary>Configuration details</summary>`);
|
|
1651
|
+
md.push(``);
|
|
1652
|
+
md.push(`| Setting | Value |`);
|
|
1653
|
+
md.push(`|---------|-------|`);
|
|
1654
|
+
md.push(`| Config Path | \`${pw.configPath}\` |`);
|
|
1655
|
+
if (pw.testDir)
|
|
1656
|
+
md.push(`| Test Directory | \`${pw.testDir}\` |`);
|
|
1657
|
+
if (pw.timeoutMs)
|
|
1658
|
+
md.push(`| Test Timeout | ${formatMs(pw.timeoutMs)} |`);
|
|
1659
|
+
if (pw.expectTimeoutMs)
|
|
1660
|
+
md.push(`| Expect Timeout | ${formatMs(pw.expectTimeoutMs)} |`);
|
|
1661
|
+
if (pw.workers)
|
|
1662
|
+
md.push(`| Workers | ${pw.workers} |`);
|
|
1663
|
+
if (pw.retries)
|
|
1664
|
+
md.push(`| Retries | ${pw.retries} |`);
|
|
1665
|
+
if (pw.globalSetup)
|
|
1666
|
+
md.push(`| Global Setup | \`${pw.globalSetup}\` |`);
|
|
1667
|
+
if (pw.use) {
|
|
1668
|
+
if (pw.use.headless !== undefined)
|
|
1669
|
+
md.push(`| Headless | ${pw.use.headless} |`);
|
|
1670
|
+
if (pw.use.actionTimeoutMs)
|
|
1671
|
+
md.push(`| Action Timeout | ${formatMs(pw.use.actionTimeoutMs)} |`);
|
|
1672
|
+
if (pw.use.trace)
|
|
1673
|
+
md.push(`| Trace | ${pw.use.trace} |`);
|
|
1674
|
+
if (pw.use.video)
|
|
1675
|
+
md.push(`| Video | ${pw.use.video} |`);
|
|
1676
|
+
if (pw.use.screenshot)
|
|
1677
|
+
md.push(`| Screenshot | ${pw.use.screenshot} |`);
|
|
1678
|
+
}
|
|
1679
|
+
if (pw.reporters?.length) {
|
|
1680
|
+
const uniqueNames = [...new Set(pw.reporters.map((r) => r.name))];
|
|
1681
|
+
md.push(`| Reporters | ${uniqueNames.join(', ')} |`);
|
|
1682
|
+
}
|
|
1683
|
+
if (pw.projects?.length) {
|
|
1684
|
+
const projNames = pw.projects.map((p) => p.name).join(', ');
|
|
1685
|
+
md.push(`| Projects | ${projNames} |`);
|
|
1686
|
+
}
|
|
1687
|
+
if (allConfigs && allConfigs.length > 1) {
|
|
1688
|
+
md.push(``);
|
|
1689
|
+
md.push(`**All Playwright Config Files:**`);
|
|
1690
|
+
for (const cp of allConfigs) {
|
|
1691
|
+
md.push(`- \`${cp}\``);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
md.push(``);
|
|
1695
|
+
md.push(`</details>`);
|
|
1696
|
+
md.push(``);
|
|
1697
|
+
}
|
|
1698
|
+
// CI Configuration
|
|
1699
|
+
const ci = results.inventory.ciSummary;
|
|
1700
|
+
if (ci) {
|
|
1701
|
+
md.push(`### CI/CD Configuration`);
|
|
1702
|
+
md.push(``);
|
|
1703
|
+
// CI summary line
|
|
1704
|
+
const ciPlatform = ci.detectedAzurePipelines ? 'Azure Pipelines' : ci.detectedGitHubActions ? 'GitHub Actions' : 'Unknown';
|
|
1705
|
+
const ciSummaryParts = [
|
|
1706
|
+
ciPlatform,
|
|
1707
|
+
`${ci.files.length} pipeline${ci.files.length !== 1 ? 's' : ''}`,
|
|
1708
|
+
ci.usesCache ? 'Caching enabled' : 'No caching',
|
|
1709
|
+
ci.usesSharding ? 'Sharding enabled' : 'No sharding',
|
|
1710
|
+
];
|
|
1711
|
+
md.push(`<p class="config-summary">${ciSummaryParts.join(' \u00b7 ')}</p>`);
|
|
1712
|
+
md.push(``);
|
|
1713
|
+
md.push(`<details>`);
|
|
1714
|
+
md.push(`<summary>CI details</summary>`);
|
|
1715
|
+
md.push(``);
|
|
1716
|
+
md.push(`| Setting | Status |`);
|
|
1717
|
+
md.push(`|---------|--------|`);
|
|
1718
|
+
md.push(`| CI Platform | ${ci.detectedAzurePipelines ? 'Azure Pipelines' : ''}${ci.detectedGitHubActions ? 'GitHub Actions' : ''}${!ci.detectedAzurePipelines && !ci.detectedGitHubActions ? 'Not detected' : ''} |`);
|
|
1719
|
+
md.push(`| Pipeline Files | ${ci.files.length} |`);
|
|
1720
|
+
md.push(`| HTML Report Publishing | ${ci.publishesPlaywrightReport ? ' Configured' : ' Not configured'} |`);
|
|
1721
|
+
md.push(`| JUnit Publishing | ${ci.publishesJUnit ? ' Configured' : ' Not configured'} |`);
|
|
1722
|
+
md.push(`| Trace/Artifact Publishing | ${ci.publishesTracesOrTestResultsDir ? ' Configured' : ' Not configured'} |`);
|
|
1723
|
+
if (ci.publishesTracesOrTestResultsDir) {
|
|
1724
|
+
md.push(`| Artifacts on Failure | ${ci.publishesArtifactsOnFailure ? ' Yes' : ' Not detected'} |`);
|
|
1725
|
+
}
|
|
1726
|
+
md.push(`| Dependency Caching | ${ci.usesCache ? ' Enabled' : ' Not enabled'} |`);
|
|
1727
|
+
md.push(`| Test Sharding | ${ci.usesSharding ? ' Enabled' : ' Not enabled'} |`);
|
|
1728
|
+
md.push(`| WORKERS Env Variable | ${ci.setsWorkersEnv ? ' Set' : ' Not set'} |`);
|
|
1729
|
+
md.push(``);
|
|
1730
|
+
if (ci.files.length > 0) {
|
|
1731
|
+
md.push(`**Pipeline Files:**`);
|
|
1732
|
+
for (const f of ci.files) {
|
|
1733
|
+
md.push(`- \`${f.file.split(/[/\\]/).pop()}\``);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
md.push(``);
|
|
1737
|
+
md.push(`</details>`);
|
|
1738
|
+
md.push(``);
|
|
1739
|
+
}
|
|
1740
|
+
// ── Section 7: Appendix ──
|
|
1741
|
+
md.push(`## Appendix`);
|
|
1742
|
+
md.push(``);
|
|
1743
|
+
md.push(`### Repository Statistics`);
|
|
1744
|
+
md.push(``);
|
|
1745
|
+
md.push(`| Metric | Value |`);
|
|
1746
|
+
md.push(`|--------|-------|`);
|
|
1747
|
+
md.push(`| Total Files Scanned | ${results.inventory.totalFiles} |`);
|
|
1748
|
+
md.push(`| Test Files | ${results.inventory.testFiles} |`);
|
|
1749
|
+
if (results.inventory.testCases != null)
|
|
1750
|
+
md.push(`| Test Cases | ${results.inventory.testCases} |`);
|
|
1751
|
+
md.push(`| Playwright Config | ${results.inventory.hasPlaywrightConfig ? `Found (${results.inventory.playwrightConfigPaths?.length ?? 1} file${(results.inventory.playwrightConfigPaths?.length ?? 1) > 1 ? 's' : ''})` : 'Not found'} |`);
|
|
1752
|
+
if (results.gitBranch)
|
|
1753
|
+
md.push(`| Branch | \`${results.gitBranch}\` |`);
|
|
1754
|
+
if (results.gitCommit)
|
|
1755
|
+
md.push(`| Commit | \`${results.gitCommit}\` |`);
|
|
1756
|
+
md.push(``);
|
|
1757
|
+
md.push(`### Score Interpretation Guide`);
|
|
1758
|
+
md.push(``);
|
|
1759
|
+
md.push(`| Grade | Level | Interpretation |`);
|
|
1760
|
+
md.push(`|:-----:|-------|----------------|`);
|
|
1761
|
+
md.push(`| **A** | 🟢 Excellent | Well structured framework with strong practices |`);
|
|
1762
|
+
md.push(`| **B** | 🟡 Good | Solid foundation with room for improvement |`);
|
|
1763
|
+
md.push(`| **C** | 🟠 Fair | Multiple areas need attention |`);
|
|
1764
|
+
md.push(`| **D** | 🔴 Poor | Significant issues affecting reliability |`);
|
|
1765
|
+
md.push(`| **F** | 🔴 Critical | Widespread issues requiring immediate action |`);
|
|
1766
|
+
md.push(``);
|
|
1767
|
+
md.push(`> *Grades are calculated using severity weighted findings and evidence concentration.*`);
|
|
1768
|
+
md.push(``);
|
|
1769
|
+
// Footer
|
|
1770
|
+
md.push(`<div align="center">`);
|
|
1771
|
+
md.push(``);
|
|
1772
|
+
if (footerLogoDataUri) {
|
|
1773
|
+
md.push(`<img class="report-footer-logo" src="${footerLogoDataUri}" alt="Scantrix" />`);
|
|
1774
|
+
md.push(``);
|
|
1775
|
+
}
|
|
1776
|
+
md.push(``);
|
|
1777
|
+
md.push(`<div class="report-footer-text"><b>Questions?</b> Contact QA Engineering team for remediation guidance.</div>`);
|
|
1778
|
+
md.push(`<div class="report-footer-text"><b>https://github.com/scantrix/scantrix</b></div>`);
|
|
1779
|
+
md.push(``);
|
|
1780
|
+
md.push(`</div>`);
|
|
1781
|
+
const markdownReport = md.join("\n");
|
|
1782
|
+
const mdParser = new markdown_it_1.default({
|
|
1783
|
+
html: true,
|
|
1784
|
+
linkify: true,
|
|
1785
|
+
breaks: false,
|
|
1786
|
+
});
|
|
1787
|
+
const htmlBody = mdParser.render(markdownReport);
|
|
1788
|
+
const fullHtml = linkifyHttpUrlsInRenderedHtml(wrapHtmlDocument("Automation Framework Audit Report", htmlBody, "full", { sidebarLogoSvg, faviconSvgDataUri }));
|
|
1789
|
+
const emailMarkdown = toEmailFriendlyMarkdown(markdownReport);
|
|
1790
|
+
const emailHtmlBody = mdParser.render(emailMarkdown);
|
|
1791
|
+
const emailHtml = linkifyHttpUrlsInRenderedHtml(wrapHtmlDocument("Automation Framework Audit Report (Email)", emailHtmlBody, "email"));
|
|
1792
|
+
await promises_1.default.writeFile(path_1.default.join(outDir, "audit_summary.md"), markdownReport, "utf8");
|
|
1793
|
+
await promises_1.default.writeFile(path_1.default.join(outDir, "audit_summary.html"), fullHtml, "utf8");
|
|
1794
|
+
await promises_1.default.writeFile(path_1.default.join(outDir, "audit_email.html"), emailHtml, "utf8");
|
|
1795
|
+
const repoName = results.repoDisplayName ?? (results.repoPath.split(/[/\\]/).pop() || "unknown");
|
|
1796
|
+
await promises_1.default.writeFile(path_1.default.join(outDir, "findings.json"), JSON.stringify({ repoName, findings: findingsSorted }, null, 2), "utf8");
|
|
1797
|
+
await promises_1.default.writeFile(path_1.default.join(outDir, "repo_inventory.json"), JSON.stringify({
|
|
1798
|
+
...results.inventory,
|
|
1799
|
+
gitBranch: results.gitBranch,
|
|
1800
|
+
gitCommit: results.gitCommit,
|
|
1801
|
+
}, null, 2), "utf8");
|
|
1802
|
+
// ── SARIF output ──────────────────────────────────────────────────
|
|
1803
|
+
const formats = options?.formats ?? ["md", "html", "json", "sarif", "email"];
|
|
1804
|
+
if (formats.includes("sarif")) {
|
|
1805
|
+
const sarif = (0, sarifFormatter_1.toSarif)(results);
|
|
1806
|
+
await promises_1.default.writeFile(path_1.default.join(outDir, "audit.sarif"), JSON.stringify(sarif, null, 2), "utf8");
|
|
1807
|
+
}
|
|
1808
|
+
// ── Diff report ───────────────────────────────────────────────────
|
|
1809
|
+
if (options?.baselinePath) {
|
|
1810
|
+
const baseline = await (0, diffTracker_1.loadBaseline)(options.baselinePath);
|
|
1811
|
+
if (baseline) {
|
|
1812
|
+
const diff = (0, diffTracker_1.computeDiff)(baseline, findingsSorted, {
|
|
1813
|
+
baselineInventory: options.baselineMeta,
|
|
1814
|
+
currentInventory: results.inventory,
|
|
1815
|
+
});
|
|
1816
|
+
const diffMd = (0, diffTracker_1.formatDiffMarkdown)(diff);
|
|
1817
|
+
await promises_1.default.writeFile(path_1.default.join(outDir, "audit_diff.md"), diffMd, "utf8");
|
|
1818
|
+
await promises_1.default.writeFile(path_1.default.join(outDir, "audit_diff.json"), JSON.stringify(diff, null, 2), "utf8");
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
function renderFinding(md, f) {
|
|
1823
|
+
const category = f.findingId.startsWith("PW-FLAKE") ? "flakiness" :
|
|
1824
|
+
f.findingId.startsWith("PW-STABILITY") ? "stability" :
|
|
1825
|
+
f.findingId.startsWith("PW-LOC") ? "locators" :
|
|
1826
|
+
f.findingId.startsWith("PW-PERF") ? "performance" :
|
|
1827
|
+
f.findingId.startsWith("PW-INSIGHT") ? "insights" :
|
|
1828
|
+
f.findingId.startsWith("PW-DEPREC") ? "deprecated" :
|
|
1829
|
+
f.findingId.startsWith("CI-") ? "ci" :
|
|
1830
|
+
f.findingId.startsWith("ARCH-") ? "architecture" :
|
|
1831
|
+
f.findingId.startsWith("DEP-") ? "dependencies" :
|
|
1832
|
+
"other";
|
|
1833
|
+
const badgeClass = f.severity === "high" ? "badge-high" : f.severity === "medium" ? "badge-medium" : "badge-low";
|
|
1834
|
+
const badgeLabel = f.severity === "high" ? "High" : f.severity === "medium" ? "Medium" : "Low";
|
|
1835
|
+
const escalationNote = f.baseSeverity
|
|
1836
|
+
? ` <span class="escalation-tag" title="${escapeHtml(f.escalationReason ?? '')}">▲ from ${f.baseSeverity}</span>`
|
|
1837
|
+
: "";
|
|
1838
|
+
md.push(`<details class="finding-card" data-severity="${f.severity}" data-category="${category}" data-finding-id="${f.findingId}">`);
|
|
1839
|
+
md.push(`<summary><span class="badge ${badgeClass}">${badgeLabel}</span>${escalationNote} <strong>${f.findingId}</strong> — ${f.title}</summary>`);
|
|
1840
|
+
md.push(``);
|
|
1841
|
+
md.push(`> ${f.description}`);
|
|
1842
|
+
md.push(``);
|
|
1843
|
+
md.push(`<div class="finding-block">`);
|
|
1844
|
+
md.push(`<div class="finding-block-title"><b>Recommendation</b></div>`);
|
|
1845
|
+
// Important: keep the body content as Markdown (not raw HTML) so markdown-it
|
|
1846
|
+
// can apply linkify/code spans and produce clickable links in the final HTML.
|
|
1847
|
+
// formatRecommendation() converts \n-delimited text into proper Markdown with
|
|
1848
|
+
// paragraph breaks and fenced code blocks for indented example lines.
|
|
1849
|
+
md.push(`<div class="finding-block-body">`);
|
|
1850
|
+
md.push(``);
|
|
1851
|
+
md.push(formatRecommendation(f.recommendation));
|
|
1852
|
+
md.push(``);
|
|
1853
|
+
md.push(`</div>`);
|
|
1854
|
+
md.push(`</div>`);
|
|
1855
|
+
md.push(``);
|
|
1856
|
+
// Evidence deduplication
|
|
1857
|
+
const uniqueEvidence = new Map();
|
|
1858
|
+
for (const e of f.evidence) {
|
|
1859
|
+
const key = `${e.file}:${e.line}`;
|
|
1860
|
+
if (!uniqueEvidence.has(key)) {
|
|
1861
|
+
uniqueEvidence.set(key, e);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
const totalHits = f.totalOccurrences ?? uniqueEvidence.size;
|
|
1865
|
+
const affectedFilesCount = f.affectedFiles ?? new Set([...uniqueEvidence.values()].map(e => e.file)).size;
|
|
1866
|
+
// Merge top affected files inline into Impact line
|
|
1867
|
+
const fileHitCounts = new Map();
|
|
1868
|
+
for (const e of uniqueEvidence.values()) {
|
|
1869
|
+
fileHitCounts.set(e.file, (fileHitCounts.get(e.file) ?? 0) + 1);
|
|
1870
|
+
}
|
|
1871
|
+
const hotspots = [...fileHitCounts.entries()]
|
|
1872
|
+
.sort((a, b) => b[1] - a[1])
|
|
1873
|
+
.slice(0, 3);
|
|
1874
|
+
let impactLine = `**Impact:** ${totalHits} occurrence${totalHits > 1 ? 's' : ''} across ${affectedFilesCount} file${affectedFilesCount > 1 ? 's' : ''}`;
|
|
1875
|
+
if (hotspots.length > 0 && !f.findingId.startsWith("CI-")) {
|
|
1876
|
+
const fileList = hotspots.map(([file, count]) => {
|
|
1877
|
+
const fileName = file.split(/[/\\]/).pop() || file;
|
|
1878
|
+
return `\`${fileName}\` (${count})`;
|
|
1879
|
+
}).join(', ');
|
|
1880
|
+
impactLine += ` \u2014 ${fileList}`;
|
|
1881
|
+
}
|
|
1882
|
+
md.push(impactLine);
|
|
1883
|
+
md.push(``);
|
|
1884
|
+
// Evidence samples (all)
|
|
1885
|
+
const evidenceList = Array.from(uniqueEvidence.values());
|
|
1886
|
+
md.push(`<div class="finding-block">`);
|
|
1887
|
+
md.push(`<div class="finding-block-title"><b>Hotspots</b></div>`);
|
|
1888
|
+
md.push(`<div class="finding-block-body">`);
|
|
1889
|
+
md.push(`<table>`);
|
|
1890
|
+
md.push(`<thead><tr><th>Source Location</th><th>Code</th></tr></thead>`);
|
|
1891
|
+
md.push(`<tbody>`);
|
|
1892
|
+
for (const e of evidenceList) {
|
|
1893
|
+
const fileName = e.file.split(/[/\\]/).pop() || e.file;
|
|
1894
|
+
const snippetRaw = e.snippet.length > 120 ? e.snippet.slice(0, 120) + "..." : e.snippet;
|
|
1895
|
+
md.push(`<tr><td><code>${escapeHtml(`${fileName}:${e.line}`)}</code></td><td><code>${escapeHtml(snippetRaw)}</code></td></tr>`);
|
|
1896
|
+
}
|
|
1897
|
+
md.push(`</tbody>`);
|
|
1898
|
+
md.push(`</table>`);
|
|
1899
|
+
md.push(`</div>`);
|
|
1900
|
+
md.push(`</div>`);
|
|
1901
|
+
md.push(``);
|
|
1902
|
+
md.push(`</details>`);
|
|
1903
|
+
md.push(``);
|
|
1904
|
+
}
|