@eduardbar/drift 0.4.0 → 0.6.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/src/report.ts ADDED
@@ -0,0 +1,500 @@
1
+ import { basename } from 'node:path'
2
+ import { DriftReport, DriftIssue } from './types.js'
3
+
4
+ const VERSION = '0.6.0'
5
+
6
+ function severityColor(severity: DriftIssue['severity']): string {
7
+ switch (severity) {
8
+ case 'error': return '#ef4444'
9
+ case 'warning': return '#eab308'
10
+ case 'info': return '#94a3b8'
11
+ }
12
+ }
13
+
14
+ function severityIcon(severity: DriftIssue['severity']): string {
15
+ switch (severity) {
16
+ case 'error': return '✖'
17
+ case 'warning': return '▲'
18
+ case 'info': return '◦'
19
+ }
20
+ }
21
+
22
+ function scoreColor(score: number): string {
23
+ if (score < 20) return '#22c55e'
24
+ if (score < 45) return '#eab308'
25
+ if (score < 70) return '#f97316'
26
+ return '#ef4444'
27
+ }
28
+
29
+ function scoreLabel(score: number): string {
30
+ if (score < 20) return 'LOW'
31
+ if (score < 45) return 'MODERATE'
32
+ if (score < 70) return 'HIGH'
33
+ return 'CRITICAL'
34
+ }
35
+
36
+ function escapeHtml(str: string): string {
37
+ return str
38
+ .replace(/&/g, '&amp;')
39
+ .replace(/</g, '&lt;')
40
+ .replace(/>/g, '&gt;')
41
+ .replace(/"/g, '&quot;')
42
+ .replace(/'/g, '&#39;')
43
+ }
44
+
45
+ export function generateHtmlReport(report: DriftReport): string {
46
+ const projectName = basename(report.targetPath)
47
+ const scanDate = new Date(report.scannedAt).toLocaleString('en-US', {
48
+ year: 'numeric', month: 'short', day: 'numeric',
49
+ hour: '2-digit', minute: '2-digit',
50
+ })
51
+
52
+ const projColor = scoreColor(report.totalScore)
53
+ const projLabel = scoreLabel(report.totalScore)
54
+
55
+ // count totals
56
+ let totalErrors = 0
57
+ let totalWarnings = 0
58
+ let totalInfos = 0
59
+ for (const f of report.files) {
60
+ for (const issue of f.issues) {
61
+ if (issue.severity === 'error') totalErrors++
62
+ else if (issue.severity === 'warning') totalWarnings++
63
+ else totalInfos++
64
+ }
65
+ }
66
+
67
+ const filesWithIssues = report.files.filter(f => f.issues.length > 0).length
68
+
69
+ // top issues by rule
70
+ const byRule: Record<string, { count: number; severity: DriftIssue['severity'] }> = {}
71
+ for (const f of report.files) {
72
+ for (const issue of f.issues) {
73
+ if (!byRule[issue.rule]) {
74
+ byRule[issue.rule] = { count: 0, severity: issue.severity }
75
+ }
76
+ byRule[issue.rule].count++
77
+ // escalate severity if needed
78
+ const cur = byRule[issue.rule].severity
79
+ if (issue.severity === 'error') byRule[issue.rule].severity = 'error'
80
+ else if (issue.severity === 'warning' && cur !== 'error') byRule[issue.rule].severity = 'warning'
81
+ }
82
+ }
83
+
84
+ const topRules = Object.entries(byRule)
85
+ .sort((a, b) => b[1].count - a[1].count)
86
+ .slice(0, 15)
87
+
88
+ const topRulesRows = topRules.map(([rule, { count, severity }]) => {
89
+ const icon = severityIcon(severity)
90
+ const color = severityColor(severity)
91
+ return `
92
+ <tr>
93
+ <td><span class="sev-icon" style="color:${color}">${icon}</span> <span class="rule-name">${escapeHtml(rule)}</span></td>
94
+ <td class="count-cell">${count}</td>
95
+ </tr>`
96
+ }).join('')
97
+
98
+ // files sections — already sorted by score desc from buildReport
99
+ const fileSections = report.files
100
+ .filter(f => f.issues.length > 0)
101
+ .map(f => {
102
+ const hasError = f.issues.some(i => i.severity === 'error')
103
+ const openAttr = hasError ? ' open' : ''
104
+ const fColor = scoreColor(f.score)
105
+ const fLabel = scoreLabel(f.score)
106
+
107
+ const issueItems = f.issues.map(issue => {
108
+ const ic = severityColor(issue.severity)
109
+ const ii = severityIcon(issue.severity)
110
+ const snippet = issue.snippet
111
+ ? `<pre class="snippet"><code>${escapeHtml(issue.snippet)}</code></pre>`
112
+ : ''
113
+ return `
114
+ <li class="issue-item">
115
+ <div class="issue-header">
116
+ <span class="sev-icon" style="color:${ic}">${ii}</span>
117
+ <span class="issue-location">Line ${issue.line}${issue.column > 0 ? `:${issue.column}` : ''}</span>
118
+ <span class="issue-rule">${escapeHtml(issue.rule)}</span>
119
+ <span class="issue-message">${escapeHtml(issue.message)}</span>
120
+ </div>
121
+ ${snippet}
122
+ </li>`
123
+ }).join('')
124
+
125
+ return `
126
+ <details${openAttr} class="file-section">
127
+ <summary class="file-summary">
128
+ <span class="file-path">${escapeHtml(f.path)}</span>
129
+ <span class="file-score" style="color:${fColor}">${f.score} <span class="file-label">${fLabel}</span></span>
130
+ <span class="file-count">${f.issues.length} issue${f.issues.length !== 1 ? 's' : ''}</span>
131
+ </summary>
132
+ <ul class="issue-list">${issueItems}
133
+ </ul>
134
+ </details>`
135
+ }).join('\n')
136
+
137
+ return `<!DOCTYPE html>
138
+ <html lang="en">
139
+ <head>
140
+ <meta charset="UTF-8" />
141
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
142
+ <title>drift report — ${escapeHtml(projectName)}</title>
143
+ <style>
144
+ :root {
145
+ --bg: #0a0a0f;
146
+ --bg-card: #111118;
147
+ --bg-code: #1e1e2e;
148
+ --border: #2a2a3a;
149
+ --text: #e2e8f0;
150
+ --muted: #94a3b8;
151
+ --accent: #6366f1;
152
+ --error: #ef4444;
153
+ --warning: #eab308;
154
+ --info: #94a3b8;
155
+ --green: #22c55e;
156
+ --font-mono: ui-monospace, "Cascadia Code", "Fira Code", Consolas, monospace;
157
+ --radius: 6px;
158
+ }
159
+
160
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
161
+
162
+ body {
163
+ background: var(--bg);
164
+ color: var(--text);
165
+ font-family: var(--font-mono);
166
+ font-size: 14px;
167
+ line-height: 1.6;
168
+ padding: 2rem 1rem;
169
+ }
170
+
171
+ .container {
172
+ max-width: 900px;
173
+ margin: 0 auto;
174
+ }
175
+
176
+ /* ── Header ── */
177
+ .header {
178
+ display: flex;
179
+ flex-wrap: wrap;
180
+ align-items: center;
181
+ justify-content: space-between;
182
+ gap: 1.5rem;
183
+ margin-bottom: 2rem;
184
+ padding-bottom: 1.5rem;
185
+ border-bottom: 1px solid var(--border);
186
+ }
187
+
188
+ .header-left h1 {
189
+ font-size: 1.5rem;
190
+ font-weight: 700;
191
+ letter-spacing: -0.02em;
192
+ }
193
+
194
+ .header-left .scan-date {
195
+ color: var(--muted);
196
+ font-size: 0.8rem;
197
+ margin-top: 0.25rem;
198
+ }
199
+
200
+ .score-block {
201
+ text-align: right;
202
+ }
203
+
204
+ .score-number {
205
+ font-size: 4rem;
206
+ font-weight: 800;
207
+ line-height: 1;
208
+ letter-spacing: -0.04em;
209
+ }
210
+
211
+ .score-label {
212
+ font-size: 0.75rem;
213
+ font-weight: 600;
214
+ letter-spacing: 0.1em;
215
+ text-transform: uppercase;
216
+ margin-top: 0.2rem;
217
+ }
218
+
219
+ /* ── Stats row ── */
220
+ .stats-row {
221
+ display: flex;
222
+ flex-wrap: wrap;
223
+ gap: 1rem;
224
+ margin-bottom: 2rem;
225
+ }
226
+
227
+ .stat-card {
228
+ flex: 1 1 140px;
229
+ background: var(--bg-card);
230
+ border: 1px solid var(--border);
231
+ border-radius: var(--radius);
232
+ padding: 0.9rem 1.1rem;
233
+ }
234
+
235
+ .stat-card .stat-value {
236
+ font-size: 1.6rem;
237
+ font-weight: 700;
238
+ line-height: 1;
239
+ }
240
+
241
+ .stat-card .stat-label {
242
+ color: var(--muted);
243
+ font-size: 0.75rem;
244
+ margin-top: 0.3rem;
245
+ }
246
+
247
+ .stat-card .sev-breakdown {
248
+ display: flex;
249
+ gap: 0.8rem;
250
+ margin-top: 0.4rem;
251
+ font-size: 0.8rem;
252
+ }
253
+
254
+ /* ── Top rules table ── */
255
+ .section-title {
256
+ font-size: 0.7rem;
257
+ font-weight: 600;
258
+ letter-spacing: 0.12em;
259
+ text-transform: uppercase;
260
+ color: var(--muted);
261
+ margin-bottom: 0.75rem;
262
+ }
263
+
264
+ .rules-table {
265
+ width: 100%;
266
+ border-collapse: collapse;
267
+ margin-bottom: 2rem;
268
+ background: var(--bg-card);
269
+ border: 1px solid var(--border);
270
+ border-radius: var(--radius);
271
+ overflow: hidden;
272
+ }
273
+
274
+ .rules-table th {
275
+ text-align: left;
276
+ font-size: 0.7rem;
277
+ letter-spacing: 0.08em;
278
+ text-transform: uppercase;
279
+ color: var(--muted);
280
+ padding: 0.6rem 1rem;
281
+ border-bottom: 1px solid var(--border);
282
+ }
283
+
284
+ .rules-table td {
285
+ padding: 0.55rem 1rem;
286
+ border-bottom: 1px solid var(--border);
287
+ vertical-align: middle;
288
+ }
289
+
290
+ .rules-table tr:last-child td { border-bottom: none; }
291
+
292
+ .rules-table .count-cell {
293
+ text-align: right;
294
+ color: var(--muted);
295
+ font-size: 0.85rem;
296
+ width: 60px;
297
+ }
298
+
299
+ .rule-name { color: var(--text); }
300
+ .sev-icon { margin-right: 0.4rem; }
301
+
302
+ /* ── File sections ── */
303
+ .files-section { margin-top: 2rem; }
304
+
305
+ .file-section {
306
+ background: var(--bg-card);
307
+ border: 1px solid var(--border);
308
+ border-radius: var(--radius);
309
+ margin-bottom: 0.75rem;
310
+ overflow: hidden;
311
+ }
312
+
313
+ .file-summary {
314
+ display: flex;
315
+ flex-wrap: wrap;
316
+ align-items: center;
317
+ gap: 0.75rem;
318
+ padding: 0.75rem 1rem;
319
+ cursor: pointer;
320
+ user-select: none;
321
+ list-style: none;
322
+ }
323
+
324
+ .file-summary::-webkit-details-marker { display: none; }
325
+
326
+ .file-summary::before {
327
+ content: '▶';
328
+ font-size: 0.65rem;
329
+ color: var(--muted);
330
+ transition: transform 0.15s;
331
+ flex-shrink: 0;
332
+ }
333
+
334
+ details[open] > .file-summary::before { transform: rotate(90deg); }
335
+
336
+ .file-path {
337
+ flex: 1;
338
+ font-size: 0.85rem;
339
+ word-break: break-all;
340
+ }
341
+
342
+ .file-score {
343
+ font-size: 0.9rem;
344
+ font-weight: 700;
345
+ }
346
+
347
+ .file-label {
348
+ font-size: 0.65rem;
349
+ font-weight: 600;
350
+ letter-spacing: 0.08em;
351
+ text-transform: uppercase;
352
+ }
353
+
354
+ .file-count {
355
+ font-size: 0.75rem;
356
+ color: var(--muted);
357
+ background: var(--bg);
358
+ border: 1px solid var(--border);
359
+ border-radius: 99px;
360
+ padding: 0.1rem 0.55rem;
361
+ white-space: nowrap;
362
+ }
363
+
364
+ /* ── Issue list ── */
365
+ .issue-list {
366
+ list-style: none;
367
+ padding: 0 1rem 0.75rem;
368
+ }
369
+
370
+ .issue-item {
371
+ padding: 0.6rem 0;
372
+ border-top: 1px solid var(--border);
373
+ }
374
+
375
+ .issue-header {
376
+ display: flex;
377
+ flex-wrap: wrap;
378
+ align-items: baseline;
379
+ gap: 0.4rem 0.75rem;
380
+ font-size: 0.82rem;
381
+ }
382
+
383
+ .issue-location {
384
+ color: var(--muted);
385
+ font-size: 0.75rem;
386
+ white-space: nowrap;
387
+ }
388
+
389
+ .issue-rule {
390
+ background: var(--bg);
391
+ border: 1px solid var(--border);
392
+ border-radius: 3px;
393
+ padding: 0 0.35rem;
394
+ font-size: 0.72rem;
395
+ color: var(--muted);
396
+ white-space: nowrap;
397
+ }
398
+
399
+ .issue-message {
400
+ color: var(--text);
401
+ flex: 1;
402
+ }
403
+
404
+ /* ── Snippet ── */
405
+ .snippet {
406
+ background: var(--bg-code);
407
+ border: 1px solid var(--border);
408
+ border-radius: var(--radius);
409
+ padding: 0.65rem 0.9rem;
410
+ margin-top: 0.5rem;
411
+ overflow-x: auto;
412
+ font-size: 0.8rem;
413
+ line-height: 1.5;
414
+ }
415
+
416
+ .snippet code {
417
+ font-family: var(--font-mono);
418
+ white-space: pre;
419
+ }
420
+
421
+ /* ── Footer ── */
422
+ .footer {
423
+ margin-top: 3rem;
424
+ padding-top: 1rem;
425
+ border-top: 1px solid var(--border);
426
+ color: var(--muted);
427
+ font-size: 0.75rem;
428
+ text-align: center;
429
+ }
430
+
431
+ /* ── Responsive ── */
432
+ @media (max-width: 600px) {
433
+ .score-number { font-size: 2.8rem; }
434
+ .header { flex-direction: column; align-items: flex-start; }
435
+ .score-block { text-align: left; }
436
+ }
437
+ </style>
438
+ </head>
439
+ <body>
440
+ <div class="container">
441
+
442
+ <!-- Header -->
443
+ <header class="header">
444
+ <div class="header-left">
445
+ <h1>${escapeHtml(projectName)}</h1>
446
+ <div class="scan-date">Scanned ${escapeHtml(scanDate)}</div>
447
+ </div>
448
+ <div class="score-block">
449
+ <div class="score-number" style="color:${projColor}">${report.totalScore}</div>
450
+ <div class="score-label" style="color:${projColor}">${projLabel}</div>
451
+ </div>
452
+ </header>
453
+
454
+ <!-- Stats -->
455
+ <div class="stats-row">
456
+ <div class="stat-card">
457
+ <div class="stat-value">${report.totalFiles}</div>
458
+ <div class="stat-label">Files scanned</div>
459
+ </div>
460
+ <div class="stat-card">
461
+ <div class="stat-value">${filesWithIssues}</div>
462
+ <div class="stat-label">Files with issues</div>
463
+ </div>
464
+ <div class="stat-card">
465
+ <div class="stat-value">${report.totalIssues}</div>
466
+ <div class="stat-label">Total issues</div>
467
+ <div class="sev-breakdown">
468
+ <span style="color:var(--error)">✖ ${totalErrors}</span>
469
+ <span style="color:var(--warning)">▲ ${totalWarnings}</span>
470
+ <span style="color:var(--info)">◦ ${totalInfos}</span>
471
+ </div>
472
+ </div>
473
+ </div>
474
+
475
+ <!-- Top rules -->
476
+ ${topRules.length > 0 ? `<div class="section-title">Top issues by rule</div>
477
+ <table class="rules-table">
478
+ <thead>
479
+ <tr>
480
+ <th>Rule</th>
481
+ <th style="text-align:right">Count</th>
482
+ </tr>
483
+ </thead>
484
+ <tbody>${topRulesRows}
485
+ </tbody>
486
+ </table>` : ''}
487
+
488
+ <!-- Files -->
489
+ <div class="files-section">
490
+ <div class="section-title">Files with issues</div>
491
+ ${fileSections || '<p style="color:var(--muted);font-size:0.85rem">No issues found.</p>'}
492
+ </div>
493
+
494
+ <!-- Footer -->
495
+ <footer class="footer">Generated by drift v${VERSION}</footer>
496
+
497
+ </div>
498
+ </body>
499
+ </html>`
500
+ }