@eduardbar/drift 0.9.1 → 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.
Files changed (68) hide show
  1. package/.github/workflows/publish-vscode.yml +76 -0
  2. package/AGENTS.md +30 -12
  3. package/README.md +1 -1
  4. package/ROADMAP.md +130 -98
  5. package/dist/analyzer.d.ts +4 -38
  6. package/dist/analyzer.js +85 -1543
  7. package/dist/cli.js +47 -4
  8. package/dist/config.js +1 -1
  9. package/dist/fix.d.ts +13 -0
  10. package/dist/fix.js +120 -0
  11. package/dist/git/blame.d.ts +22 -0
  12. package/dist/git/blame.js +227 -0
  13. package/dist/git/helpers.d.ts +36 -0
  14. package/dist/git/helpers.js +152 -0
  15. package/dist/git/trend.d.ts +21 -0
  16. package/dist/git/trend.js +80 -0
  17. package/dist/git.d.ts +0 -4
  18. package/dist/git.js +2 -2
  19. package/dist/report.js +620 -293
  20. package/dist/rules/phase0-basic.d.ts +11 -0
  21. package/dist/rules/phase0-basic.js +176 -0
  22. package/dist/rules/phase1-complexity.d.ts +31 -0
  23. package/dist/rules/phase1-complexity.js +277 -0
  24. package/dist/rules/phase2-crossfile.d.ts +27 -0
  25. package/dist/rules/phase2-crossfile.js +122 -0
  26. package/dist/rules/phase3-arch.d.ts +31 -0
  27. package/dist/rules/phase3-arch.js +148 -0
  28. package/dist/rules/phase5-ai.d.ts +8 -0
  29. package/dist/rules/phase5-ai.js +262 -0
  30. package/dist/rules/phase8-semantic.d.ts +22 -0
  31. package/dist/rules/phase8-semantic.js +109 -0
  32. package/dist/rules/shared.d.ts +7 -0
  33. package/dist/rules/shared.js +27 -0
  34. package/package.json +8 -3
  35. package/packages/vscode-drift/.vscodeignore +9 -0
  36. package/packages/vscode-drift/LICENSE +21 -0
  37. package/packages/vscode-drift/README.md +64 -0
  38. package/packages/vscode-drift/images/icon.png +0 -0
  39. package/packages/vscode-drift/images/icon.svg +30 -0
  40. package/packages/vscode-drift/package-lock.json +485 -0
  41. package/packages/vscode-drift/package.json +119 -0
  42. package/packages/vscode-drift/src/analyzer.ts +38 -0
  43. package/packages/vscode-drift/src/diagnostics.ts +55 -0
  44. package/packages/vscode-drift/src/extension.ts +111 -0
  45. package/packages/vscode-drift/src/statusbar.ts +47 -0
  46. package/packages/vscode-drift/src/treeview.ts +108 -0
  47. package/packages/vscode-drift/tsconfig.json +18 -0
  48. package/packages/vscode-drift/vscode-drift-0.1.0.vsix +0 -0
  49. package/packages/vscode-drift/vscode-drift-0.1.1.vsix +0 -0
  50. package/src/analyzer.ts +124 -1773
  51. package/src/cli.ts +53 -4
  52. package/src/config.ts +1 -1
  53. package/src/fix.ts +154 -0
  54. package/src/git/blame.ts +279 -0
  55. package/src/git/helpers.ts +198 -0
  56. package/src/git/trend.ts +116 -0
  57. package/src/git.ts +2 -2
  58. package/src/report.ts +631 -296
  59. package/src/rules/phase0-basic.ts +187 -0
  60. package/src/rules/phase1-complexity.ts +302 -0
  61. package/src/rules/phase2-crossfile.ts +149 -0
  62. package/src/rules/phase3-arch.ts +179 -0
  63. package/src/rules/phase5-ai.ts +292 -0
  64. package/src/rules/phase8-semantic.ts +132 -0
  65. package/src/rules/shared.ts +39 -0
  66. package/tests/helpers.ts +45 -0
  67. package/tests/rules.test.ts +1269 -0
  68. package/vitest.config.ts +15 -0
package/src/report.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // drift-ignore-file
1
2
  import { basename } from 'node:path'
2
3
  import { createRequire } from 'node:module'
3
4
  import { DriftReport, DriftIssue } from './types.js'
@@ -5,11 +6,13 @@ import { DriftReport, DriftIssue } from './types.js'
5
6
  const require = createRequire(import.meta.url)
6
7
  const { version: VERSION } = require('../package.json') as { version: string }
7
8
 
9
+ // ─── Helpers ────────────────────────────────────────────────────────────────
10
+
8
11
  function severityColor(severity: DriftIssue['severity']): string {
9
12
  switch (severity) {
10
13
  case 'error': return '#ef4444'
11
- case 'warning': return '#eab308'
12
- case 'info': return '#94a3b8'
14
+ case 'warning': return '#f59e0b'
15
+ case 'info': return '#3b82f6'
13
16
  }
14
17
  }
15
18
 
@@ -35,6 +38,14 @@ function scoreLabel(score: number): string {
35
38
  return 'CRITICAL'
36
39
  }
37
40
 
41
+ function scoreGrade(score: number): string {
42
+ if (score < 20) return 'A'
43
+ if (score < 40) return 'B'
44
+ if (score < 60) return 'C'
45
+ if (score < 80) return 'D'
46
+ return 'F'
47
+ }
48
+
38
49
  function escapeHtml(str: string): string {
39
50
  return str
40
51
  .replace(/&/g, '&amp;')
@@ -44,119 +55,26 @@ function escapeHtml(str: string): string {
44
55
  .replace(/'/g, '&#39;')
45
56
  }
46
57
 
47
- export function generateHtmlReport(report: DriftReport): string {
48
- const projectName = basename(report.targetPath)
49
- const scanDate = new Date(report.scannedAt).toLocaleString('en-US', {
50
- year: 'numeric', month: 'short', day: 'numeric',
51
- hour: '2-digit', minute: '2-digit',
52
- })
53
-
54
- const projColor = scoreColor(report.totalScore)
55
- const projLabel = scoreLabel(report.totalScore)
56
-
57
- // count totals
58
- let totalErrors = 0
59
- let totalWarnings = 0
60
- let totalInfos = 0
61
- for (const f of report.files) {
62
- for (const issue of f.issues) {
63
- if (issue.severity === 'error') totalErrors++
64
- else if (issue.severity === 'warning') totalWarnings++
65
- else totalInfos++
66
- }
67
- }
68
-
69
- const filesWithIssues = report.files.filter(f => f.issues.length > 0).length
70
-
71
- // top issues by rule
72
- const byRule: Record<string, { count: number; severity: DriftIssue['severity'] }> = {}
73
- for (const f of report.files) {
74
- for (const issue of f.issues) {
75
- if (!byRule[issue.rule]) {
76
- byRule[issue.rule] = { count: 0, severity: issue.severity }
77
- }
78
- byRule[issue.rule].count++
79
- // escalate severity if needed
80
- const cur = byRule[issue.rule].severity
81
- if (issue.severity === 'error') byRule[issue.rule].severity = 'error'
82
- else if (issue.severity === 'warning' && cur !== 'error') byRule[issue.rule].severity = 'warning'
83
- }
84
- }
85
-
86
- const topRules = Object.entries(byRule)
87
- .sort((a, b) => b[1].count - a[1].count)
88
- .slice(0, 15)
89
-
90
- const topRulesRows = topRules.map(([rule, { count, severity }]) => {
91
- const icon = severityIcon(severity)
92
- const color = severityColor(severity)
93
- return `
94
- <tr>
95
- <td><span class="sev-icon" style="color:${color}">${icon}</span> <span class="rule-name">${escapeHtml(rule)}</span></td>
96
- <td class="count-cell">${count}</td>
97
- </tr>`
98
- }).join('')
99
-
100
- // files sections — already sorted by score desc from buildReport
101
- const fileSections = report.files
102
- .filter(f => f.issues.length > 0)
103
- .map(f => {
104
- const hasError = f.issues.some(i => i.severity === 'error')
105
- const openAttr = hasError ? ' open' : ''
106
- const fColor = scoreColor(f.score)
107
- const fLabel = scoreLabel(f.score)
108
-
109
- const issueItems = f.issues.map(issue => {
110
- const ic = severityColor(issue.severity)
111
- const ii = severityIcon(issue.severity)
112
- const snippet = issue.snippet
113
- ? `<pre class="snippet"><code>${escapeHtml(issue.snippet)}</code></pre>`
114
- : ''
115
- return `
116
- <li class="issue-item">
117
- <div class="issue-header">
118
- <span class="sev-icon" style="color:${ic}">${ii}</span>
119
- <span class="issue-location">Line ${issue.line}${issue.column > 0 ? `:${issue.column}` : ''}</span>
120
- <span class="issue-rule">${escapeHtml(issue.rule)}</span>
121
- <span class="issue-message">${escapeHtml(issue.message)}</span>
122
- </div>
123
- ${snippet}
124
- </li>`
125
- }).join('')
126
-
127
- return `
128
- <details${openAttr} class="file-section">
129
- <summary class="file-summary">
130
- <span class="file-path">${escapeHtml(f.path)}</span>
131
- <span class="file-score" style="color:${fColor}">${f.score} <span class="file-label">${fLabel}</span></span>
132
- <span class="file-count">${f.issues.length} issue${f.issues.length !== 1 ? 's' : ''}</span>
133
- </summary>
134
- <ul class="issue-list">${issueItems}
135
- </ul>
136
- </details>`
137
- }).join('\n')
58
+ // ─── CSS ────────────────────────────────────────────────────────────────────
138
59
 
139
- return `<!DOCTYPE html>
140
- <html lang="en">
141
- <head>
142
- <meta charset="UTF-8" />
143
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
144
- <title>drift report — ${escapeHtml(projectName)}</title>
145
- <style>
60
+ function buildCss(): string {
61
+ return `
146
62
  :root {
147
- --bg: #0a0a0f;
148
- --bg-card: #111118;
149
- --bg-code: #1e1e2e;
150
- --border: #2a2a3a;
151
- --text: #e2e8f0;
152
- --muted: #94a3b8;
153
- --accent: #6366f1;
154
- --error: #ef4444;
155
- --warning: #eab308;
156
- --info: #94a3b8;
157
- --green: #22c55e;
158
- --font-mono: ui-monospace, "Cascadia Code", "Fira Code", Consolas, monospace;
159
- --radius: 6px;
63
+ --bg: #0a0a0f;
64
+ --bg-card: #12121a;
65
+ --bg-hover: #1a1a2e;
66
+ --bg-code: #0d0d17;
67
+ --border: #2a2a3a;
68
+ --text: #ffffff;
69
+ --muted: #94a3b8;
70
+ --accent: #6366f1;
71
+ --accent-2: #8b5cf6;
72
+ --error: #ef4444;
73
+ --warning: #f59e0b;
74
+ --info: #3b82f6;
75
+ --success: #22c55e;
76
+ --font-mono: ui-monospace, "Cascadia Code", "Fira Code", Consolas, monospace;
77
+ --radius: 6px;
160
78
  }
161
79
 
162
80
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -165,338 +83,755 @@ export function generateHtmlReport(report: DriftReport): string {
165
83
  background: var(--bg);
166
84
  color: var(--text);
167
85
  font-family: var(--font-mono);
168
- font-size: 14px;
86
+ font-size: 13px;
169
87
  line-height: 1.6;
170
- padding: 2rem 1rem;
88
+ min-height: 100vh;
171
89
  }
172
90
 
173
- .container {
174
- max-width: 900px;
175
- margin: 0 auto;
91
+ /* ── Layout ── */
92
+ #app {
93
+ display: flex;
94
+ flex-direction: column;
95
+ min-height: 100vh;
96
+ }
97
+
98
+ header.top-header {
99
+ background: var(--bg-card);
100
+ border-bottom: 1px solid var(--border);
101
+ padding: 1rem 1.5rem;
176
102
  }
177
103
 
178
- /* ── Header ── */
179
- .header {
104
+ .header-row {
180
105
  display: flex;
181
- flex-wrap: wrap;
182
106
  align-items: center;
183
107
  justify-content: space-between;
184
- gap: 1.5rem;
185
- margin-bottom: 2rem;
186
- padding-bottom: 1.5rem;
187
- border-bottom: 1px solid var(--border);
108
+ flex-wrap: wrap;
109
+ gap: 1rem;
110
+ margin-bottom: 1rem;
188
111
  }
189
112
 
190
- .header-left h1 {
191
- font-size: 1.5rem;
113
+ .project-title {
114
+ font-size: 1.2rem;
192
115
  font-weight: 700;
193
116
  letter-spacing: -0.02em;
194
117
  }
195
118
 
196
- .header-left .scan-date {
119
+ .scan-meta {
197
120
  color: var(--muted);
198
- font-size: 0.8rem;
121
+ font-size: 0.75rem;
122
+ }
123
+
124
+ .stats-cards {
125
+ display: flex;
126
+ flex-wrap: wrap;
127
+ gap: 0.75rem;
128
+ }
129
+
130
+ .stat-card {
131
+ background: var(--bg);
132
+ border: 1px solid var(--border);
133
+ border-radius: var(--radius);
134
+ padding: 0.65rem 1rem;
135
+ min-width: 130px;
136
+ }
137
+
138
+ .stat-card .stat-value {
139
+ font-size: 1.5rem;
140
+ font-weight: 700;
141
+ line-height: 1;
142
+ }
143
+
144
+ .stat-card .stat-label {
145
+ color: var(--muted);
146
+ font-size: 0.7rem;
199
147
  margin-top: 0.25rem;
148
+ text-transform: uppercase;
149
+ letter-spacing: 0.06em;
150
+ }
151
+
152
+ .layout {
153
+ display: flex;
154
+ flex: 1;
155
+ overflow: hidden;
156
+ }
157
+
158
+ /* ── Sidebar ── */
159
+ #sidebar {
160
+ width: 280px;
161
+ flex-shrink: 0;
162
+ background: var(--bg-card);
163
+ border-right: 1px solid var(--border);
164
+ overflow-y: auto;
165
+ padding: 1rem;
166
+ display: flex;
167
+ flex-direction: column;
168
+ gap: 1.25rem;
169
+ }
170
+
171
+ .sidebar-block {
172
+ display: flex;
173
+ flex-direction: column;
174
+ gap: 0.5rem;
200
175
  }
201
176
 
177
+ .sidebar-label {
178
+ font-size: 0.65rem;
179
+ font-weight: 600;
180
+ letter-spacing: 0.12em;
181
+ text-transform: uppercase;
182
+ color: var(--muted);
183
+ }
184
+
185
+ /* Score block in sidebar */
202
186
  .score-block {
203
- text-align: right;
187
+ display: flex;
188
+ align-items: baseline;
189
+ gap: 0.6rem;
204
190
  }
205
191
 
206
192
  .score-number {
207
- font-size: 4rem;
193
+ font-size: 3rem;
208
194
  font-weight: 800;
209
195
  line-height: 1;
210
196
  letter-spacing: -0.04em;
211
197
  }
212
198
 
199
+ .score-right {
200
+ display: flex;
201
+ flex-direction: column;
202
+ gap: 0.2rem;
203
+ }
204
+
213
205
  .score-label {
214
- font-size: 0.75rem;
206
+ font-size: 0.65rem;
215
207
  font-weight: 600;
216
208
  letter-spacing: 0.1em;
217
209
  text-transform: uppercase;
218
- margin-top: 0.2rem;
219
210
  }
220
211
 
221
- /* ── Stats row ── */
222
- .stats-row {
212
+ .grade-badge {
213
+ display: inline-block;
214
+ font-size: 1.1rem;
215
+ font-weight: 800;
216
+ width: 2rem;
217
+ height: 2rem;
218
+ line-height: 2rem;
219
+ text-align: center;
220
+ border-radius: 4px;
221
+ background: var(--bg-hover);
222
+ border: 1px solid var(--border);
223
+ }
224
+
225
+ /* Severity checkboxes */
226
+ .sev-check-list {
223
227
  display: flex;
224
- flex-wrap: wrap;
225
- gap: 1rem;
226
- margin-bottom: 2rem;
228
+ flex-direction: column;
229
+ gap: 0.4rem;
227
230
  }
228
231
 
229
- .stat-card {
230
- flex: 1 1 140px;
231
- background: var(--bg-card);
232
+ .sev-check-item {
233
+ display: flex;
234
+ align-items: center;
235
+ gap: 0.5rem;
236
+ cursor: pointer;
237
+ font-size: 0.82rem;
238
+ padding: 0.25rem 0.4rem;
239
+ border-radius: 4px;
240
+ }
241
+
242
+ .sev-check-item:hover { background: var(--bg-hover); }
243
+
244
+ .sev-check-item input[type="checkbox"] {
245
+ accent-color: var(--accent);
246
+ width: 14px;
247
+ height: 14px;
248
+ cursor: pointer;
249
+ }
250
+
251
+ .sev-dot {
252
+ width: 8px;
253
+ height: 8px;
254
+ border-radius: 50%;
255
+ flex-shrink: 0;
256
+ }
257
+
258
+ /* File search */
259
+ #file-search {
260
+ width: 100%;
261
+ background: var(--bg);
232
262
  border: 1px solid var(--border);
233
263
  border-radius: var(--radius);
234
- padding: 0.9rem 1.1rem;
264
+ color: var(--text);
265
+ font-family: var(--font-mono);
266
+ font-size: 0.8rem;
267
+ padding: 0.45rem 0.7rem;
268
+ outline: none;
269
+ transition: border-color 0.15s;
235
270
  }
236
271
 
237
- .stat-card .stat-value {
238
- font-size: 1.6rem;
239
- font-weight: 700;
240
- line-height: 1;
272
+ #file-search:focus { border-color: var(--accent); }
273
+ #file-search::placeholder { color: var(--muted); }
274
+
275
+ /* Rules list */
276
+ .rules-list {
277
+ display: flex;
278
+ flex-direction: column;
279
+ gap: 2px;
280
+ max-height: 280px;
281
+ overflow-y: auto;
241
282
  }
242
283
 
243
- .stat-card .stat-label {
244
- color: var(--muted);
284
+ .rule-filter-btn {
285
+ display: flex;
286
+ align-items: center;
287
+ justify-content: space-between;
288
+ background: transparent;
289
+ border: none;
290
+ color: var(--text);
291
+ font-family: var(--font-mono);
245
292
  font-size: 0.75rem;
246
- margin-top: 0.3rem;
293
+ padding: 0.35rem 0.5rem;
294
+ border-radius: 4px;
295
+ cursor: pointer;
296
+ text-align: left;
297
+ border-left: 3px solid transparent;
298
+ transition: background 0.1s, border-color 0.1s;
247
299
  }
248
300
 
249
- .stat-card .sev-breakdown {
250
- display: flex;
251
- gap: 0.8rem;
252
- margin-top: 0.4rem;
253
- font-size: 0.8rem;
301
+ .rule-filter-btn:hover { background: var(--bg-hover); }
302
+
303
+ .rule-filter-btn.active {
304
+ border-left-color: var(--accent);
305
+ background: var(--bg-hover);
306
+ color: var(--accent);
254
307
  }
255
308
 
256
- /* ── Top rules table ── */
257
- .section-title {
258
- font-size: 0.7rem;
259
- font-weight: 600;
260
- letter-spacing: 0.12em;
261
- text-transform: uppercase;
309
+ .rule-filter-btn .rule-count {
262
310
  color: var(--muted);
263
- margin-bottom: 0.75rem;
311
+ font-size: 0.7rem;
312
+ background: var(--bg);
313
+ border: 1px solid var(--border);
314
+ border-radius: 99px;
315
+ padding: 0 0.4rem;
316
+ min-width: 1.6rem;
317
+ text-align: center;
318
+ flex-shrink: 0;
264
319
  }
265
320
 
266
- .rules-table {
321
+ .rule-filter-btn.active .rule-count {
322
+ background: var(--bg-hover);
323
+ border-color: var(--accent);
324
+ }
325
+
326
+ /* Reset button */
327
+ #reset-filters {
267
328
  width: 100%;
268
- border-collapse: collapse;
269
- margin-bottom: 2rem;
270
- background: var(--bg-card);
329
+ background: transparent;
271
330
  border: 1px solid var(--border);
272
331
  border-radius: var(--radius);
273
- overflow: hidden;
332
+ color: var(--muted);
333
+ font-family: var(--font-mono);
334
+ font-size: 0.75rem;
335
+ padding: 0.4rem;
336
+ cursor: pointer;
337
+ transition: border-color 0.15s, color 0.15s;
274
338
  }
275
339
 
276
- .rules-table th {
277
- text-align: left;
278
- font-size: 0.7rem;
279
- letter-spacing: 0.08em;
280
- text-transform: uppercase;
281
- color: var(--muted);
282
- padding: 0.6rem 1rem;
283
- border-bottom: 1px solid var(--border);
340
+ #reset-filters:hover {
341
+ border-color: var(--accent);
342
+ color: var(--text);
284
343
  }
285
344
 
286
- .rules-table td {
287
- padding: 0.55rem 1rem;
288
- border-bottom: 1px solid var(--border);
289
- vertical-align: middle;
345
+ /* ── Main panel ── */
346
+ #main {
347
+ flex: 1;
348
+ overflow-y: auto;
349
+ padding: 1rem 1.25rem;
290
350
  }
291
351
 
292
- .rules-table tr:last-child td { border-bottom: none; }
352
+ .main-header {
353
+ display: flex;
354
+ align-items: center;
355
+ justify-content: space-between;
356
+ margin-bottom: 1rem;
357
+ padding-bottom: 0.75rem;
358
+ border-bottom: 1px solid var(--border);
359
+ }
293
360
 
294
- .rules-table .count-cell {
295
- text-align: right;
361
+ #issue-counter {
362
+ font-size: 0.75rem;
296
363
  color: var(--muted);
297
- font-size: 0.85rem;
298
- width: 60px;
299
364
  }
300
365
 
301
- .rule-name { color: var(--text); }
302
- .sev-icon { margin-right: 0.4rem; }
303
-
304
366
  /* ── File sections ── */
305
- .files-section { margin-top: 2rem; }
306
-
307
367
  .file-section {
308
368
  background: var(--bg-card);
309
369
  border: 1px solid var(--border);
310
370
  border-radius: var(--radius);
311
- margin-bottom: 0.75rem;
371
+ margin-bottom: 0.6rem;
312
372
  overflow: hidden;
313
373
  }
314
374
 
315
- .file-summary {
375
+ .file-section > summary {
316
376
  display: flex;
317
377
  flex-wrap: wrap;
318
378
  align-items: center;
319
- gap: 0.75rem;
320
- padding: 0.75rem 1rem;
379
+ gap: 0.6rem;
380
+ padding: 0.65rem 0.9rem;
321
381
  cursor: pointer;
322
382
  user-select: none;
323
383
  list-style: none;
324
384
  }
325
385
 
326
- .file-summary::-webkit-details-marker { display: none; }
386
+ .file-section > summary::-webkit-details-marker { display: none; }
327
387
 
328
- .file-summary::before {
388
+ .file-section > summary::before {
329
389
  content: '▶';
330
- font-size: 0.65rem;
390
+ font-size: 0.6rem;
331
391
  color: var(--muted);
332
392
  transition: transform 0.15s;
333
393
  flex-shrink: 0;
334
394
  }
335
395
 
336
- details[open] > .file-summary::before { transform: rotate(90deg); }
396
+ details[open] > summary::before { transform: rotate(90deg); }
337
397
 
338
- .file-path {
398
+ .file-section > summary:hover { background: var(--bg-hover); }
399
+
400
+ .file-name {
401
+ font-size: 0.82rem;
402
+ font-weight: 600;
339
403
  flex: 1;
340
- font-size: 0.85rem;
341
404
  word-break: break-all;
342
405
  }
343
406
 
407
+ .file-path-full {
408
+ font-size: 0.7rem;
409
+ color: var(--muted);
410
+ word-break: break-all;
411
+ flex-basis: 100%;
412
+ padding-left: 1.2rem;
413
+ }
414
+
344
415
  .file-score {
345
- font-size: 0.9rem;
416
+ font-size: 0.85rem;
346
417
  font-weight: 700;
418
+ white-space: nowrap;
347
419
  }
348
420
 
349
- .file-label {
350
- font-size: 0.65rem;
421
+ .issue-badge {
422
+ font-size: 0.68rem;
351
423
  font-weight: 600;
352
- letter-spacing: 0.08em;
353
- text-transform: uppercase;
354
- }
355
-
356
- .file-count {
357
- font-size: 0.75rem;
358
- color: var(--muted);
359
- background: var(--bg);
360
- border: 1px solid var(--border);
361
424
  border-radius: 99px;
362
- padding: 0.1rem 0.55rem;
425
+ padding: 0.08rem 0.5rem;
426
+ border: 1px solid;
363
427
  white-space: nowrap;
364
428
  }
365
429
 
366
- /* ── Issue list ── */
367
- .issue-list {
368
- list-style: none;
369
- padding: 0 1rem 0.75rem;
430
+ .issue-badge.error { color: var(--error); border-color: var(--error); background: #ef444418; }
431
+ .issue-badge.warning { color: var(--warning); border-color: var(--warning); background: #f59e0b18; }
432
+ .issue-badge.info { color: var(--info); border-color: var(--info); background: #3b82f618; }
433
+
434
+ /* ── Issue rows ── */
435
+ .issues-list {
436
+ padding: 0 0.9rem 0.75rem;
370
437
  }
371
438
 
372
- .issue-item {
373
- padding: 0.6rem 0;
439
+ .issue-row {
440
+ display: grid;
441
+ grid-template-columns: 52px 20px 1fr;
442
+ grid-template-rows: auto auto;
443
+ column-gap: 0.5rem;
444
+ row-gap: 0.2rem;
445
+ padding: 0.55rem 0;
374
446
  border-top: 1px solid var(--border);
447
+ font-size: 0.8rem;
375
448
  }
376
449
 
377
- .issue-header {
378
- display: flex;
379
- flex-wrap: wrap;
380
- align-items: baseline;
381
- gap: 0.4rem 0.75rem;
382
- font-size: 0.82rem;
450
+ .issue-line {
451
+ color: var(--muted);
452
+ font-size: 0.72rem;
453
+ white-space: nowrap;
454
+ align-self: center;
383
455
  }
384
456
 
385
- .issue-location {
386
- color: var(--muted);
457
+ .issue-sev {
458
+ align-self: center;
387
459
  font-size: 0.75rem;
388
- white-space: nowrap;
460
+ }
461
+
462
+ .issue-rule-msg {
463
+ display: flex;
464
+ flex-wrap: wrap;
465
+ align-items: baseline;
466
+ gap: 0.4rem;
467
+ grid-column: 3;
389
468
  }
390
469
 
391
470
  .issue-rule {
392
471
  background: var(--bg);
393
472
  border: 1px solid var(--border);
394
473
  border-radius: 3px;
395
- padding: 0 0.35rem;
396
- font-size: 0.72rem;
474
+ padding: 0 0.3rem;
475
+ font-size: 0.7rem;
397
476
  color: var(--muted);
398
477
  white-space: nowrap;
399
478
  }
400
479
 
401
- .issue-message {
480
+ .issue-msg {
402
481
  color: var(--text);
403
- flex: 1;
482
+ font-size: 0.8rem;
404
483
  }
405
484
 
406
- /* ── Snippet ── */
407
- .snippet {
485
+ .issue-snippet {
486
+ grid-column: 1 / -1;
408
487
  background: var(--bg-code);
409
488
  border: 1px solid var(--border);
410
- border-radius: var(--radius);
411
- padding: 0.65rem 0.9rem;
412
- margin-top: 0.5rem;
489
+ border-radius: 4px;
490
+ padding: 0.5rem 0.75rem;
491
+ margin-top: 0.35rem;
492
+ font-size: 12px;
493
+ font-family: var(--font-mono);
413
494
  overflow-x: auto;
414
- font-size: 0.8rem;
495
+ white-space: pre;
415
496
  line-height: 1.5;
416
497
  }
417
498
 
418
- .snippet code {
419
- font-family: var(--font-mono);
420
- white-space: pre;
499
+ /* ── Empty state ── */
500
+ .empty-state {
501
+ text-align: center;
502
+ color: var(--muted);
503
+ padding: 3rem 1rem;
504
+ font-size: 0.85rem;
421
505
  }
422
506
 
423
507
  /* ── Footer ── */
424
508
  .footer {
425
- margin-top: 3rem;
426
- padding-top: 1rem;
509
+ background: var(--bg-card);
427
510
  border-top: 1px solid var(--border);
511
+ padding: 0.6rem 1.5rem;
428
512
  color: var(--muted);
429
- font-size: 0.75rem;
513
+ font-size: 0.7rem;
430
514
  text-align: center;
431
515
  }
432
516
 
517
+ /* ── Scrollbars ── */
518
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
519
+ ::-webkit-scrollbar-track { background: transparent; }
520
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
521
+ ::-webkit-scrollbar-thumb:hover { background: var(--muted); }
522
+
433
523
  /* ── Responsive ── */
434
- @media (max-width: 600px) {
435
- .score-number { font-size: 2.8rem; }
436
- .header { flex-direction: column; align-items: flex-start; }
437
- .score-block { text-align: left; }
524
+ @media (max-width: 700px) {
525
+ .layout { flex-direction: column; }
526
+ #sidebar {
527
+ width: 100%;
528
+ border-right: none;
529
+ border-bottom: 1px solid var(--border);
530
+ max-height: 50vh;
531
+ }
532
+ .score-number { font-size: 2.2rem; }
438
533
  }
439
- </style>
534
+ `
535
+ }
536
+
537
+ // ─── JS ─────────────────────────────────────────────────────────────────────
538
+
539
+ function buildJs(): string {
540
+ return `
541
+ const state = {
542
+ severities: new Set(['error', 'warning', 'info']),
543
+ rule: null,
544
+ fileSearch: '',
545
+ };
546
+
547
+ function applyFilters() {
548
+ let visibleCount = 0;
549
+ let totalCount = 0;
550
+
551
+ document.querySelectorAll('.file-section').forEach(function(section) {
552
+ const issues = section.querySelectorAll('.issue-row');
553
+ let fileVisible = 0;
554
+
555
+ issues.forEach(function(issue) {
556
+ const sev = issue.dataset.severity;
557
+ const rule = issue.dataset.rule;
558
+ const visible =
559
+ state.severities.has(sev) &&
560
+ (state.rule === null || state.rule === rule);
561
+
562
+ issue.style.display = visible ? '' : 'none';
563
+ if (visible) { visibleCount++; fileVisible++; }
564
+ totalCount++;
565
+ });
566
+
567
+ const filePath = (section.dataset.path || '').toLowerCase();
568
+ const searchMatch = state.fileSearch === '' || filePath.includes(state.fileSearch);
569
+ section.style.display = (fileVisible > 0 && searchMatch) ? '' : 'none';
570
+ });
571
+
572
+ const counter = document.getElementById('issue-counter');
573
+ if (counter) {
574
+ counter.textContent = 'Showing ' + visibleCount + ' of ' + totalCount + ' issues';
575
+ }
576
+ }
577
+
578
+ document.querySelectorAll('.severity-filter').forEach(function(cb) {
579
+ cb.addEventListener('change', function() {
580
+ if (cb.checked) {
581
+ state.severities.add(cb.value);
582
+ } else {
583
+ state.severities.delete(cb.value);
584
+ }
585
+ applyFilters();
586
+ });
587
+ });
588
+
589
+ var fileSearch = document.getElementById('file-search');
590
+ if (fileSearch) {
591
+ fileSearch.addEventListener('input', function(e) {
592
+ state.fileSearch = e.target.value.toLowerCase();
593
+ applyFilters();
594
+ });
595
+ }
596
+
597
+ document.querySelectorAll('.rule-filter-btn').forEach(function(btn) {
598
+ btn.addEventListener('click', function() {
599
+ if (state.rule === btn.dataset.rule) {
600
+ state.rule = null;
601
+ btn.classList.remove('active');
602
+ } else {
603
+ document.querySelectorAll('.rule-filter-btn').forEach(function(b) {
604
+ b.classList.remove('active');
605
+ });
606
+ state.rule = btn.dataset.rule;
607
+ btn.classList.add('active');
608
+ }
609
+ applyFilters();
610
+ });
611
+ });
612
+
613
+ var resetBtn = document.getElementById('reset-filters');
614
+ if (resetBtn) {
615
+ resetBtn.addEventListener('click', function() {
616
+ state.severities = new Set(['error', 'warning', 'info']);
617
+ state.rule = null;
618
+ state.fileSearch = '';
619
+ document.querySelectorAll('.severity-filter').forEach(function(cb) {
620
+ cb.checked = true;
621
+ });
622
+ document.querySelectorAll('.rule-filter-btn').forEach(function(b) {
623
+ b.classList.remove('active');
624
+ });
625
+ var fs = document.getElementById('file-search');
626
+ if (fs) fs.value = '';
627
+ applyFilters();
628
+ });
629
+ }
630
+
631
+ applyFilters();
632
+ `
633
+ }
634
+
635
+ // ─── Main export ────────────────────────────────────────────────────────────
636
+
637
+ export function generateHtmlReport(report: DriftReport): string {
638
+ const projectName = basename(report.targetPath)
639
+ const scanDate = new Date(report.scannedAt).toLocaleString('en-US', {
640
+ year: 'numeric', month: 'short', day: 'numeric',
641
+ hour: '2-digit', minute: '2-digit',
642
+ })
643
+
644
+ const projColor = scoreColor(report.totalScore)
645
+ const projLabel = scoreLabel(report.totalScore)
646
+ const projGrade = scoreGrade(report.totalScore)
647
+
648
+ const filesWithIssues = report.files.filter(f => f.issues.length > 0).length
649
+
650
+ // ── Top rules for sidebar ──────────────────────────────────────────────
651
+ const topRules = Object.entries(report.summary.byRule)
652
+ .sort(([, a], [, b]) => b - a)
653
+ .slice(0, 20)
654
+
655
+ const ruleItems = topRules.map(([rule, count]) => `
656
+ <button class="rule-filter-btn" data-rule="${escapeHtml(rule)}">
657
+ <span class="rule-name">${escapeHtml(rule)}</span>
658
+ <span class="rule-count">${count}</span>
659
+ </button>`).join('')
660
+
661
+ // ── File sections ──────────────────────────────────────────────────────
662
+ const fileSections = report.files
663
+ .filter(f => f.issues.length > 0)
664
+ .sort((a, b) => {
665
+ const aErr = a.issues.filter(i => i.severity === 'error').length
666
+ const bErr = b.issues.filter(i => i.severity === 'error').length
667
+ return bErr - aErr || b.score - a.score
668
+ })
669
+ .map(f => {
670
+ const hasError = f.issues.some(i => i.severity === 'error')
671
+ const fColor = scoreColor(f.score)
672
+ const errCount = f.issues.filter(i => i.severity === 'error').length
673
+ const warnCount = f.issues.filter(i => i.severity === 'warning').length
674
+ const infoCount = f.issues.filter(i => i.severity === 'info').length
675
+
676
+ const issueRows = f.issues.map(issue => {
677
+ const sev = issue.severity
678
+ const ic = severityColor(sev)
679
+ const ii = severityIcon(sev)
680
+ const snippet = issue.snippet
681
+ ? `<pre class="issue-snippet">${escapeHtml(issue.snippet)}</pre>`
682
+ : ''
683
+ const col = issue.column > 0 ? `:${issue.column}` : ''
684
+ return `
685
+ <div class="issue-row" data-severity="${sev}" data-rule="${escapeHtml(issue.rule)}">
686
+ <span class="issue-line">L${issue.line}${escapeHtml(col)}</span>
687
+ <span class="issue-sev" style="color:${ic}">${ii}</span>
688
+ <div class="issue-rule-msg">
689
+ <span class="issue-rule">${escapeHtml(issue.rule)}</span>
690
+ <span class="issue-msg">${escapeHtml(issue.message)}</span>
691
+ </div>
692
+ ${snippet}
693
+ </div>`
694
+ }).join('')
695
+
696
+ const badgesHtml = [
697
+ errCount > 0 ? `<span class="issue-badge error">${errCount} err</span>` : '',
698
+ warnCount > 0 ? `<span class="issue-badge warning">${warnCount} warn</span>` : '',
699
+ infoCount > 0 ? `<span class="issue-badge info">${infoCount} info</span>` : '',
700
+ ].join('')
701
+
702
+ return `
703
+ <details class="file-section" data-path="${escapeHtml(f.path)}"${hasError ? ' open' : ''}>
704
+ <summary>
705
+ <span class="file-name">${escapeHtml(basename(f.path))}</span>
706
+ <span class="file-path-full">${escapeHtml(f.path)}</span>
707
+ <span class="file-score" style="color:${fColor}">${f.score}/100</span>
708
+ ${badgesHtml}
709
+ </summary>
710
+ <div class="issues-list">${issueRows}
711
+ </div>
712
+ </details>`
713
+ }).join('\n')
714
+
715
+ const noIssues = `<div class="empty-state">No issues found. Clean codebase.</div>`
716
+
717
+ return `<!DOCTYPE html>
718
+ <html lang="en">
719
+ <head>
720
+ <meta charset="UTF-8" />
721
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
722
+ <title>drift report — ${escapeHtml(projectName)}</title>
723
+ <style>${buildCss()}</style>
440
724
  </head>
441
725
  <body>
442
- <div class="container">
726
+ <div id="app">
443
727
 
444
728
  <!-- Header -->
445
- <header class="header">
446
- <div class="header-left">
447
- <h1>${escapeHtml(projectName)}</h1>
448
- <div class="scan-date">Scanned ${escapeHtml(scanDate)}</div>
729
+ <header class="top-header">
730
+ <div class="header-row">
731
+ <div>
732
+ <div class="project-title">${escapeHtml(projectName)}</div>
733
+ <div class="scan-meta">Scanned ${escapeHtml(scanDate)} · drift v${VERSION}</div>
734
+ </div>
449
735
  </div>
450
- <div class="score-block">
451
- <div class="score-number" style="color:${projColor}">${report.totalScore}</div>
452
- <div class="score-label" style="color:${projColor}">${projLabel}</div>
736
+ <div class="stats-cards">
737
+ <div class="stat-card">
738
+ <div class="stat-value">${report.totalFiles}</div>
739
+ <div class="stat-label">Total files</div>
740
+ </div>
741
+ <div class="stat-card">
742
+ <div class="stat-value">${filesWithIssues}</div>
743
+ <div class="stat-label">Files with issues</div>
744
+ </div>
745
+ <div class="stat-card">
746
+ <div class="stat-value">${report.totalIssues}</div>
747
+ <div class="stat-label">Total issues</div>
748
+ </div>
749
+ <div class="stat-card">
750
+ <div class="stat-value" style="color:${projColor}">${report.totalScore}</div>
751
+ <div class="stat-label">Score (drift)</div>
752
+ </div>
453
753
  </div>
454
754
  </header>
455
755
 
456
- <!-- Stats -->
457
- <div class="stats-row">
458
- <div class="stat-card">
459
- <div class="stat-value">${report.totalFiles}</div>
460
- <div class="stat-label">Files scanned</div>
461
- </div>
462
- <div class="stat-card">
463
- <div class="stat-value">${filesWithIssues}</div>
464
- <div class="stat-label">Files with issues</div>
465
- </div>
466
- <div class="stat-card">
467
- <div class="stat-value">${report.totalIssues}</div>
468
- <div class="stat-label">Total issues</div>
469
- <div class="sev-breakdown">
470
- <span style="color:var(--error)">✖ ${totalErrors}</span>
471
- <span style="color:var(--warning)">▲ ${totalWarnings}</span>
472
- <span style="color:var(--info)">◦ ${totalInfos}</span>
756
+ <div class="layout">
757
+
758
+ <!-- Sidebar -->
759
+ <aside id="sidebar">
760
+
761
+ <!-- Score + Grade -->
762
+ <div class="sidebar-block">
763
+ <div class="sidebar-label">Drift Score</div>
764
+ <div class="score-block">
765
+ <div class="score-number" style="color:${projColor}">${report.totalScore}</div>
766
+ <div class="score-right">
767
+ <div class="score-label" style="color:${projColor}">${projLabel}</div>
768
+ <div class="grade-badge" style="color:${projColor}">${projGrade}</div>
769
+ </div>
770
+ </div>
473
771
  </div>
474
- </div>
475
- </div>
476
-
477
- <!-- Top rules -->
478
- ${topRules.length > 0 ? `<div class="section-title">Top issues by rule</div>
479
- <table class="rules-table">
480
- <thead>
481
- <tr>
482
- <th>Rule</th>
483
- <th style="text-align:right">Count</th>
484
- </tr>
485
- </thead>
486
- <tbody>${topRulesRows}
487
- </tbody>
488
- </table>` : ''}
489
-
490
- <!-- Files -->
491
- <div class="files-section">
492
- <div class="section-title">Files with issues</div>
493
- ${fileSections || '<p style="color:var(--muted);font-size:0.85rem">No issues found.</p>'}
494
- </div>
495
-
496
- <!-- Footer -->
772
+
773
+ <!-- Severity filters -->
774
+ <div class="sidebar-block">
775
+ <div class="sidebar-label">Severity</div>
776
+ <div class="sev-check-list">
777
+ <label class="sev-check-item">
778
+ <input type="checkbox" class="severity-filter" value="error" checked />
779
+ <span class="sev-dot" style="background:var(--error)"></span>
780
+ <span style="color:var(--error)">✖ Error</span>
781
+ <span style="color:var(--muted);margin-left:auto;font-size:0.7rem">${report.summary.errors}</span>
782
+ </label>
783
+ <label class="sev-check-item">
784
+ <input type="checkbox" class="severity-filter" value="warning" checked />
785
+ <span class="sev-dot" style="background:var(--warning)"></span>
786
+ <span style="color:var(--warning)">▲ Warning</span>
787
+ <span style="color:var(--muted);margin-left:auto;font-size:0.7rem">${report.summary.warnings}</span>
788
+ </label>
789
+ <label class="sev-check-item">
790
+ <input type="checkbox" class="severity-filter" value="info" checked />
791
+ <span class="sev-dot" style="background:var(--info)"></span>
792
+ <span style="color:var(--info)">◦ Info</span>
793
+ <span style="color:var(--muted);margin-left:auto;font-size:0.7rem">${report.summary.infos}</span>
794
+ </label>
795
+ </div>
796
+ </div>
797
+
798
+ <!-- File search -->
799
+ <div class="sidebar-block">
800
+ <div class="sidebar-label">Search files</div>
801
+ <input id="file-search" type="text" placeholder="Filter by filename…" autocomplete="off" />
802
+ </div>
803
+
804
+ <!-- Rules list -->
805
+ ${topRules.length > 0 ? `
806
+ <div class="sidebar-block">
807
+ <div class="sidebar-label">Rules (click to filter)</div>
808
+ <div class="rules-list">${ruleItems}
809
+ </div>
810
+ </div>` : ''}
811
+
812
+ <!-- Reset -->
813
+ <button id="reset-filters">Reset filters</button>
814
+
815
+ </aside>
816
+
817
+ <!-- Main -->
818
+ <main id="main">
819
+ <div class="main-header">
820
+ <span id="issue-counter" style="color:var(--muted);font-size:0.75rem">Loading…</span>
821
+ </div>
822
+ ${fileSections || noIssues}
823
+ </main>
824
+
825
+ </div><!-- .layout -->
826
+
497
827
  <footer class="footer">Generated by drift v${VERSION}</footer>
498
828
 
499
- </div>
829
+ </div><!-- #app -->
830
+
831
+ <script>
832
+ const DRIFT_DATA = ${JSON.stringify(report)};
833
+ ${buildJs()}
834
+ </script>
500
835
  </body>
501
836
  </html>`
502
837
  }