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