@eduardbar/drift 1.0.0 → 1.2.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 (105) hide show
  1. package/.github/actions/drift-scan/README.md +61 -0
  2. package/.github/actions/drift-scan/action.yml +65 -0
  3. package/.github/workflows/publish-vscode.yml +3 -1
  4. package/.github/workflows/review-pr.yml +61 -0
  5. package/AGENTS.md +53 -11
  6. package/README.md +106 -1
  7. package/dist/analyzer.d.ts +6 -2
  8. package/dist/analyzer.js +116 -3
  9. package/dist/badge.js +40 -22
  10. package/dist/ci.js +32 -18
  11. package/dist/cli.js +179 -6
  12. package/dist/diff.d.ts +0 -7
  13. package/dist/diff.js +26 -25
  14. package/dist/fix.d.ts +4 -0
  15. package/dist/fix.js +59 -47
  16. package/dist/git/trend.js +1 -0
  17. package/dist/git.d.ts +0 -9
  18. package/dist/git.js +25 -19
  19. package/dist/index.d.ts +7 -1
  20. package/dist/index.js +4 -0
  21. package/dist/map.d.ts +4 -0
  22. package/dist/map.js +191 -0
  23. package/dist/metrics.d.ts +4 -0
  24. package/dist/metrics.js +176 -0
  25. package/dist/plugins.d.ts +6 -0
  26. package/dist/plugins.js +74 -0
  27. package/dist/printer.js +20 -0
  28. package/dist/report.js +34 -0
  29. package/dist/reporter.js +85 -2
  30. package/dist/review.d.ts +15 -0
  31. package/dist/review.js +80 -0
  32. package/dist/rules/comments.d.ts +4 -0
  33. package/dist/rules/comments.js +45 -0
  34. package/dist/rules/complexity.d.ts +4 -0
  35. package/dist/rules/complexity.js +51 -0
  36. package/dist/rules/coupling.d.ts +4 -0
  37. package/dist/rules/coupling.js +19 -0
  38. package/dist/rules/magic.d.ts +4 -0
  39. package/dist/rules/magic.js +33 -0
  40. package/dist/rules/nesting.d.ts +5 -0
  41. package/dist/rules/nesting.js +82 -0
  42. package/dist/rules/phase0-basic.js +14 -7
  43. package/dist/rules/phase1-complexity.d.ts +6 -30
  44. package/dist/rules/phase1-complexity.js +7 -276
  45. package/dist/rules/phase2-crossfile.d.ts +0 -4
  46. package/dist/rules/phase2-crossfile.js +52 -39
  47. package/dist/rules/phase3-arch.d.ts +0 -8
  48. package/dist/rules/phase3-arch.js +26 -23
  49. package/dist/rules/phase3-configurable.d.ts +6 -0
  50. package/dist/rules/phase3-configurable.js +97 -0
  51. package/dist/rules/phase8-semantic.d.ts +0 -5
  52. package/dist/rules/phase8-semantic.js +30 -29
  53. package/dist/rules/promise.d.ts +4 -0
  54. package/dist/rules/promise.js +24 -0
  55. package/dist/saas.d.ts +83 -0
  56. package/dist/saas.js +321 -0
  57. package/dist/snapshot.d.ts +19 -0
  58. package/dist/snapshot.js +119 -0
  59. package/dist/types.d.ts +75 -0
  60. package/dist/utils.d.ts +2 -1
  61. package/dist/utils.js +1 -0
  62. package/docs/AGENTS.md +146 -0
  63. package/docs/PRD.md +157 -0
  64. package/package.json +1 -1
  65. package/packages/eslint-plugin-drift/src/index.ts +1 -1
  66. package/packages/vscode-drift/package.json +1 -1
  67. package/packages/vscode-drift/src/analyzer.ts +2 -0
  68. package/packages/vscode-drift/src/code-actions.ts +53 -0
  69. package/packages/vscode-drift/src/extension.ts +98 -63
  70. package/packages/vscode-drift/src/statusbar.ts +13 -5
  71. package/packages/vscode-drift/src/treeview.ts +2 -0
  72. package/src/analyzer.ts +144 -12
  73. package/src/badge.ts +38 -16
  74. package/src/ci.ts +38 -17
  75. package/src/cli.ts +206 -7
  76. package/src/diff.ts +36 -30
  77. package/src/fix.ts +77 -53
  78. package/src/git/trend.ts +3 -2
  79. package/src/git.ts +31 -22
  80. package/src/index.ts +31 -1
  81. package/src/map.ts +219 -0
  82. package/src/metrics.ts +200 -0
  83. package/src/plugins.ts +76 -0
  84. package/src/printer.ts +20 -0
  85. package/src/report.ts +35 -0
  86. package/src/reporter.ts +95 -2
  87. package/src/review.ts +98 -0
  88. package/src/rules/comments.ts +56 -0
  89. package/src/rules/complexity.ts +57 -0
  90. package/src/rules/coupling.ts +23 -0
  91. package/src/rules/magic.ts +38 -0
  92. package/src/rules/nesting.ts +88 -0
  93. package/src/rules/phase0-basic.ts +14 -7
  94. package/src/rules/phase1-complexity.ts +8 -302
  95. package/src/rules/phase2-crossfile.ts +68 -40
  96. package/src/rules/phase3-arch.ts +34 -30
  97. package/src/rules/phase3-configurable.ts +132 -0
  98. package/src/rules/phase8-semantic.ts +33 -29
  99. package/src/rules/promise.ts +29 -0
  100. package/src/saas.ts +433 -0
  101. package/src/snapshot.ts +175 -0
  102. package/src/types.ts +81 -1
  103. package/src/utils.ts +3 -1
  104. package/tests/new-features.test.ts +180 -0
  105. package/tests/saas-foundation.test.ts +107 -0
package/dist/badge.js CHANGED
@@ -1,23 +1,41 @@
1
1
  const LEFT_WIDTH = 47;
2
2
  const CHAR_WIDTH = 7;
3
3
  const PADDING = 16;
4
+ const SVG_SCALE = 10;
5
+ const GRADE_THRESHOLDS = {
6
+ LOW: 20,
7
+ MODERATE: 45,
8
+ HIGH: 70,
9
+ };
10
+ const GRADE_COLORS = {
11
+ LOW: '#4c1',
12
+ MODERATE: '#dfb317',
13
+ HIGH: '#fe7d37',
14
+ CRITICAL: '#e05d44',
15
+ };
16
+ const GRADE_LABELS = {
17
+ LOW: 'LOW',
18
+ MODERATE: 'MODERATE',
19
+ HIGH: 'HIGH',
20
+ CRITICAL: 'CRITICAL',
21
+ };
4
22
  function scoreColor(score) {
5
- if (score < 20)
6
- return '#4c1';
7
- if (score < 45)
8
- return '#dfb317';
9
- if (score < 70)
10
- return '#fe7d37';
11
- return '#e05d44';
23
+ if (score < GRADE_THRESHOLDS.LOW)
24
+ return GRADE_COLORS.LOW;
25
+ if (score < GRADE_THRESHOLDS.MODERATE)
26
+ return GRADE_COLORS.MODERATE;
27
+ if (score < GRADE_THRESHOLDS.HIGH)
28
+ return GRADE_COLORS.HIGH;
29
+ return GRADE_COLORS.CRITICAL;
12
30
  }
13
31
  function scoreLabel(score) {
14
- if (score < 20)
15
- return 'LOW';
16
- if (score < 45)
17
- return 'MODERATE';
18
- if (score < 70)
19
- return 'HIGH';
20
- return 'CRITICAL';
32
+ if (score < GRADE_THRESHOLDS.LOW)
33
+ return GRADE_LABELS.LOW;
34
+ if (score < GRADE_THRESHOLDS.MODERATE)
35
+ return GRADE_LABELS.MODERATE;
36
+ if (score < GRADE_THRESHOLDS.HIGH)
37
+ return GRADE_LABELS.HIGH;
38
+ return GRADE_LABELS.CRITICAL;
21
39
  }
22
40
  function rightWidth(text) {
23
41
  return text.length * CHAR_WIDTH + PADDING;
@@ -29,10 +47,10 @@ export function generateBadge(score) {
29
47
  const totalWidth = LEFT_WIDTH + rWidth;
30
48
  const leftCenterX = LEFT_WIDTH / 2;
31
49
  const rightCenterX = LEFT_WIDTH + rWidth / 2;
32
- // shields.io pattern: font-size="110" + scale(.1) = effective 11px
33
- // all X/Y coords are ×10
34
- const leftTextWidth = (LEFT_WIDTH - 10) * 10;
35
- const rightTextWidth = (rWidth - PADDING) * 10;
50
+ const leftTextWidth = (LEFT_WIDTH - PADDING) * SVG_SCALE;
51
+ const rightTextWidth = (rWidth - PADDING) * SVG_SCALE;
52
+ const leftCenterXScaled = leftCenterX * SVG_SCALE;
53
+ const rightCenterXScaled = rightCenterX * SVG_SCALE;
36
54
  return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${totalWidth}" height="20">
37
55
  <linearGradient id="s" x2="0" y2="100%">
38
56
  <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
@@ -47,10 +65,10 @@ export function generateBadge(score) {
47
65
  <rect width="${totalWidth}" height="20" fill="url(#s)"/>
48
66
  </g>
49
67
  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
50
- <text x="${leftCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
51
- <text x="${leftCenterX * 10}" y="140" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
52
- <text x="${rightCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
53
- <text x="${rightCenterX * 10}" y="140" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
68
+ <text x="${leftCenterXScaled}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
69
+ <text x="${leftCenterXScaled}" y="140" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
70
+ <text x="${rightCenterXScaled}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
71
+ <text x="${rightCenterXScaled}" y="140" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
54
72
  </g>
55
73
  </svg>`;
56
74
  }
package/dist/ci.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import { relative } from 'node:path';
3
+ const GRADE_THRESHOLDS = {
4
+ A: 80,
5
+ B: 60,
6
+ C: 40,
7
+ D: 20,
8
+ };
9
+ const TOP_FILES_LIMIT = 10;
3
10
  function encodeMessage(msg) {
4
11
  return msg
5
12
  .replace(/%/g, '%25')
@@ -16,13 +23,13 @@ function severityToAnnotation(s) {
16
23
  return 'notice';
17
24
  }
18
25
  function scoreLabel(score) {
19
- if (score >= 80)
26
+ if (score >= GRADE_THRESHOLDS.A)
20
27
  return 'A';
21
- if (score >= 60)
28
+ if (score >= GRADE_THRESHOLDS.B)
22
29
  return 'B';
23
- if (score >= 40)
30
+ if (score >= GRADE_THRESHOLDS.C)
24
31
  return 'C';
25
- if (score >= 20)
32
+ if (score >= GRADE_THRESHOLDS.D)
26
33
  return 'D';
27
34
  return 'F';
28
35
  }
@@ -38,28 +45,35 @@ export function emitCIAnnotations(report) {
38
45
  }
39
46
  }
40
47
  }
48
+ function countIssuesBySeverity(report) {
49
+ let errors = 0;
50
+ let warnings = 0;
51
+ let info = 0;
52
+ for (const file of report.files) {
53
+ countFileIssues(file, { errors: () => errors++, warnings: () => warnings++, info: () => info++ });
54
+ }
55
+ return { errors, warnings, info };
56
+ }
57
+ function countFileIssues(file, counters) {
58
+ for (const issue of file.issues) {
59
+ if (issue.severity === 'error')
60
+ counters.errors();
61
+ else if (issue.severity === 'warning')
62
+ counters.warnings();
63
+ else
64
+ counters.info();
65
+ }
66
+ }
41
67
  export function printCISummary(report) {
42
68
  const summaryPath = process.env['GITHUB_STEP_SUMMARY'];
43
69
  if (!summaryPath)
44
70
  return;
45
71
  const score = report.totalScore;
46
72
  const grade = scoreLabel(score);
47
- let errors = 0;
48
- let warnings = 0;
49
- let info = 0;
50
- for (const file of report.files) {
51
- for (const issue of file.issues) {
52
- if (issue.severity === 'error')
53
- errors++;
54
- else if (issue.severity === 'warning')
55
- warnings++;
56
- else
57
- info++;
58
- }
59
- }
73
+ const { errors, warnings, info } = countIssuesBySeverity(report);
60
74
  const sorted = [...report.files]
61
75
  .sort((a, b) => b.issues.length - a.issues.length)
62
- .slice(0, 10);
76
+ .slice(0, TOP_FILES_LIMIT);
63
77
  const rows = sorted
64
78
  .map((f) => {
65
79
  const relPath = relative(process.cwd(), f.path).replace(/\\/g, '/');
package/dist/cli.js CHANGED
@@ -2,8 +2,10 @@
2
2
  // drift-ignore-file
3
3
  import { Command } from 'commander';
4
4
  import { writeFileSync } from 'node:fs';
5
- import { resolve } from 'node:path';
5
+ import { basename, resolve } from 'node:path';
6
6
  import { createRequire } from 'node:module';
7
+ import { createInterface } from 'node:readline/promises';
8
+ import { stdin as input, stdout as output } from 'node:process';
7
9
  const require = createRequire(import.meta.url);
8
10
  const { version: VERSION } = require('../package.json');
9
11
  import { analyzeProject, analyzeFile, TrendAnalyzer, BlameAnalyzer } from './analyzer.js';
@@ -16,6 +18,10 @@ import { generateHtmlReport } from './report.js';
16
18
  import { generateBadge } from './badge.js';
17
19
  import { emitCIAnnotations, printCISummary } from './ci.js';
18
20
  import { applyFixes } from './fix.js';
21
+ import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js';
22
+ import { generateReview } from './review.js';
23
+ import { generateArchitectureMap } from './map.js';
24
+ import { ingestSnapshotFromReport, getSaasSummary, generateSaasDashboardHtml } from './saas.js';
19
25
  const program = new Command();
20
26
  program
21
27
  .name('drift')
@@ -103,6 +109,44 @@ program
103
109
  cleanupTempDir(tempDir);
104
110
  }
105
111
  });
112
+ program
113
+ .command('review')
114
+ .description('Review drift against a base ref and output PR markdown')
115
+ .option('--base <ref>', 'Git base ref to compare against', 'origin/main')
116
+ .option('--json', 'Output structured review JSON')
117
+ .option('--comment', 'Output markdown comment body')
118
+ .option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
119
+ .action(async (options) => {
120
+ try {
121
+ const review = await generateReview(resolve('.'), options.base);
122
+ if (options.json) {
123
+ process.stdout.write(JSON.stringify(review, null, 2) + '\n');
124
+ }
125
+ else {
126
+ process.stdout.write((options.comment ? review.markdown : `${review.summary}\n\n${review.markdown}`) + '\n');
127
+ }
128
+ const failOn = options.failOn ? Number(options.failOn) : undefined;
129
+ if (typeof failOn === 'number' && !Number.isNaN(failOn) && review.totalDelta >= failOn) {
130
+ process.exit(1);
131
+ }
132
+ }
133
+ catch (err) {
134
+ const message = err instanceof Error ? err.message : String(err);
135
+ process.stderr.write(`\n Error: ${message}\n\n`);
136
+ process.exit(1);
137
+ }
138
+ });
139
+ program
140
+ .command('map [path]')
141
+ .description('Generate architecture.svg with simple layer dependencies')
142
+ .option('-o, --output <file>', 'Output SVG path (default: architecture.svg)', 'architecture.svg')
143
+ .action(async (targetPath, options) => {
144
+ const resolvedPath = resolve(targetPath ?? '.');
145
+ process.stderr.write(`\nBuilding architecture map for ${resolvedPath}...\n`);
146
+ const config = await loadConfig(resolvedPath);
147
+ const out = generateArchitectureMap(resolvedPath, options.output, config);
148
+ process.stderr.write(` Architecture map saved to ${out}\n\n`);
149
+ });
106
150
  program
107
151
  .command('report [path]')
108
152
  .description('Generate a self-contained HTML report')
@@ -189,21 +233,49 @@ program
189
233
  .command('fix [path]')
190
234
  .description('Auto-fix safe issues (debug-leftover console.*, catch-swallow)')
191
235
  .option('--rule <rule>', 'Fix only a specific rule')
236
+ .option('--preview', 'Preview changes without writing files')
237
+ .option('--write', 'Write fixes to disk')
192
238
  .option('--dry-run', 'Show what would change without writing files')
239
+ .option('-y, --yes', 'Skip interactive confirmation for --write')
193
240
  .action(async (targetPath, options) => {
194
241
  const resolvedPath = resolve(targetPath ?? '.');
195
242
  const config = await loadConfig(resolvedPath);
243
+ const previewMode = Boolean(options.preview || options.dryRun);
244
+ const writeMode = options.write ?? !previewMode;
245
+ if (writeMode && !options.yes) {
246
+ const previewResults = await applyFixes(resolvedPath, config, {
247
+ rule: options.rule,
248
+ dryRun: true,
249
+ preview: true,
250
+ write: false,
251
+ });
252
+ if (previewResults.length === 0) {
253
+ console.log('No fixable issues found.');
254
+ return;
255
+ }
256
+ const files = new Set(previewResults.map((result) => result.file)).size;
257
+ const prompt = `Apply ${previewResults.length} fix(es) across ${files} file(s)? [y/N] `;
258
+ const rl = createInterface({ input, output });
259
+ const answer = (await rl.question(prompt)).trim().toLowerCase();
260
+ rl.close();
261
+ if (answer !== 'y' && answer !== 'yes') {
262
+ console.log('Aborted. No files were modified.');
263
+ return;
264
+ }
265
+ }
196
266
  const results = await applyFixes(resolvedPath, config, {
197
267
  rule: options.rule,
198
- dryRun: options.dryRun,
268
+ dryRun: previewMode,
269
+ preview: previewMode,
270
+ write: writeMode,
199
271
  });
200
272
  if (results.length === 0) {
201
273
  console.log('No fixable issues found.');
202
274
  return;
203
275
  }
204
276
  const applied = results.filter(r => r.applied);
205
- if (options.dryRun) {
206
- console.log(`\ndrift fix --dry-run: ${results.length} fixable issues found\n`);
277
+ if (previewMode) {
278
+ console.log(`\ndrift fix --preview: ${results.length} fixable issues found\n`);
207
279
  }
208
280
  else {
209
281
  console.log(`\ndrift fix: ${applied.length} fixes applied\n`);
@@ -219,13 +291,114 @@ program
219
291
  const relPath = file.replace(resolvedPath + '/', '').replace(resolvedPath + '\\', '');
220
292
  console.log(` ${relPath}`);
221
293
  for (const r of fileResults) {
222
- const status = r.applied ? (options.dryRun ? 'would fix' : 'fixed') : 'skipped';
294
+ const status = r.applied ? (previewMode ? 'would fix' : 'fixed') : 'skipped';
223
295
  console.log(` [${r.rule}] line ${r.line}: ${r.description} — ${status}`);
296
+ if (r.before || r.after) {
297
+ console.log(` before: ${r.before ?? '(empty)'}`);
298
+ console.log(` after : ${r.after ?? '(empty)'}`);
299
+ }
224
300
  }
225
301
  }
226
- if (!options.dryRun && applied.length > 0) {
302
+ if (!previewMode && applied.length > 0) {
227
303
  console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`);
228
304
  }
229
305
  });
306
+ program
307
+ .command('snapshot [path]')
308
+ .description('Record a score snapshot to drift-history.json')
309
+ .option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
310
+ .option('--history', 'show all recorded snapshots')
311
+ .option('--diff', 'compare current score vs last snapshot')
312
+ .action(async (targetPath, opts) => {
313
+ const resolvedPath = resolve(targetPath ?? '.');
314
+ if (opts.history) {
315
+ const history = loadHistory(resolvedPath);
316
+ printHistory(history);
317
+ return;
318
+ }
319
+ process.stderr.write(`\nScanning ${resolvedPath}...\n`);
320
+ const config = await loadConfig(resolvedPath);
321
+ const files = analyzeProject(resolvedPath, config);
322
+ process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
323
+ const report = buildReport(resolvedPath, files);
324
+ if (opts.diff) {
325
+ const history = loadHistory(resolvedPath);
326
+ printSnapshotDiff(history, report.totalScore);
327
+ return;
328
+ }
329
+ const entry = saveSnapshot(resolvedPath, report, opts.label);
330
+ const labelStr = entry.label ? ` [${entry.label}]` : '';
331
+ process.stdout.write(` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`);
332
+ process.stdout.write(` Saved to drift-history.json\n\n`);
333
+ });
334
+ const cloud = program
335
+ .command('cloud')
336
+ .description('Local SaaS foundations: ingest, summary, and dashboard');
337
+ cloud
338
+ .command('ingest [path]')
339
+ .description('Scan path, build report, and store cloud snapshot')
340
+ .requiredOption('--workspace <id>', 'Workspace id')
341
+ .requiredOption('--user <id>', 'User id')
342
+ .option('--repo <name>', 'Repo name (default: basename of scanned path)')
343
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
344
+ .action(async (targetPath, options) => {
345
+ const resolvedPath = resolve(targetPath ?? '.');
346
+ process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`);
347
+ const config = await loadConfig(resolvedPath);
348
+ const files = analyzeProject(resolvedPath, config);
349
+ const report = buildReport(resolvedPath, files);
350
+ const snapshot = ingestSnapshotFromReport(report, {
351
+ workspaceId: options.workspace,
352
+ userId: options.user,
353
+ repoName: options.repo ?? basename(resolvedPath),
354
+ storeFile: options.store,
355
+ policy: config?.saas,
356
+ });
357
+ process.stdout.write(`Ingested snapshot ${snapshot.id}\n`);
358
+ process.stdout.write(`Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`);
359
+ process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`);
360
+ });
361
+ cloud
362
+ .command('summary')
363
+ .description('Show SaaS usage metrics and free threshold status')
364
+ .option('--json', 'Output raw JSON summary')
365
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
366
+ .action((options) => {
367
+ const summary = getSaasSummary({ storeFile: options.store });
368
+ if (options.json) {
369
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
370
+ return;
371
+ }
372
+ process.stdout.write('\n');
373
+ process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`);
374
+ process.stdout.write(`Users registered: ${summary.usersRegistered}\n`);
375
+ process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`);
376
+ process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`);
377
+ process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`);
378
+ process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`);
379
+ process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`);
380
+ process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`);
381
+ process.stdout.write('Runs per month:\n');
382
+ const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b));
383
+ if (monthly.length === 0) {
384
+ process.stdout.write(' - none\n\n');
385
+ return;
386
+ }
387
+ for (const [month, runs] of monthly) {
388
+ process.stdout.write(` - ${month}: ${runs}\n`);
389
+ }
390
+ process.stdout.write('\n');
391
+ });
392
+ cloud
393
+ .command('dashboard')
394
+ .description('Generate an HTML dashboard with trends and hotspots')
395
+ .option('-o, --output <file>', 'Output HTML file', 'drift-cloud-dashboard.html')
396
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
397
+ .action((options) => {
398
+ const html = generateSaasDashboardHtml({ storeFile: options.store });
399
+ const outPath = resolve(options.output);
400
+ writeFileSync(outPath, html, 'utf8');
401
+ process.stdout.write(`Dashboard saved to ${outPath}\n`);
402
+ });
230
403
  program.parse();
231
404
  //# sourceMappingURL=cli.js.map
package/dist/diff.d.ts CHANGED
@@ -1,10 +1,3 @@
1
1
  import type { DriftReport, DriftDiff } from './types.js';
2
- /**
3
- * Compute the diff between two DriftReports.
4
- *
5
- * Issues are matched by (rule + line + column) as a unique key within a file.
6
- * A "new" issue exists in `current` but not in `base`.
7
- * A "resolved" issue exists in `base` but not in `current`.
8
- */
9
2
  export declare function computeDiff(base: DriftReport, current: DriftReport, baseRef: string): DriftDiff;
10
3
  //# sourceMappingURL=diff.d.ts.map
package/dist/diff.js CHANGED
@@ -5,12 +5,33 @@
5
5
  * A "new" issue exists in `current` but not in `base`.
6
6
  * A "resolved" issue exists in `base` but not in `current`.
7
7
  */
8
+ function computeFileDiff(filePath, baseFile, currentFile) {
9
+ const scoreBefore = baseFile?.score ?? 0;
10
+ const scoreAfter = currentFile?.score ?? 0;
11
+ const scoreDelta = scoreAfter - scoreBefore;
12
+ const baseIssues = baseFile?.issues ?? [];
13
+ const currentIssues = currentFile?.issues ?? [];
14
+ const issueKey = (i) => `${i.rule}:${i.line}:${i.column}`;
15
+ const baseKeys = new Set(baseIssues.map(issueKey));
16
+ const currentKeys = new Set(currentIssues.map(issueKey));
17
+ const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)));
18
+ const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)));
19
+ if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
20
+ return {
21
+ path: filePath,
22
+ scoreBefore,
23
+ scoreAfter,
24
+ scoreDelta,
25
+ newIssues,
26
+ resolvedIssues,
27
+ };
28
+ }
29
+ return null;
30
+ }
8
31
  export function computeDiff(base, current, baseRef) {
9
32
  const fileDiffs = [];
10
- // Build a map of base files by path for O(1) lookup
11
33
  const baseByPath = new Map(base.files.map(f => [f.path, f]));
12
34
  const currentByPath = new Map(current.files.map(f => [f.path, f]));
13
- // All unique paths across both reports
14
35
  const allPaths = new Set([
15
36
  ...base.files.map(f => f.path),
16
37
  ...current.files.map(f => f.path),
@@ -18,30 +39,10 @@ export function computeDiff(base, current, baseRef) {
18
39
  for (const filePath of allPaths) {
19
40
  const baseFile = baseByPath.get(filePath);
20
41
  const currentFile = currentByPath.get(filePath);
21
- const scoreBefore = baseFile?.score ?? 0;
22
- const scoreAfter = currentFile?.score ?? 0;
23
- const scoreDelta = scoreAfter - scoreBefore;
24
- const baseIssues = baseFile?.issues ?? [];
25
- const currentIssues = currentFile?.issues ?? [];
26
- // Issue identity key: rule + line + column
27
- const issueKey = (i) => `${i.rule}:${i.line}:${i.column}`;
28
- const baseKeys = new Set(baseIssues.map(issueKey));
29
- const currentKeys = new Set(currentIssues.map(issueKey));
30
- const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)));
31
- const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)));
32
- // Only include files that have actual changes
33
- if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
34
- fileDiffs.push({
35
- path: filePath,
36
- scoreBefore,
37
- scoreAfter,
38
- scoreDelta,
39
- newIssues,
40
- resolvedIssues,
41
- });
42
- }
42
+ const diff = computeFileDiff(filePath, baseFile, currentFile);
43
+ if (diff)
44
+ fileDiffs.push(diff);
43
45
  }
44
- // Sort: most regressed first, then most improved last
45
46
  fileDiffs.sort((a, b) => b.scoreDelta - a.scoreDelta);
46
47
  return {
47
48
  baseRef,
package/dist/fix.d.ts CHANGED
@@ -5,9 +5,13 @@ export interface FixResult {
5
5
  line: number;
6
6
  description: string;
7
7
  applied: boolean;
8
+ before?: string;
9
+ after?: string;
8
10
  }
9
11
  export declare function applyFixes(targetPath: string, config?: DriftConfig, options?: {
10
12
  rule?: string;
11
13
  dryRun?: boolean;
14
+ write?: boolean;
15
+ preview?: boolean;
12
16
  }): Promise<FixResult[]>;
13
17
  //# sourceMappingURL=fix.d.ts.map
package/dist/fix.js CHANGED
@@ -50,10 +50,65 @@ function applyFixToLines(lines, issue) {
50
50
  }
51
51
  return null;
52
52
  }
53
+ function collectFixableIssues(fileReports, options) {
54
+ const fixableByFile = new Map();
55
+ for (const report of fileReports) {
56
+ const fixableIssues = report.issues.filter(issue => {
57
+ if (!isFixable(issue))
58
+ return false;
59
+ if (options?.rule && issue.rule !== options.rule)
60
+ return false;
61
+ return true;
62
+ });
63
+ if (fixableIssues.length > 0) {
64
+ fixableByFile.set(report.path, fixableIssues);
65
+ }
66
+ }
67
+ return fixableByFile;
68
+ }
69
+ function processFile(filePath, issues, dryRun) {
70
+ const content = readFileSync(filePath, 'utf8');
71
+ let lines = content.split('\n');
72
+ const results = [];
73
+ const sortedIssues = [...issues].sort((a, b) => b.line - a.line);
74
+ for (const issue of sortedIssues) {
75
+ const before = lines[issue.line - 1]?.trim() ?? '';
76
+ const fixResult = applyFixToLines(lines, issue);
77
+ if (fixResult) {
78
+ const after = fixResult.newLines[issue.line - 1]?.trim() ?? '';
79
+ results.push({
80
+ file: filePath,
81
+ rule: issue.rule,
82
+ line: issue.line,
83
+ description: fixResult.description,
84
+ applied: true,
85
+ before,
86
+ after,
87
+ });
88
+ lines = fixResult.newLines;
89
+ }
90
+ else {
91
+ results.push({
92
+ file: filePath,
93
+ rule: issue.rule,
94
+ line: issue.line,
95
+ description: 'no fix available',
96
+ applied: false,
97
+ });
98
+ }
99
+ }
100
+ if (!dryRun) {
101
+ writeFileSync(filePath, lines.join('\n'), 'utf8');
102
+ }
103
+ return results;
104
+ }
53
105
  export async function applyFixes(targetPath, config, options) {
54
106
  const resolvedPath = resolve(targetPath);
55
- const dryRun = options?.dryRun ?? false;
56
- // Determine if target is a file or directory
107
+ const dryRun = options?.write
108
+ ? false
109
+ : options?.preview || options?.dryRun
110
+ ? true
111
+ : false;
57
112
  let fileReports;
58
113
  const stat = statSync(resolvedPath);
59
114
  if (stat.isFile()) {
@@ -67,53 +122,10 @@ export async function applyFixes(targetPath, config, options) {
67
122
  else {
68
123
  fileReports = analyzeProject(resolvedPath, config);
69
124
  }
70
- // Collect fixable issues, optionally filtered by rule
71
- const fixableByFile = new Map();
72
- for (const report of fileReports) {
73
- const fixableIssues = report.issues.filter(issue => {
74
- if (!isFixable(issue))
75
- return false;
76
- if (options?.rule && issue.rule !== options.rule)
77
- return false;
78
- return true;
79
- });
80
- if (fixableIssues.length > 0) {
81
- fixableByFile.set(report.path, fixableIssues);
82
- }
83
- }
125
+ const fixableByFile = collectFixableIssues(fileReports, options);
84
126
  const results = [];
85
127
  for (const [filePath, issues] of fixableByFile) {
86
- const content = readFileSync(filePath, 'utf8');
87
- let lines = content.split('\n');
88
- // Sort issues by line descending to avoid line number drift after fixes
89
- const sortedIssues = [...issues].sort((a, b) => b.line - a.line);
90
- // Track line offset caused by deletions (debug-leftover removes lines)
91
- // We process top-to-bottom after sorting descending, so no offset needed per issue
92
- for (const issue of sortedIssues) {
93
- const fixResult = applyFixToLines(lines, issue);
94
- if (fixResult) {
95
- results.push({
96
- file: filePath,
97
- rule: issue.rule,
98
- line: issue.line,
99
- description: fixResult.description,
100
- applied: true,
101
- });
102
- lines = fixResult.newLines;
103
- }
104
- else {
105
- results.push({
106
- file: filePath,
107
- rule: issue.rule,
108
- line: issue.line,
109
- description: 'no fix available',
110
- applied: false,
111
- });
112
- }
113
- }
114
- if (!dryRun) {
115
- writeFileSync(filePath, lines.join('\n'), 'utf8');
116
- }
128
+ results.push(...processFile(filePath, issues, dryRun));
117
129
  }
118
130
  return results;
119
131
  }
package/dist/git/trend.js CHANGED
@@ -1,3 +1,4 @@
1
+ // drift-ignore-file
1
2
  import { assertGitRepo, analyzeHistoricalCommits } from './helpers.js';
2
3
  import { buildReport } from '../reporter.js';
3
4
  export class TrendAnalyzer {
package/dist/git.d.ts CHANGED
@@ -1,12 +1,3 @@
1
- /**
2
- * Extract all TypeScript files from the project at a given git ref into a
3
- * temporary directory. Returns the temp directory path.
4
- *
5
- * Uses `git ls-tree` to list files and `git show <ref>:<path>` to read each
6
- * file — no checkout, no stash, no repo state mutation.
7
- *
8
- * Throws if the directory is not a git repo or the ref is invalid.
9
- */
10
1
  export declare function extractFilesAtRef(projectPath: string, ref: string): string;
11
2
  /**
12
3
  * Clean up a temporary directory created by extractFilesAtRef.