@eduardbar/drift 1.0.0 → 1.1.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 (99) 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/AGENTS.md +53 -11
  5. package/README.md +68 -1
  6. package/dist/analyzer.d.ts +6 -2
  7. package/dist/analyzer.js +116 -3
  8. package/dist/badge.js +40 -22
  9. package/dist/ci.js +32 -18
  10. package/dist/cli.js +83 -5
  11. package/dist/diff.d.ts +0 -7
  12. package/dist/diff.js +26 -25
  13. package/dist/fix.d.ts +4 -0
  14. package/dist/fix.js +59 -47
  15. package/dist/git/trend.js +1 -0
  16. package/dist/git.d.ts +0 -9
  17. package/dist/git.js +25 -19
  18. package/dist/index.d.ts +5 -1
  19. package/dist/index.js +3 -0
  20. package/dist/map.d.ts +3 -0
  21. package/dist/map.js +103 -0
  22. package/dist/metrics.d.ts +4 -0
  23. package/dist/metrics.js +176 -0
  24. package/dist/plugins.d.ts +6 -0
  25. package/dist/plugins.js +74 -0
  26. package/dist/printer.js +20 -0
  27. package/dist/report.js +34 -0
  28. package/dist/reporter.js +85 -2
  29. package/dist/review.d.ts +15 -0
  30. package/dist/review.js +80 -0
  31. package/dist/rules/comments.d.ts +4 -0
  32. package/dist/rules/comments.js +45 -0
  33. package/dist/rules/complexity.d.ts +4 -0
  34. package/dist/rules/complexity.js +51 -0
  35. package/dist/rules/coupling.d.ts +4 -0
  36. package/dist/rules/coupling.js +19 -0
  37. package/dist/rules/magic.d.ts +4 -0
  38. package/dist/rules/magic.js +33 -0
  39. package/dist/rules/nesting.d.ts +5 -0
  40. package/dist/rules/nesting.js +82 -0
  41. package/dist/rules/phase0-basic.js +14 -7
  42. package/dist/rules/phase1-complexity.d.ts +6 -30
  43. package/dist/rules/phase1-complexity.js +7 -276
  44. package/dist/rules/phase2-crossfile.d.ts +0 -4
  45. package/dist/rules/phase2-crossfile.js +52 -39
  46. package/dist/rules/phase3-arch.d.ts +0 -8
  47. package/dist/rules/phase3-arch.js +26 -23
  48. package/dist/rules/phase3-configurable.d.ts +6 -0
  49. package/dist/rules/phase3-configurable.js +97 -0
  50. package/dist/rules/phase8-semantic.d.ts +0 -5
  51. package/dist/rules/phase8-semantic.js +30 -29
  52. package/dist/rules/promise.d.ts +4 -0
  53. package/dist/rules/promise.js +24 -0
  54. package/dist/snapshot.d.ts +19 -0
  55. package/dist/snapshot.js +119 -0
  56. package/dist/types.d.ts +69 -0
  57. package/dist/utils.d.ts +2 -1
  58. package/dist/utils.js +1 -0
  59. package/docs/AGENTS.md +146 -0
  60. package/docs/PRD.md +208 -0
  61. package/package.json +1 -1
  62. package/packages/eslint-plugin-drift/src/index.ts +1 -1
  63. package/packages/vscode-drift/package.json +1 -1
  64. package/packages/vscode-drift/src/analyzer.ts +2 -0
  65. package/packages/vscode-drift/src/extension.ts +87 -63
  66. package/packages/vscode-drift/src/statusbar.ts +13 -5
  67. package/packages/vscode-drift/src/treeview.ts +2 -0
  68. package/src/analyzer.ts +144 -12
  69. package/src/badge.ts +38 -16
  70. package/src/ci.ts +38 -17
  71. package/src/cli.ts +96 -6
  72. package/src/diff.ts +36 -30
  73. package/src/fix.ts +77 -53
  74. package/src/git/trend.ts +3 -2
  75. package/src/git.ts +31 -22
  76. package/src/index.ts +16 -1
  77. package/src/map.ts +117 -0
  78. package/src/metrics.ts +200 -0
  79. package/src/plugins.ts +76 -0
  80. package/src/printer.ts +20 -0
  81. package/src/report.ts +35 -0
  82. package/src/reporter.ts +95 -2
  83. package/src/review.ts +98 -0
  84. package/src/rules/comments.ts +56 -0
  85. package/src/rules/complexity.ts +57 -0
  86. package/src/rules/coupling.ts +23 -0
  87. package/src/rules/magic.ts +38 -0
  88. package/src/rules/nesting.ts +88 -0
  89. package/src/rules/phase0-basic.ts +14 -7
  90. package/src/rules/phase1-complexity.ts +8 -302
  91. package/src/rules/phase2-crossfile.ts +68 -40
  92. package/src/rules/phase3-arch.ts +34 -30
  93. package/src/rules/phase3-configurable.ts +132 -0
  94. package/src/rules/phase8-semantic.ts +33 -29
  95. package/src/rules/promise.ts +29 -0
  96. package/src/snapshot.ts +175 -0
  97. package/src/types.ts +75 -1
  98. package/src/utils.ts +3 -1
  99. package/tests/new-features.test.ts +153 -0
@@ -1,13 +1,21 @@
1
1
  import * as vscode from 'vscode'
2
2
  import type { FileReport } from '@eduardbar/drift'
3
3
 
4
+ const STATUSBAR_PRIORITY = 100
5
+
6
+ const SCORE_THRESHOLDS = {
7
+ WARNING: 50,
8
+ ERROR: 30,
9
+ WARNING_BG: 60,
10
+ }
11
+
4
12
  export class DriftStatusBarItem {
5
13
  private item: vscode.StatusBarItem
6
14
 
7
15
  constructor() {
8
- this.item = vscode.window.createStatusBarItem(
16
+ this.item = vscode.createStatusBarItem(
9
17
  vscode.StatusBarAlignment.Right,
10
- 100
18
+ STATUSBAR_PRIORITY
11
19
  )
12
20
  this.item.command = 'drift.scanWorkspace'
13
21
  this.item.tooltip = 'Click to scan workspace'
@@ -27,12 +35,12 @@ export class DriftStatusBarItem {
27
35
  const totalIssues = reports.reduce((sum, r) => sum + r.issues.length, 0)
28
36
  const hasErrors = reports.some(r => r.issues.some(i => i.severity === 'error'))
29
37
 
30
- const icon = hasErrors ? '$(error)' : totalScore < 50 ? '$(warning)' : '$(check)'
38
+ const icon = hasErrors ? '$(error)' : totalScore < SCORE_THRESHOLDS.WARNING ? '$(warning)' : '$(check)'
31
39
  this.item.text = `${icon} drift ${totalScore}/100 · ${totalIssues} issues`
32
40
 
33
- if (hasErrors || totalScore < 30) {
41
+ if (hasErrors || totalScore < SCORE_THRESHOLDS.ERROR) {
34
42
  this.item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground')
35
- } else if (totalScore < 60) {
43
+ } else if (totalScore < SCORE_THRESHOLDS.WARNING_BG) {
36
44
  this.item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground')
37
45
  } else {
38
46
  this.item.backgroundColor = undefined
@@ -1,3 +1,5 @@
1
+ // drift-ignore-file
2
+
1
3
  import * as vscode from 'vscode'
2
4
  import * as path from 'path'
3
5
  import type { FileReport } from '@eduardbar/drift'
package/src/analyzer.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // drift-ignore-file
2
2
  import * as path from 'node:path'
3
3
  import { Project } from 'ts-morph'
4
- import type { DriftIssue, FileReport, DriftConfig } from './types.js'
4
+ import type { DriftIssue, FileReport, DriftConfig, LoadedPlugin, PluginRuleContext } from './types.js'
5
5
 
6
6
  // Rules
7
7
  import { isFileIgnored } from './rules/shared.js'
@@ -15,15 +15,12 @@ import {
15
15
  detectCatchSwallow,
16
16
  detectMissingReturnTypes,
17
17
  } from './rules/phase0-basic.js'
18
- import {
19
- detectHighComplexity,
20
- detectDeepNesting,
21
- detectTooManyParams,
22
- detectHighCoupling,
23
- detectPromiseStyleMix,
24
- detectMagicNumbers,
25
- detectCommentContradiction,
26
- } from './rules/phase1-complexity.js'
18
+ import { detectHighComplexity } from './rules/complexity.js'
19
+ import { detectDeepNesting, detectTooManyParams } from './rules/nesting.js'
20
+ import { detectHighCoupling } from './rules/coupling.js'
21
+ import { detectPromiseStyleMix } from './rules/promise.js'
22
+ import { detectMagicNumbers } from './rules/magic.js'
23
+ import { detectCommentContradiction } from './rules/comments.js'
27
24
  import {
28
25
  detectDeadFiles,
29
26
  detectUnusedExports,
@@ -34,6 +31,11 @@ import {
34
31
  detectLayerViolations,
35
32
  detectCrossBoundaryImports,
36
33
  } from './rules/phase3-arch.js'
34
+ import {
35
+ detectControllerNoDb,
36
+ detectServiceNoHttp,
37
+ detectMaxFunctionLines,
38
+ } from './rules/phase3-configurable.js'
37
39
  import {
38
40
  detectOverCommented,
39
41
  detectHardcodedConfig,
@@ -46,6 +48,7 @@ import {
46
48
  fingerprintFunction,
47
49
  calculateScore,
48
50
  } from './rules/phase8-semantic.js'
51
+ import { loadPlugins } from './plugins.js'
49
52
 
50
53
  // Git analyzers (re-exported as part of the public API)
51
54
  export { TrendAnalyzer } from './git/trend.js'
@@ -81,21 +84,113 @@ export const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; we
81
84
  // Phase 3b/c: layer and module boundary enforcement (require drift.config.ts)
82
85
  'layer-violation': { severity: 'error', weight: 16 },
83
86
  'cross-boundary-import': { severity: 'warning', weight: 10 },
87
+ 'controller-no-db': { severity: 'warning', weight: 11 },
88
+ 'service-no-http': { severity: 'warning', weight: 11 },
89
+ 'max-function-lines': { severity: 'warning', weight: 9 },
84
90
  // Phase 5: AI authorship heuristics
85
91
  'over-commented': { severity: 'info', weight: 4 },
86
92
  'hardcoded-config': { severity: 'warning', weight: 10 },
87
93
  'inconsistent-error-handling': { severity: 'warning', weight: 8 },
88
94
  'unnecessary-abstraction': { severity: 'warning', weight: 7 },
89
95
  'naming-inconsistency': { severity: 'warning', weight: 6 },
96
+ 'ai-code-smell': { severity: 'warning', weight: 12 },
90
97
  // Phase 8: semantic duplication
91
98
  'semantic-duplication': { severity: 'warning', weight: 12 },
99
+ 'plugin-error': { severity: 'warning', weight: 4 },
100
+ }
101
+
102
+ const AI_SMELL_SIGNALS = new Set([
103
+ 'over-commented',
104
+ 'hardcoded-config',
105
+ 'inconsistent-error-handling',
106
+ 'unnecessary-abstraction',
107
+ 'naming-inconsistency',
108
+ 'comment-contradiction',
109
+ 'promise-style-mix',
110
+ 'any-abuse',
111
+ ])
112
+
113
+ function detectAICodeSmell(issues: DriftIssue[], filePath: string): DriftIssue[] {
114
+ const signalCounts = new Map<string, number>()
115
+ for (const issue of issues) {
116
+ if (!AI_SMELL_SIGNALS.has(issue.rule)) continue
117
+ signalCounts.set(issue.rule, (signalCounts.get(issue.rule) ?? 0) + 1)
118
+ }
119
+
120
+ const totalSignals = [...signalCounts.values()].reduce((sum, count) => sum + count, 0)
121
+ if (totalSignals < 3) return []
122
+
123
+ const triggers = [...signalCounts.entries()]
124
+ .sort((a, b) => b[1] - a[1])
125
+ .slice(0, 3)
126
+ .map(([rule, count]) => `${rule} x${count}`)
127
+
128
+ return [{
129
+ rule: 'ai-code-smell',
130
+ severity: 'warning',
131
+ message: `Aggregated AI smell signals detected (${totalSignals}): ${triggers.join(', ')}`,
132
+ line: 1,
133
+ column: 1,
134
+ snippet: path.basename(filePath),
135
+ }]
136
+ }
137
+
138
+ function runPluginRules(
139
+ file: import('ts-morph').SourceFile,
140
+ loadedPlugins: LoadedPlugin[],
141
+ config: DriftConfig | undefined,
142
+ projectRoot: string,
143
+ ): DriftIssue[] {
144
+ if (loadedPlugins.length === 0) return []
145
+ const context: PluginRuleContext = {
146
+ projectRoot,
147
+ filePath: file.getFilePath(),
148
+ config,
149
+ }
150
+
151
+ const issues: DriftIssue[] = []
152
+ for (const loaded of loadedPlugins) {
153
+ for (const rule of loaded.plugin.rules) {
154
+ try {
155
+ const detected = rule.detect(file, context) ?? []
156
+ for (const issue of detected) {
157
+ issues.push({
158
+ ...issue,
159
+ rule: issue.rule || `${loaded.plugin.name}/${rule.name}`,
160
+ severity: issue.severity ?? (rule.severity ?? 'warning'),
161
+ })
162
+ }
163
+ } catch (error) {
164
+ issues.push({
165
+ rule: 'plugin-error',
166
+ severity: 'warning',
167
+ message: `Plugin '${loaded.id}' rule '${rule.name}' failed: ${error instanceof Error ? error.message : String(error)}`,
168
+ line: 1,
169
+ column: 1,
170
+ snippet: file.getBaseName(),
171
+ })
172
+ }
173
+ }
174
+ }
175
+ return issues
92
176
  }
93
177
 
94
178
  // ---------------------------------------------------------------------------
95
179
  // Per-file analysis
96
180
  // ---------------------------------------------------------------------------
97
181
 
98
- export function analyzeFile(file: import('ts-morph').SourceFile): FileReport {
182
+ export function analyzeFile(
183
+ file: import('ts-morph').SourceFile,
184
+ options?: DriftConfig | {
185
+ config?: DriftConfig
186
+ loadedPlugins?: LoadedPlugin[]
187
+ projectRoot?: string
188
+ },
189
+ ): FileReport {
190
+ const normalizedOptions = (options && typeof options === 'object' && ('config' in options || 'loadedPlugins' in options || 'projectRoot' in options))
191
+ ? options
192
+ : { config: (options && typeof options === 'object' ? options : undefined) as DriftConfig | undefined }
193
+
99
194
  if (isFileIgnored(file)) {
100
195
  return {
101
196
  path: file.getFilePath(),
@@ -127,8 +222,21 @@ export function analyzeFile(file: import('ts-morph').SourceFile): FileReport {
127
222
  ...detectInconsistentErrorHandling(file),
128
223
  ...detectUnnecessaryAbstraction(file),
129
224
  ...detectNamingInconsistency(file),
225
+ // Configurable architecture rules
226
+ ...detectControllerNoDb(file, normalizedOptions?.config),
227
+ ...detectServiceNoHttp(file, normalizedOptions?.config),
228
+ ...detectMaxFunctionLines(file, normalizedOptions?.config),
229
+ // Plugin rules
230
+ ...runPluginRules(
231
+ file,
232
+ normalizedOptions?.loadedPlugins ?? [],
233
+ normalizedOptions?.config,
234
+ normalizedOptions?.projectRoot ?? path.dirname(file.getFilePath()),
235
+ ),
130
236
  ]
131
237
 
238
+ issues.push(...detectAICodeSmell(issues, file.getFilePath()))
239
+
132
240
  return {
133
241
  path: file.getFilePath(),
134
242
  issues,
@@ -161,9 +269,14 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
161
269
  ])
162
270
 
163
271
  const sourceFiles = project.getSourceFiles()
272
+ const pluginRuntime = loadPlugins(targetPath, config?.plugins)
164
273
 
165
274
  // Phase 1: per-file analysis
166
- const reports: FileReport[] = sourceFiles.map(analyzeFile)
275
+ const reports: FileReport[] = sourceFiles.map((file) => analyzeFile(file, {
276
+ config,
277
+ loadedPlugins: pluginRuntime.plugins,
278
+ projectRoot: targetPath,
279
+ }))
167
280
  const reportByPath = new Map<string, FileReport>()
168
281
  for (const r of reports) reportByPath.set(r.path, r)
169
282
 
@@ -227,6 +340,25 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
227
340
  }
228
341
  }
229
342
 
343
+ // Plugin load failures are surfaced as synthetic report entries.
344
+ if (pluginRuntime.errors.length > 0) {
345
+ for (const err of pluginRuntime.errors) {
346
+ const pluginIssue: DriftIssue = {
347
+ rule: 'plugin-error',
348
+ severity: 'warning',
349
+ message: `Failed to load plugin '${err.pluginId}': ${err.message}`,
350
+ line: 1,
351
+ column: 1,
352
+ snippet: err.pluginId,
353
+ }
354
+ reports.push({
355
+ path: path.join(targetPath, '.drift-plugin-errors', `${err.pluginId}.plugin`),
356
+ issues: [pluginIssue],
357
+ score: calculateScore([pluginIssue], RULE_WEIGHTS),
358
+ })
359
+ }
360
+ }
361
+
230
362
  // ── Phase 2: dead-file + unused-export + unused-dependency ─────────────────
231
363
  const deadFiles = detectDeadFiles(sourceFiles, allImportedPaths, RULE_WEIGHTS)
232
364
  for (const [sfPath, issue] of deadFiles) {
package/src/badge.ts CHANGED
@@ -3,19 +3,40 @@ import type {} from './types.js'
3
3
  const LEFT_WIDTH = 47
4
4
  const CHAR_WIDTH = 7
5
5
  const PADDING = 16
6
+ const SVG_SCALE = 10
7
+
8
+ const GRADE_THRESHOLDS = {
9
+ LOW: 20,
10
+ MODERATE: 45,
11
+ HIGH: 70,
12
+ }
13
+
14
+ const GRADE_COLORS = {
15
+ LOW: '#4c1',
16
+ MODERATE: '#dfb317',
17
+ HIGH: '#fe7d37',
18
+ CRITICAL: '#e05d44',
19
+ }
20
+
21
+ const GRADE_LABELS = {
22
+ LOW: 'LOW',
23
+ MODERATE: 'MODERATE',
24
+ HIGH: 'HIGH',
25
+ CRITICAL: 'CRITICAL',
26
+ }
6
27
 
7
28
  function scoreColor(score: number): string {
8
- if (score < 20) return '#4c1'
9
- if (score < 45) return '#dfb317'
10
- if (score < 70) return '#fe7d37'
11
- return '#e05d44'
29
+ if (score < GRADE_THRESHOLDS.LOW) return GRADE_COLORS.LOW
30
+ if (score < GRADE_THRESHOLDS.MODERATE) return GRADE_COLORS.MODERATE
31
+ if (score < GRADE_THRESHOLDS.HIGH) return GRADE_COLORS.HIGH
32
+ return GRADE_COLORS.CRITICAL
12
33
  }
13
34
 
14
35
  function scoreLabel(score: number): string {
15
- if (score < 20) return 'LOW'
16
- if (score < 45) return 'MODERATE'
17
- if (score < 70) return 'HIGH'
18
- return 'CRITICAL'
36
+ if (score < GRADE_THRESHOLDS.LOW) return GRADE_LABELS.LOW
37
+ if (score < GRADE_THRESHOLDS.MODERATE) return GRADE_LABELS.MODERATE
38
+ if (score < GRADE_THRESHOLDS.HIGH) return GRADE_LABELS.HIGH
39
+ return GRADE_LABELS.CRITICAL
19
40
  }
20
41
 
21
42
  function rightWidth(text: string): number {
@@ -32,10 +53,11 @@ export function generateBadge(score: number): string {
32
53
  const leftCenterX = LEFT_WIDTH / 2
33
54
  const rightCenterX = LEFT_WIDTH + rWidth / 2
34
55
 
35
- // shields.io pattern: font-size="110" + scale(.1) = effective 11px
36
- // all X/Y coords are ×10
37
- const leftTextWidth = (LEFT_WIDTH - 10) * 10
38
- const rightTextWidth = (rWidth - PADDING) * 10
56
+ const leftTextWidth = (LEFT_WIDTH - PADDING) * SVG_SCALE
57
+ const rightTextWidth = (rWidth - PADDING) * SVG_SCALE
58
+
59
+ const leftCenterXScaled = leftCenterX * SVG_SCALE
60
+ const rightCenterXScaled = rightCenterX * SVG_SCALE
39
61
 
40
62
  return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${totalWidth}" height="20">
41
63
  <linearGradient id="s" x2="0" y2="100%">
@@ -51,10 +73,10 @@ export function generateBadge(score: number): string {
51
73
  <rect width="${totalWidth}" height="20" fill="url(#s)"/>
52
74
  </g>
53
75
  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
54
- <text x="${leftCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
55
- <text x="${leftCenterX * 10}" y="140" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
56
- <text x="${rightCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
57
- <text x="${rightCenterX * 10}" y="140" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
76
+ <text x="${leftCenterXScaled}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
77
+ <text x="${leftCenterXScaled}" y="140" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
78
+ <text x="${rightCenterXScaled}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
79
+ <text x="${rightCenterXScaled}" y="140" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
58
80
  </g>
59
81
  </svg>`
60
82
  }
package/src/ci.ts CHANGED
@@ -2,6 +2,15 @@ import { writeFileSync } from 'node:fs'
2
2
  import { relative } from 'node:path'
3
3
  import type { DriftReport } from './types.js'
4
4
 
5
+ const GRADE_THRESHOLDS = {
6
+ A: 80,
7
+ B: 60,
8
+ C: 40,
9
+ D: 20,
10
+ }
11
+
12
+ const TOP_FILES_LIMIT = 10
13
+
5
14
  function encodeMessage(msg: string): string {
6
15
  return msg
7
16
  .replace(/%/g, '%25')
@@ -18,10 +27,10 @@ function severityToAnnotation(s: string): 'error' | 'warning' | 'notice' {
18
27
  }
19
28
 
20
29
  function scoreLabel(score: number): string {
21
- if (score >= 80) return 'A'
22
- if (score >= 60) return 'B'
23
- if (score >= 40) return 'C'
24
- if (score >= 20) return 'D'
30
+ if (score >= GRADE_THRESHOLDS.A) return 'A'
31
+ if (score >= GRADE_THRESHOLDS.B) return 'B'
32
+ if (score >= GRADE_THRESHOLDS.C) return 'C'
33
+ if (score >= GRADE_THRESHOLDS.D) return 'D'
25
34
  return 'F'
26
35
  }
27
36
 
@@ -38,28 +47,40 @@ export function emitCIAnnotations(report: DriftReport): void {
38
47
  }
39
48
  }
40
49
 
41
- export function printCISummary(report: DriftReport): void {
42
- const summaryPath = process.env['GITHUB_STEP_SUMMARY']
43
- if (!summaryPath) return
44
-
45
- const score = report.totalScore
46
- const grade = scoreLabel(score)
47
-
50
+ function countIssuesBySeverity(report: DriftReport): { errors: number; warnings: number; info: number } {
48
51
  let errors = 0
49
52
  let warnings = 0
50
53
  let info = 0
51
54
 
52
55
  for (const file of report.files) {
53
- for (const issue of file.issues) {
54
- if (issue.severity === 'error') errors++
55
- else if (issue.severity === 'warning') warnings++
56
- else info++
57
- }
56
+ countFileIssues(file, { errors: () => errors++, warnings: () => warnings++, info: () => info++ })
58
57
  }
59
58
 
59
+ return { errors, warnings, info }
60
+ }
61
+
62
+ function countFileIssues(
63
+ file: { issues: Array<{ severity: string }> },
64
+ counters: { errors: () => void; warnings: () => void; info: () => void },
65
+ ): void {
66
+ for (const issue of file.issues) {
67
+ if (issue.severity === 'error') counters.errors()
68
+ else if (issue.severity === 'warning') counters.warnings()
69
+ else counters.info()
70
+ }
71
+ }
72
+
73
+ export function printCISummary(report: DriftReport): void {
74
+ const summaryPath = process.env['GITHUB_STEP_SUMMARY']
75
+ if (!summaryPath) return
76
+
77
+ const score = report.totalScore
78
+ const grade = scoreLabel(score)
79
+ const { errors, warnings, info } = countIssuesBySeverity(report)
80
+
60
81
  const sorted = [...report.files]
61
82
  .sort((a, b) => b.issues.length - a.issues.length)
62
- .slice(0, 10)
83
+ .slice(0, TOP_FILES_LIMIT)
63
84
 
64
85
  const rows = sorted
65
86
  .map((f) => {
package/src/cli.ts CHANGED
@@ -16,6 +16,9 @@ import { generateHtmlReport } from './report.js'
16
16
  import { generateBadge } from './badge.js'
17
17
  import { emitCIAnnotations, printCISummary } from './ci.js'
18
18
  import { applyFixes, type FixResult } from './fix.js'
19
+ import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js'
20
+ import { generateReview } from './review.js'
21
+ import { generateArchitectureMap } from './map.js'
19
22
 
20
23
  const program = new Command()
21
24
 
@@ -117,6 +120,45 @@ program
117
120
  }
118
121
  })
119
122
 
123
+ program
124
+ .command('review')
125
+ .description('Review drift against a base ref and output PR markdown')
126
+ .option('--base <ref>', 'Git base ref to compare against', 'origin/main')
127
+ .option('--json', 'Output structured review JSON')
128
+ .option('--comment', 'Output markdown comment body')
129
+ .option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
130
+ .action(async (options: { base: string; json?: boolean; comment?: boolean; failOn?: string }) => {
131
+ try {
132
+ const review = await generateReview(resolve('.'), options.base)
133
+
134
+ if (options.json) {
135
+ process.stdout.write(JSON.stringify(review, null, 2) + '\n')
136
+ } else {
137
+ process.stdout.write((options.comment ? review.markdown : `${review.summary}\n\n${review.markdown}`) + '\n')
138
+ }
139
+
140
+ const failOn = options.failOn ? Number(options.failOn) : undefined
141
+ if (typeof failOn === 'number' && !Number.isNaN(failOn) && review.totalDelta >= failOn) {
142
+ process.exit(1)
143
+ }
144
+ } catch (err) {
145
+ const message = err instanceof Error ? err.message : String(err)
146
+ process.stderr.write(`\n Error: ${message}\n\n`)
147
+ process.exit(1)
148
+ }
149
+ })
150
+
151
+ program
152
+ .command('map [path]')
153
+ .description('Generate architecture.svg with simple layer dependencies')
154
+ .option('-o, --output <file>', 'Output SVG path (default: architecture.svg)', 'architecture.svg')
155
+ .action(async (targetPath: string | undefined, options: { output: string }) => {
156
+ const resolvedPath = resolve(targetPath ?? '.')
157
+ process.stderr.write(`\nBuilding architecture map for ${resolvedPath}...\n`)
158
+ const out = generateArchitectureMap(resolvedPath, options.output)
159
+ process.stderr.write(` Architecture map saved to ${out}\n\n`)
160
+ })
161
+
120
162
  program
121
163
  .command('report [path]')
122
164
  .description('Generate a self-contained HTML report')
@@ -214,14 +256,20 @@ program
214
256
  .command('fix [path]')
215
257
  .description('Auto-fix safe issues (debug-leftover console.*, catch-swallow)')
216
258
  .option('--rule <rule>', 'Fix only a specific rule')
259
+ .option('--preview', 'Preview changes without writing files')
260
+ .option('--write', 'Write fixes to disk')
217
261
  .option('--dry-run', 'Show what would change without writing files')
218
- .action(async (targetPath: string | undefined, options: { rule?: string; dryRun?: boolean }) => {
262
+ .action(async (targetPath: string | undefined, options: { rule?: string; dryRun?: boolean; preview?: boolean; write?: boolean }) => {
219
263
  const resolvedPath = resolve(targetPath ?? '.')
220
264
  const config = await loadConfig(resolvedPath)
265
+ const previewMode = Boolean(options.preview || options.dryRun)
266
+ const writeMode = options.write ?? !previewMode
221
267
 
222
268
  const results = await applyFixes(resolvedPath, config, {
223
269
  rule: options.rule,
224
- dryRun: options.dryRun,
270
+ dryRun: previewMode,
271
+ preview: previewMode,
272
+ write: writeMode,
225
273
  })
226
274
 
227
275
  if (results.length === 0) {
@@ -231,8 +279,8 @@ program
231
279
 
232
280
  const applied = results.filter(r => r.applied)
233
281
 
234
- if (options.dryRun) {
235
- console.log(`\ndrift fix --dry-run: ${results.length} fixable issues found\n`)
282
+ if (previewMode) {
283
+ console.log(`\ndrift fix --preview: ${results.length} fixable issues found\n`)
236
284
  } else {
237
285
  console.log(`\ndrift fix: ${applied.length} fixes applied\n`)
238
286
  }
@@ -248,14 +296,56 @@ program
248
296
  const relPath = file.replace(resolvedPath + '/', '').replace(resolvedPath + '\\', '')
249
297
  console.log(` ${relPath}`)
250
298
  for (const r of fileResults) {
251
- const status = r.applied ? (options.dryRun ? 'would fix' : 'fixed') : 'skipped'
299
+ const status = r.applied ? (previewMode ? 'would fix' : 'fixed') : 'skipped'
252
300
  console.log(` [${r.rule}] line ${r.line}: ${r.description} — ${status}`)
301
+ if (r.before || r.after) {
302
+ console.log(` before: ${r.before ?? '(empty)'}`)
303
+ console.log(` after : ${r.after ?? '(empty)'}`)
304
+ }
253
305
  }
254
306
  }
255
307
 
256
- if (!options.dryRun && applied.length > 0) {
308
+ if (!previewMode && applied.length > 0) {
257
309
  console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`)
258
310
  }
259
311
  })
260
312
 
313
+ program
314
+ .command('snapshot [path]')
315
+ .description('Record a score snapshot to drift-history.json')
316
+ .option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
317
+ .option('--history', 'show all recorded snapshots')
318
+ .option('--diff', 'compare current score vs last snapshot')
319
+ .action(async (
320
+ targetPath: string | undefined,
321
+ opts: { label?: string; history?: boolean; diff?: boolean },
322
+ ) => {
323
+ const resolvedPath = resolve(targetPath ?? '.')
324
+
325
+ if (opts.history) {
326
+ const history = loadHistory(resolvedPath)
327
+ printHistory(history)
328
+ return
329
+ }
330
+
331
+ process.stderr.write(`\nScanning ${resolvedPath}...\n`)
332
+ const config = await loadConfig(resolvedPath)
333
+ const files = analyzeProject(resolvedPath, config)
334
+ process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
335
+ const report = buildReport(resolvedPath, files)
336
+
337
+ if (opts.diff) {
338
+ const history = loadHistory(resolvedPath)
339
+ printSnapshotDiff(history, report.totalScore)
340
+ return
341
+ }
342
+
343
+ const entry = saveSnapshot(resolvedPath, report, opts.label)
344
+ const labelStr = entry.label ? ` [${entry.label}]` : ''
345
+ process.stdout.write(
346
+ ` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`,
347
+ )
348
+ process.stdout.write(` Saved to drift-history.json\n\n`)
349
+ })
350
+
261
351
  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 {