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