@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/src/cli.ts 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') as { version: string }
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, type FixResult } 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
 
20
26
  const program = new Command()
21
27
 
@@ -117,6 +123,46 @@ program
117
123
  }
118
124
  })
119
125
 
126
+ program
127
+ .command('review')
128
+ .description('Review drift against a base ref and output PR markdown')
129
+ .option('--base <ref>', 'Git base ref to compare against', 'origin/main')
130
+ .option('--json', 'Output structured review JSON')
131
+ .option('--comment', 'Output markdown comment body')
132
+ .option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
133
+ .action(async (options: { base: string; json?: boolean; comment?: boolean; failOn?: string }) => {
134
+ try {
135
+ const review = await generateReview(resolve('.'), options.base)
136
+
137
+ if (options.json) {
138
+ process.stdout.write(JSON.stringify(review, null, 2) + '\n')
139
+ } else {
140
+ process.stdout.write((options.comment ? review.markdown : `${review.summary}\n\n${review.markdown}`) + '\n')
141
+ }
142
+
143
+ const failOn = options.failOn ? Number(options.failOn) : undefined
144
+ if (typeof failOn === 'number' && !Number.isNaN(failOn) && review.totalDelta >= failOn) {
145
+ process.exit(1)
146
+ }
147
+ } catch (err) {
148
+ const message = err instanceof Error ? err.message : String(err)
149
+ process.stderr.write(`\n Error: ${message}\n\n`)
150
+ process.exit(1)
151
+ }
152
+ })
153
+
154
+ program
155
+ .command('map [path]')
156
+ .description('Generate architecture.svg with simple layer dependencies')
157
+ .option('-o, --output <file>', 'Output SVG path (default: architecture.svg)', 'architecture.svg')
158
+ .action(async (targetPath: string | undefined, options: { output: string }) => {
159
+ const resolvedPath = resolve(targetPath ?? '.')
160
+ process.stderr.write(`\nBuilding architecture map for ${resolvedPath}...\n`)
161
+ const config = await loadConfig(resolvedPath)
162
+ const out = generateArchitectureMap(resolvedPath, options.output, config)
163
+ process.stderr.write(` Architecture map saved to ${out}\n\n`)
164
+ })
165
+
120
166
  program
121
167
  .command('report [path]')
122
168
  .description('Generate a self-contained HTML report')
@@ -214,14 +260,46 @@ program
214
260
  .command('fix [path]')
215
261
  .description('Auto-fix safe issues (debug-leftover console.*, catch-swallow)')
216
262
  .option('--rule <rule>', 'Fix only a specific rule')
263
+ .option('--preview', 'Preview changes without writing files')
264
+ .option('--write', 'Write fixes to disk')
217
265
  .option('--dry-run', 'Show what would change without writing files')
218
- .action(async (targetPath: string | undefined, options: { rule?: string; dryRun?: boolean }) => {
266
+ .option('-y, --yes', 'Skip interactive confirmation for --write')
267
+ .action(async (targetPath: string | undefined, options: { rule?: string; dryRun?: boolean; preview?: boolean; write?: boolean; yes?: boolean }) => {
219
268
  const resolvedPath = resolve(targetPath ?? '.')
220
269
  const config = await loadConfig(resolvedPath)
270
+ const previewMode = Boolean(options.preview || options.dryRun)
271
+ const writeMode = options.write ?? !previewMode
272
+
273
+ if (writeMode && !options.yes) {
274
+ const previewResults = await applyFixes(resolvedPath, config, {
275
+ rule: options.rule,
276
+ dryRun: true,
277
+ preview: true,
278
+ write: false,
279
+ })
280
+
281
+ if (previewResults.length === 0) {
282
+ console.log('No fixable issues found.')
283
+ return
284
+ }
285
+
286
+ const files = new Set(previewResults.map((result) => result.file)).size
287
+ const prompt = `Apply ${previewResults.length} fix(es) across ${files} file(s)? [y/N] `
288
+ const rl = createInterface({ input, output })
289
+ const answer = (await rl.question(prompt)).trim().toLowerCase()
290
+ rl.close()
291
+
292
+ if (answer !== 'y' && answer !== 'yes') {
293
+ console.log('Aborted. No files were modified.')
294
+ return
295
+ }
296
+ }
221
297
 
222
298
  const results = await applyFixes(resolvedPath, config, {
223
299
  rule: options.rule,
224
- dryRun: options.dryRun,
300
+ dryRun: previewMode,
301
+ preview: previewMode,
302
+ write: writeMode,
225
303
  })
226
304
 
227
305
  if (results.length === 0) {
@@ -231,8 +309,8 @@ program
231
309
 
232
310
  const applied = results.filter(r => r.applied)
233
311
 
234
- if (options.dryRun) {
235
- console.log(`\ndrift fix --dry-run: ${results.length} fixable issues found\n`)
312
+ if (previewMode) {
313
+ console.log(`\ndrift fix --preview: ${results.length} fixable issues found\n`)
236
314
  } else {
237
315
  console.log(`\ndrift fix: ${applied.length} fixes applied\n`)
238
316
  }
@@ -248,14 +326,135 @@ program
248
326
  const relPath = file.replace(resolvedPath + '/', '').replace(resolvedPath + '\\', '')
249
327
  console.log(` ${relPath}`)
250
328
  for (const r of fileResults) {
251
- const status = r.applied ? (options.dryRun ? 'would fix' : 'fixed') : 'skipped'
329
+ const status = r.applied ? (previewMode ? 'would fix' : 'fixed') : 'skipped'
252
330
  console.log(` [${r.rule}] line ${r.line}: ${r.description} — ${status}`)
331
+ if (r.before || r.after) {
332
+ console.log(` before: ${r.before ?? '(empty)'}`)
333
+ console.log(` after : ${r.after ?? '(empty)'}`)
334
+ }
253
335
  }
254
336
  }
255
337
 
256
- if (!options.dryRun && applied.length > 0) {
338
+ if (!previewMode && applied.length > 0) {
257
339
  console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`)
258
340
  }
259
341
  })
260
342
 
343
+ program
344
+ .command('snapshot [path]')
345
+ .description('Record a score snapshot to drift-history.json')
346
+ .option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
347
+ .option('--history', 'show all recorded snapshots')
348
+ .option('--diff', 'compare current score vs last snapshot')
349
+ .action(async (
350
+ targetPath: string | undefined,
351
+ opts: { label?: string; history?: boolean; diff?: boolean },
352
+ ) => {
353
+ const resolvedPath = resolve(targetPath ?? '.')
354
+
355
+ if (opts.history) {
356
+ const history = loadHistory(resolvedPath)
357
+ printHistory(history)
358
+ return
359
+ }
360
+
361
+ process.stderr.write(`\nScanning ${resolvedPath}...\n`)
362
+ const config = await loadConfig(resolvedPath)
363
+ const files = analyzeProject(resolvedPath, config)
364
+ process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
365
+ const report = buildReport(resolvedPath, files)
366
+
367
+ if (opts.diff) {
368
+ const history = loadHistory(resolvedPath)
369
+ printSnapshotDiff(history, report.totalScore)
370
+ return
371
+ }
372
+
373
+ const entry = saveSnapshot(resolvedPath, report, opts.label)
374
+ const labelStr = entry.label ? ` [${entry.label}]` : ''
375
+ process.stdout.write(
376
+ ` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`,
377
+ )
378
+ process.stdout.write(` Saved to drift-history.json\n\n`)
379
+ })
380
+
381
+ const cloud = program
382
+ .command('cloud')
383
+ .description('Local SaaS foundations: ingest, summary, and dashboard')
384
+
385
+ cloud
386
+ .command('ingest [path]')
387
+ .description('Scan path, build report, and store cloud snapshot')
388
+ .requiredOption('--workspace <id>', 'Workspace id')
389
+ .requiredOption('--user <id>', 'User id')
390
+ .option('--repo <name>', 'Repo name (default: basename of scanned path)')
391
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
392
+ .action(async (targetPath: string | undefined, options: { workspace: string; user: string; repo?: string; store?: string }) => {
393
+ const resolvedPath = resolve(targetPath ?? '.')
394
+ process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`)
395
+ const config = await loadConfig(resolvedPath)
396
+ const files = analyzeProject(resolvedPath, config)
397
+ const report = buildReport(resolvedPath, files)
398
+
399
+ const snapshot = ingestSnapshotFromReport(report, {
400
+ workspaceId: options.workspace,
401
+ userId: options.user,
402
+ repoName: options.repo ?? basename(resolvedPath),
403
+ storeFile: options.store,
404
+ policy: config?.saas,
405
+ })
406
+
407
+ process.stdout.write(`Ingested snapshot ${snapshot.id}\n`)
408
+ process.stdout.write(`Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`)
409
+ process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`)
410
+ })
411
+
412
+ cloud
413
+ .command('summary')
414
+ .description('Show SaaS usage metrics and free threshold status')
415
+ .option('--json', 'Output raw JSON summary')
416
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
417
+ .action((options: { json?: boolean; store?: string }) => {
418
+ const summary = getSaasSummary({ storeFile: options.store })
419
+
420
+ if (options.json) {
421
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n')
422
+ return
423
+ }
424
+
425
+ process.stdout.write('\n')
426
+ process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`)
427
+ process.stdout.write(`Users registered: ${summary.usersRegistered}\n`)
428
+ process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`)
429
+ process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`)
430
+ process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`)
431
+ process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`)
432
+ process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`)
433
+ process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`)
434
+ process.stdout.write('Runs per month:\n')
435
+
436
+ const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b))
437
+ if (monthly.length === 0) {
438
+ process.stdout.write(' - none\n\n')
439
+ return
440
+ }
441
+
442
+ for (const [month, runs] of monthly) {
443
+ process.stdout.write(` - ${month}: ${runs}\n`)
444
+ }
445
+ process.stdout.write('\n')
446
+ })
447
+
448
+ cloud
449
+ .command('dashboard')
450
+ .description('Generate an HTML dashboard with trends and hotspots')
451
+ .option('-o, --output <file>', 'Output HTML file', 'drift-cloud-dashboard.html')
452
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
453
+ .action((options: { output: string; store?: string }) => {
454
+ const html = generateSaasDashboardHtml({ storeFile: options.store })
455
+ const outPath = resolve(options.output)
456
+ writeFileSync(outPath, html, 'utf8')
457
+ process.stdout.write(`Dashboard saved to ${outPath}\n`)
458
+ })
459
+
261
460
  program.parse()
package/src/diff.ts CHANGED
@@ -7,6 +7,40 @@ import type { DriftReport, DriftDiff, FileDiff, DriftIssue } from './types.js'
7
7
  * A "new" issue exists in `current` but not in `base`.
8
8
  * A "resolved" issue exists in `base` but not in `current`.
9
9
  */
10
+ function computeFileDiff(
11
+ filePath: string,
12
+ baseFile: { score: number; issues: DriftIssue[] } | undefined,
13
+ currentFile: { score: number; issues: DriftIssue[] } | undefined,
14
+ ): FileDiff | null {
15
+ const scoreBefore = baseFile?.score ?? 0
16
+ const scoreAfter = currentFile?.score ?? 0
17
+ const scoreDelta = scoreAfter - scoreBefore
18
+
19
+ const baseIssues = baseFile?.issues ?? []
20
+ const currentIssues = currentFile?.issues ?? []
21
+
22
+ const issueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
23
+
24
+ const baseKeys = new Set(baseIssues.map(issueKey))
25
+ const currentKeys = new Set(currentIssues.map(issueKey))
26
+
27
+ const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)))
28
+ const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)))
29
+
30
+ if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
31
+ return {
32
+ path: filePath,
33
+ scoreBefore,
34
+ scoreAfter,
35
+ scoreDelta,
36
+ newIssues,
37
+ resolvedIssues,
38
+ }
39
+ }
40
+
41
+ return null
42
+ }
43
+
10
44
  export function computeDiff(
11
45
  base: DriftReport,
12
46
  current: DriftReport,
@@ -14,11 +48,9 @@ export function computeDiff(
14
48
  ): DriftDiff {
15
49
  const fileDiffs: FileDiff[] = []
16
50
 
17
- // Build a map of base files by path for O(1) lookup
18
51
  const baseByPath = new Map(base.files.map(f => [f.path, f]))
19
52
  const currentByPath = new Map(current.files.map(f => [f.path, f]))
20
53
 
21
- // All unique paths across both reports
22
54
  const allPaths = new Set([
23
55
  ...base.files.map(f => f.path),
24
56
  ...current.files.map(f => f.path),
@@ -28,36 +60,10 @@ export function computeDiff(
28
60
  const baseFile = baseByPath.get(filePath)
29
61
  const currentFile = currentByPath.get(filePath)
30
62
 
31
- const scoreBefore = baseFile?.score ?? 0
32
- const scoreAfter = currentFile?.score ?? 0
33
- const scoreDelta = scoreAfter - scoreBefore
34
-
35
- const baseIssues = baseFile?.issues ?? []
36
- const currentIssues = currentFile?.issues ?? []
37
-
38
- // Issue identity key: rule + line + column
39
- const issueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
40
-
41
- const baseKeys = new Set(baseIssues.map(issueKey))
42
- const currentKeys = new Set(currentIssues.map(issueKey))
43
-
44
- const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)))
45
- const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)))
46
-
47
- // Only include files that have actual changes
48
- if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
49
- fileDiffs.push({
50
- path: filePath,
51
- scoreBefore,
52
- scoreAfter,
53
- scoreDelta,
54
- newIssues,
55
- resolvedIssues,
56
- })
57
- }
63
+ const diff = computeFileDiff(filePath, baseFile, currentFile)
64
+ if (diff) fileDiffs.push(diff)
58
65
  }
59
66
 
60
- // Sort: most regressed first, then most improved last
61
67
  fileDiffs.sort((a, b) => b.scoreDelta - a.scoreDelta)
62
68
 
63
69
  return {
package/src/fix.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, writeFileSync, statSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import { analyzeProject, analyzeFile } from './analyzer.js'
4
- import type { DriftIssue, DriftConfig } from './types.js'
4
+ import type { DriftIssue, DriftConfig, FileReport } from './types.js'
5
5
  import { Project } from 'ts-morph'
6
6
 
7
7
  export interface FixResult {
@@ -10,6 +10,8 @@ export interface FixResult {
10
10
  line: number
11
11
  description: string
12
12
  applied: boolean
13
+ before?: string
14
+ after?: string
13
15
  }
14
16
 
15
17
  const FIXABLE_RULES = new Set(['debug-leftover', 'catch-swallow'])
@@ -73,15 +75,84 @@ function applyFixToLines(
73
75
  return null
74
76
  }
75
77
 
78
+ function collectFixableIssues(
79
+ fileReports: FileReport[],
80
+ options?: { rule?: string }
81
+ ): Map<string, DriftIssue[]> {
82
+ const fixableByFile = new Map<string, DriftIssue[]>()
83
+
84
+ for (const report of fileReports) {
85
+ const fixableIssues = report.issues.filter(issue => {
86
+ if (!isFixable(issue)) return false
87
+ if (options?.rule && issue.rule !== options.rule) return false
88
+ return true
89
+ })
90
+
91
+ if (fixableIssues.length > 0) {
92
+ fixableByFile.set(report.path, fixableIssues)
93
+ }
94
+ }
95
+
96
+ return fixableByFile
97
+ }
98
+
99
+ function processFile(
100
+ filePath: string,
101
+ issues: DriftIssue[],
102
+ dryRun: boolean
103
+ ): FixResult[] {
104
+ const content = readFileSync(filePath, 'utf8')
105
+ let lines = content.split('\n')
106
+ const results: FixResult[] = []
107
+
108
+ const sortedIssues = [...issues].sort((a, b) => b.line - a.line)
109
+
110
+ for (const issue of sortedIssues) {
111
+ const before = lines[issue.line - 1]?.trim() ?? ''
112
+ const fixResult = applyFixToLines(lines, issue)
113
+
114
+ if (fixResult) {
115
+ const after = fixResult.newLines[issue.line - 1]?.trim() ?? ''
116
+ results.push({
117
+ file: filePath,
118
+ rule: issue.rule,
119
+ line: issue.line,
120
+ description: fixResult.description,
121
+ applied: true,
122
+ before,
123
+ after,
124
+ })
125
+ lines = fixResult.newLines
126
+ } else {
127
+ results.push({
128
+ file: filePath,
129
+ rule: issue.rule,
130
+ line: issue.line,
131
+ description: 'no fix available',
132
+ applied: false,
133
+ })
134
+ }
135
+ }
136
+
137
+ if (!dryRun) {
138
+ writeFileSync(filePath, lines.join('\n'), 'utf8')
139
+ }
140
+
141
+ return results
142
+ }
143
+
76
144
  export async function applyFixes(
77
145
  targetPath: string,
78
146
  config?: DriftConfig,
79
- options?: { rule?: string; dryRun?: boolean }
147
+ options?: { rule?: string; dryRun?: boolean; write?: boolean; preview?: boolean }
80
148
  ): Promise<FixResult[]> {
81
149
  const resolvedPath = resolve(targetPath)
82
- const dryRun = options?.dryRun ?? false
150
+ const dryRun = options?.write
151
+ ? false
152
+ : options?.preview || options?.dryRun
153
+ ? true
154
+ : false
83
155
 
84
- // Determine if target is a file or directory
85
156
  let fileReports
86
157
  const stat = statSync(resolvedPath)
87
158
 
@@ -96,58 +167,11 @@ export async function applyFixes(
96
167
  fileReports = analyzeProject(resolvedPath, config)
97
168
  }
98
169
 
99
- // Collect fixable issues, optionally filtered by rule
100
- const fixableByFile = new Map<string, DriftIssue[]>()
101
-
102
- for (const report of fileReports) {
103
- const fixableIssues = report.issues.filter(issue => {
104
- if (!isFixable(issue)) return false
105
- if (options?.rule && issue.rule !== options.rule) return false
106
- return true
107
- })
108
-
109
- if (fixableIssues.length > 0) {
110
- fixableByFile.set(report.path, fixableIssues)
111
- }
112
- }
113
-
170
+ const fixableByFile = collectFixableIssues(fileReports, options)
114
171
  const results: FixResult[] = []
115
172
 
116
173
  for (const [filePath, issues] of fixableByFile) {
117
- const content = readFileSync(filePath, 'utf8')
118
- let lines = content.split('\n')
119
-
120
- // Sort issues by line descending to avoid line number drift after fixes
121
- const sortedIssues = [...issues].sort((a, b) => b.line - a.line)
122
-
123
- // Track line offset caused by deletions (debug-leftover removes lines)
124
- // We process top-to-bottom after sorting descending, so no offset needed per issue
125
- for (const issue of sortedIssues) {
126
- const fixResult = applyFixToLines(lines, issue)
127
-
128
- if (fixResult) {
129
- results.push({
130
- file: filePath,
131
- rule: issue.rule,
132
- line: issue.line,
133
- description: fixResult.description,
134
- applied: true,
135
- })
136
- lines = fixResult.newLines
137
- } else {
138
- results.push({
139
- file: filePath,
140
- rule: issue.rule,
141
- line: issue.line,
142
- description: 'no fix available',
143
- applied: false,
144
- })
145
- }
146
- }
147
-
148
- if (!dryRun) {
149
- writeFileSync(filePath, lines.join('\n'), 'utf8')
150
- }
174
+ results.push(...processFile(filePath, issues, dryRun))
151
175
  }
152
176
 
153
177
  return results
package/src/git/trend.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { SourceFile } from 'ts-morph'
2
- import type { FileReport, DriftConfig, TrendDataPoint, DriftTrendReport, BlameAttribution } from '../types.js'
1
+ // drift-ignore-file
2
+
3
+ import type { FileReport, DriftConfig, TrendDataPoint, DriftTrendReport } from '../types.js'
3
4
  import { assertGitRepo, analyzeHistoricalCommits } from './helpers.js'
4
5
  import { buildReport } from '../reporter.js'
5
6
 
package/src/git.ts CHANGED
@@ -13,22 +13,23 @@ import { randomUUID } from 'node:crypto'
13
13
  *
14
14
  * Throws if the directory is not a git repo or the ref is invalid.
15
15
  */
16
- export function extractFilesAtRef(projectPath: string, ref: string): string {
17
- // Verify git repo
16
+ function verifyGitRepo(projectPath: string): void {
18
17
  try {
19
18
  execSync('git rev-parse --git-dir', { cwd: projectPath, stdio: 'pipe' })
20
19
  } catch {
21
20
  throw new Error(`Not a git repository: ${projectPath}`)
22
21
  }
22
+ }
23
23
 
24
- // Verify ref exists
24
+ function verifyRefExists(projectPath: string, ref: string): void {
25
25
  try {
26
26
  execSync(`git rev-parse --verify ${ref}`, { cwd: projectPath, stdio: 'pipe' })
27
27
  } catch {
28
28
  throw new Error(`Invalid git ref: '${ref}'. Run 'git log --oneline' to see available commits.`)
29
29
  }
30
+ }
30
31
 
31
- // List all .ts files tracked at this ref (excluding .d.ts)
32
+ function listTsFilesAtRef(projectPath: string, ref: string): string[] {
32
33
  let fileList: string
33
34
  try {
34
35
  fileList = execSync(
@@ -39,36 +40,44 @@ export function extractFilesAtRef(projectPath: string, ref: string): string {
39
40
  throw new Error(`Failed to list files at ref '${ref}'`)
40
41
  }
41
42
 
42
- const tsFiles = fileList
43
+ return fileList
43
44
  .split('\n')
44
45
  .map(f => f.trim())
45
46
  .filter(f => (f.endsWith('.ts') || f.endsWith('.tsx') || f.endsWith('.js') || f.endsWith('.jsx')) && !f.endsWith('.d.ts'))
47
+ }
48
+
49
+ function extractFile(projectPath: string, ref: string, filePath: string, tempDir: string): void {
50
+ let content: string
51
+ try {
52
+ content = execSync(
53
+ `git show ${ref}:${filePath}`,
54
+ { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
55
+ )
56
+ } catch {
57
+ return
58
+ }
59
+
60
+ const destPath = join(tempDir, filePath.split('/').join(sep))
61
+ const destDir = destPath.substring(0, destPath.lastIndexOf(sep))
62
+ mkdirSync(destDir, { recursive: true })
63
+ writeFileSync(destPath, content, 'utf-8')
64
+ }
65
+
66
+ export function extractFilesAtRef(projectPath: string, ref: string): string {
67
+ verifyGitRepo(projectPath)
68
+ verifyRefExists(projectPath, ref)
69
+
70
+ const tsFiles = listTsFilesAtRef(projectPath, ref)
46
71
 
47
72
  if (tsFiles.length === 0) {
48
73
  throw new Error(`No TypeScript files found at ref '${ref}'`)
49
74
  }
50
75
 
51
- // Create temp directory
52
76
  const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`)
53
77
  mkdirSync(tempDir, { recursive: true })
54
78
 
55
- // Extract each file
56
79
  for (const filePath of tsFiles) {
57
- let content: string
58
- try {
59
- content = execSync(
60
- `git show ${ref}:${filePath}`,
61
- { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
62
- )
63
- } catch {
64
- // File may not exist at this ref — skip
65
- continue
66
- }
67
-
68
- const destPath = join(tempDir, filePath.split('/').join(sep))
69
- const destDir = destPath.substring(0, destPath.lastIndexOf(sep))
70
- mkdirSync(destDir, { recursive: true })
71
- writeFileSync(destPath, content, 'utf-8')
80
+ extractFile(projectPath, ref, filePath, tempDir)
72
81
  }
73
82
 
74
83
  return tempDir
package/src/index.ts CHANGED
@@ -1,4 +1,34 @@
1
1
  export { analyzeProject, analyzeFile, RULE_WEIGHTS } from './analyzer.js'
2
2
  export { buildReport, formatMarkdown } from './reporter.js'
3
3
  export { computeDiff } from './diff.js'
4
- export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff, DriftConfig } from './types.js'
4
+ export { generateReview, formatReviewMarkdown } from './review.js'
5
+ export { generateArchitectureMap, generateArchitectureSvg } from './map.js'
6
+ export type {
7
+ DriftReport,
8
+ FileReport,
9
+ DriftIssue,
10
+ DriftDiff,
11
+ FileDiff,
12
+ DriftConfig,
13
+ RepoQualityScore,
14
+ MaintenanceRiskMetrics,
15
+ DriftPlugin,
16
+ DriftPluginRule,
17
+ } from './types.js'
18
+ export { loadHistory, saveSnapshot } from './snapshot.js'
19
+ export type { SnapshotEntry, SnapshotHistory } from './snapshot.js'
20
+ export {
21
+ DEFAULT_SAAS_POLICY,
22
+ defaultSaasStorePath,
23
+ resolveSaasPolicy,
24
+ ingestSnapshotFromReport,
25
+ getSaasSummary,
26
+ generateSaasDashboardHtml,
27
+ } from './saas.js'
28
+ export type {
29
+ SaasPolicy,
30
+ SaasStore,
31
+ SaasSummary,
32
+ SaasSnapshot,
33
+ IngestOptions,
34
+ } from './saas.js'