@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/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, "&amp;")
77
+ .replace(/</g, "&lt;")
78
+ .replace(/>/g, "&gt;")
79
+ .replace(/\"/g, "&quot;")
80
+ .replace(/'/g, "&#39;");
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">&#x229E; Expand all</button>
805
+ <button onclick="window.print()" type="button">&#128424; 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">&uarr;</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 ?? '')}">&#9650; 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> &mdash; ${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
+ }