@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
@@ -0,0 +1,175 @@
1
+ import * as fs from 'node:fs'
2
+ import * as path from 'node:path'
3
+ import kleur from 'kleur'
4
+ import type { DriftReport } from './types.js'
5
+ import { scoreToGradeText } from './utils.js'
6
+
7
+ export interface SnapshotEntry {
8
+ timestamp: string
9
+ label: string
10
+ score: number
11
+ grade: string
12
+ totalIssues: number
13
+ files: number
14
+ byRule: Record<string, number>
15
+ }
16
+
17
+ export interface SnapshotHistory {
18
+ project: string
19
+ snapshots: SnapshotEntry[]
20
+ }
21
+
22
+ const HISTORY_FILE = 'drift-history.json'
23
+
24
+ const HEADER_PAD = {
25
+ INDEX: 4,
26
+ DATE: 26,
27
+ LABEL: 20,
28
+ SCORE: 8,
29
+ GRADE: 12,
30
+ ISSUES: 8,
31
+ DELTA: 6,
32
+ }
33
+
34
+ const GRADE_THRESHOLDS = {
35
+ LOW: 20,
36
+ MODERATE: 45,
37
+ HIGH: 70,
38
+ }
39
+
40
+ export function loadHistory(targetPath: string): SnapshotHistory {
41
+ const filePath = path.join(targetPath, HISTORY_FILE)
42
+ if (fs.existsSync(filePath)) {
43
+ return JSON.parse(fs.readFileSync(filePath, 'utf8')) as SnapshotHistory
44
+ }
45
+ return { project: targetPath, snapshots: [] }
46
+ }
47
+
48
+ export function saveSnapshot(
49
+ targetPath: string,
50
+ report: DriftReport,
51
+ label?: string,
52
+ ): SnapshotEntry {
53
+ const history = loadHistory(targetPath)
54
+
55
+ const entry: SnapshotEntry = {
56
+ timestamp: new Date().toISOString(),
57
+ label: label ?? '',
58
+ score: report.totalScore,
59
+ grade: scoreToGradeText(report.totalScore).label.toUpperCase(),
60
+ totalIssues: report.totalIssues,
61
+ files: report.totalFiles,
62
+ byRule: { ...report.summary.byRule },
63
+ }
64
+
65
+ history.snapshots.push(entry)
66
+
67
+ const filePath = path.join(targetPath, HISTORY_FILE)
68
+ fs.writeFileSync(filePath, JSON.stringify(history, null, 2), 'utf8')
69
+
70
+ return entry
71
+ }
72
+
73
+ function formatDelta(current: SnapshotEntry, prev: SnapshotEntry | null): string {
74
+ if (!prev) return '—'
75
+ const delta = current.score - prev.score
76
+ if (delta > 0) return kleur.red(`+${delta}`)
77
+ if (delta < 0) return kleur.green(String(delta))
78
+ return kleur.gray('0')
79
+ }
80
+
81
+ export function printHistory(history: SnapshotHistory): void {
82
+ const { snapshots } = history
83
+
84
+ if (snapshots.length === 0) {
85
+ process.stdout.write('\n No snapshots recorded yet.\n\n')
86
+ return
87
+ }
88
+
89
+ process.stdout.write('\n')
90
+ process.stdout.write(
91
+ kleur.bold(
92
+ ` ${'#'.padEnd(HEADER_PAD.INDEX)} ${'Date'.padEnd(HEADER_PAD.DATE)} ${'Label'.padEnd(HEADER_PAD.LABEL)} ${'Score'.padEnd(HEADER_PAD.SCORE)} ${'Grade'.padEnd(HEADER_PAD.GRADE)} ${'Issues'.padEnd(HEADER_PAD.ISSUES)} ${'Delta'}\n`,
93
+ ),
94
+ )
95
+ process.stdout.write(
96
+ ` ${'─'.repeat(HEADER_PAD.INDEX)} ${'─'.repeat(HEADER_PAD.DATE)} ${'─'.repeat(HEADER_PAD.LABEL)} ${'─'.repeat(HEADER_PAD.SCORE)} ${'─'.repeat(HEADER_PAD.GRADE)} ${'─'.repeat(HEADER_PAD.ISSUES)} ${'─'.repeat(HEADER_PAD.DELTA)}\n`,
97
+ )
98
+
99
+ for (let i = 0; i < snapshots.length; i++) {
100
+ const s = snapshots[i]
101
+ const date = new Date(s.timestamp).toLocaleString('en-US', {
102
+ year: 'numeric',
103
+ month: 'short',
104
+ day: '2-digit',
105
+ hour: '2-digit',
106
+ minute: '2-digit',
107
+ })
108
+
109
+ const deltaStr = formatDelta(s, i > 0 ? snapshots[i - 1] : null)
110
+ const gradeColored = colorGrade(s.grade, s.score)
111
+
112
+ process.stdout.write(
113
+ ` ${String(i + 1).padEnd(HEADER_PAD.INDEX)} ${date.padEnd(HEADER_PAD.DATE)} ${(s.label || '—').padEnd(HEADER_PAD.LABEL)} ${String(s.score).padEnd(HEADER_PAD.SCORE)} ${gradeColored.padEnd(HEADER_PAD.GRADE)} ${String(s.totalIssues).padEnd(HEADER_PAD.ISSUES)} ${deltaStr}\n`,
114
+ )
115
+ }
116
+
117
+ process.stdout.write('\n')
118
+ }
119
+
120
+ export function printSnapshotDiff(
121
+ history: SnapshotHistory,
122
+ currentScore: number,
123
+ ): void {
124
+ const { snapshots } = history
125
+
126
+ if (snapshots.length === 0) {
127
+ process.stdout.write('\n No previous snapshot to compare against.\n\n')
128
+ return
129
+ }
130
+
131
+ const last = snapshots[snapshots.length - 1]
132
+ const delta = currentScore - last.score
133
+
134
+ const lastDate = new Date(last.timestamp).toLocaleString('en-US', {
135
+ year: 'numeric',
136
+ month: 'short',
137
+ day: '2-digit',
138
+ hour: '2-digit',
139
+ minute: '2-digit',
140
+ })
141
+ const lastLabel = last.label ? ` (${last.label})` : ''
142
+
143
+ process.stdout.write('\n')
144
+ process.stdout.write(
145
+ ` Last snapshot: ${kleur.bold(lastDate)}${lastLabel} — score ${kleur.bold(String(last.score))}\n`,
146
+ )
147
+ process.stdout.write(
148
+ ` Current score: ${kleur.bold(String(currentScore))}\n`,
149
+ )
150
+ process.stdout.write('\n')
151
+
152
+ if (delta > 0) {
153
+ process.stdout.write(
154
+ ` Delta: ${kleur.bold().red(`+${delta}`)} — technical debt increased\n`,
155
+ )
156
+ } else if (delta < 0) {
157
+ process.stdout.write(
158
+ ` Delta: ${kleur.bold().green(String(delta))} — technical debt decreased\n`,
159
+ )
160
+ } else {
161
+ process.stdout.write(
162
+ ` Delta: ${kleur.gray('0')} — no change since last snapshot\n`,
163
+ )
164
+ }
165
+
166
+ process.stdout.write('\n')
167
+ }
168
+
169
+ function colorGrade(grade: string, score: number): string {
170
+ if (score === 0) return kleur.green(grade)
171
+ if (score < GRADE_THRESHOLDS.LOW) return kleur.green(grade)
172
+ if (score < GRADE_THRESHOLDS.MODERATE) return kleur.yellow(grade)
173
+ if (score < GRADE_THRESHOLDS.HIGH) return kleur.red(grade)
174
+ return kleur.bold().red(grade)
175
+ }
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { SourceFile } from 'ts-morph'
2
+
1
3
  export interface DriftIssue {
2
4
  rule: string
3
5
  severity: 'error' | 'warning' | 'info'
@@ -26,6 +28,39 @@ export interface DriftReport {
26
28
  infos: number
27
29
  byRule: Record<string, number>
28
30
  }
31
+ quality: RepoQualityScore
32
+ maintenanceRisk: MaintenanceRiskMetrics
33
+ }
34
+
35
+ export interface RepoQualityScore {
36
+ overall: number
37
+ dimensions: {
38
+ architecture: number
39
+ complexity: number
40
+ 'ai-patterns': number
41
+ testing: number
42
+ }
43
+ }
44
+
45
+ export interface RiskHotspot {
46
+ file: string
47
+ driftScore: number
48
+ complexityIssues: number
49
+ hasNearbyTests: boolean
50
+ changeFrequency: number
51
+ risk: number
52
+ reasons: string[]
53
+ }
54
+
55
+ export interface MaintenanceRiskMetrics {
56
+ score: number
57
+ level: 'low' | 'medium' | 'high' | 'critical'
58
+ hotspots: RiskHotspot[]
59
+ signals: {
60
+ highComplexityFiles: number
61
+ filesWithoutNearbyTests: number
62
+ frequentChangeFiles: number
63
+ }
29
64
  }
30
65
 
31
66
  export interface AIOutput {
@@ -35,8 +70,13 @@ export interface AIOutput {
35
70
  total_issues: number
36
71
  files_affected: number
37
72
  files_clean: number
73
+ ai_likelihood: number
74
+ ai_code_smell_score: number
38
75
  }
76
+ files_suspected: Array<{ path: string; ai_likelihood: number; triggers: string[] }>
39
77
  priority_order: AIIssue[]
78
+ maintenance_risk: MaintenanceRiskMetrics
79
+ quality: RepoQualityScore
40
80
  context_for_ai: {
41
81
  project_type: string
42
82
  scan_path: string
@@ -86,6 +126,46 @@ export interface ModuleBoundary {
86
126
  export interface DriftConfig {
87
127
  layers?: LayerDefinition[]
88
128
  modules?: ModuleBoundary[]
129
+ plugins?: string[]
130
+ architectureRules?: {
131
+ controllerNoDb?: boolean
132
+ serviceNoHttp?: boolean
133
+ maxFunctionLines?: number
134
+ }
135
+ saas?: {
136
+ freeUserThreshold?: number
137
+ maxRunsPerWorkspacePerMonth?: number
138
+ maxReposPerWorkspace?: number
139
+ retentionDays?: number
140
+ }
141
+ }
142
+
143
+ export interface PluginRuleContext {
144
+ projectRoot: string
145
+ filePath: string
146
+ config?: DriftConfig
147
+ }
148
+
149
+ export interface DriftPluginRule {
150
+ name: string
151
+ severity?: DriftIssue['severity']
152
+ weight?: number
153
+ detect: (file: SourceFile, context: PluginRuleContext) => DriftIssue[]
154
+ }
155
+
156
+ export interface DriftPlugin {
157
+ name: string
158
+ rules: DriftPluginRule[]
159
+ }
160
+
161
+ export interface LoadedPlugin {
162
+ id: string
163
+ plugin: DriftPlugin
164
+ }
165
+
166
+ export interface PluginLoadError {
167
+ pluginId: string
168
+ message: string
89
169
  }
90
170
 
91
171
  // ---------------------------------------------------------------------------
@@ -155,4 +235,4 @@ export interface DriftTrendReport extends DriftReport {
155
235
  /** Extended DriftReport with blame data */
156
236
  export interface DriftBlameReport extends DriftReport {
157
237
  blame: BlameAttribution[];
158
- }
238
+ }
package/src/utils.ts CHANGED
@@ -1,7 +1,9 @@
1
+ // drift-ignore-file
2
+
1
3
  import kleur from 'kleur'
2
4
  import type { DriftIssue } from './types.js'
3
5
 
4
- export interface Grade {
6
+ interface Grade {
5
7
  badge: string
6
8
  label: string
7
9
  }
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { analyzeProject } from '../src/analyzer.js'
6
+ import { buildReport, formatAIOutput } from '../src/reporter.js'
7
+ import { formatReviewMarkdown, type DriftReview } from '../src/review.js'
8
+ import { generateArchitectureSvg } from '../src/map.js'
9
+ import { applyFixes } from '../src/fix.js'
10
+
11
+ describe('new feature MVP', () => {
12
+ let tmpDir = ''
13
+
14
+ afterEach(() => {
15
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
16
+ tmpDir = ''
17
+ })
18
+
19
+ it('includes ai_likelihood and suspected files in --ai output', () => {
20
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-ai-output-'))
21
+ writeFileSync(join(tmpDir, 'bad.ts'), [
22
+ 'function x(a: any) {',
23
+ ' // return value',
24
+ ' if (a) return 1',
25
+ ' return 0',
26
+ '}',
27
+ 'const URL = "https://api.example.com"',
28
+ ].join('\n'))
29
+
30
+ const files = analyzeProject(tmpDir)
31
+ const report = buildReport(tmpDir, files)
32
+ const ai = formatAIOutput(report)
33
+
34
+ expect(ai.summary.ai_likelihood).toBeGreaterThanOrEqual(0)
35
+ expect(typeof ai.summary.ai_code_smell_score).toBe('number')
36
+ expect(Array.isArray(ai.files_suspected)).toBe(true)
37
+ expect(ai.maintenance_risk).toBeDefined()
38
+ expect(ai.quality).toBeDefined()
39
+ })
40
+
41
+ it('formats review markdown for PR comments', () => {
42
+ const review: DriftReview = {
43
+ baseRef: 'origin/main',
44
+ scannedAt: new Date().toISOString(),
45
+ totalDelta: 12,
46
+ newIssues: 3,
47
+ resolvedIssues: 1,
48
+ status: 'regressed',
49
+ summary: 'Drift regressed.',
50
+ markdown: '',
51
+ diff: {
52
+ baseRef: 'origin/main',
53
+ projectPath: '/tmp/repo',
54
+ scannedAt: new Date().toISOString(),
55
+ files: [{
56
+ path: 'src/a.ts',
57
+ scoreBefore: 10,
58
+ scoreAfter: 20,
59
+ scoreDelta: 10,
60
+ newIssues: [],
61
+ resolvedIssues: [],
62
+ }],
63
+ totalScoreBefore: 20,
64
+ totalScoreAfter: 32,
65
+ totalDelta: 12,
66
+ newIssuesCount: 3,
67
+ resolvedIssuesCount: 1,
68
+ },
69
+ }
70
+
71
+ const md = formatReviewMarkdown(review)
72
+ expect(md).toContain('## drift review')
73
+ expect(md).toContain('Base ref: `origin/main`')
74
+ expect(md).toContain('src/a.ts')
75
+ })
76
+
77
+ it('detects configurable architecture rules', () => {
78
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-arch-rules-'))
79
+ mkdirSync(join(tmpDir, 'controllers'))
80
+ mkdirSync(join(tmpDir, 'services'))
81
+
82
+ writeFileSync(join(tmpDir, 'controllers', 'user.controller.ts'), [
83
+ "import { prisma } from '../db/prisma.js'",
84
+ 'export function getUser() { return prisma.user.findMany() }',
85
+ ].join('\n'))
86
+
87
+ writeFileSync(join(tmpDir, 'services', 'mail.service.ts'), [
88
+ "import express from 'express'",
89
+ 'export function sendMail() {',
90
+ ' return express()',
91
+ '}',
92
+ ].join('\n'))
93
+
94
+ writeFileSync(join(tmpDir, 'services', 'long.service.ts'), [
95
+ 'export function veryLong() {',
96
+ ...Array.from({ length: 25 }, (_, i) => ` const v${i} = ${i}`),
97
+ ' return 1',
98
+ '}',
99
+ ].join('\n'))
100
+
101
+ const reports = analyzeProject(tmpDir, {
102
+ architectureRules: {
103
+ controllerNoDb: true,
104
+ serviceNoHttp: true,
105
+ maxFunctionLines: 8,
106
+ },
107
+ })
108
+
109
+ const rules = reports.flatMap((report) => report.issues.map((issue) => issue.rule))
110
+ expect(rules).toContain('controller-no-db')
111
+ expect(rules).toContain('service-no-http')
112
+ expect(rules).toContain('max-function-lines')
113
+ })
114
+
115
+ it('generates architecture SVG map', () => {
116
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-map-'))
117
+ mkdirSync(join(tmpDir, 'api'))
118
+ mkdirSync(join(tmpDir, 'domain'))
119
+ writeFileSync(join(tmpDir, 'domain', 'user.ts'), 'export const x = 1\n')
120
+ writeFileSync(join(tmpDir, 'api', 'controller.ts'), "import { x } from '../domain/user.js'\nexport const y = x\n")
121
+
122
+ const svg = generateArchitectureSvg(tmpDir)
123
+ expect(svg.startsWith('<svg')).toBe(true)
124
+ expect(svg).toContain('api')
125
+ expect(svg).toContain('domain')
126
+ })
127
+
128
+ it('marks cycle and layer violation edges in architecture SVG', () => {
129
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-map-flags-'))
130
+ mkdirSync(join(tmpDir, 'ui'))
131
+ mkdirSync(join(tmpDir, 'api'))
132
+
133
+ writeFileSync(join(tmpDir, 'ui', 'a.ts'), "import { b } from '../api/b.js'\nexport const a = b\n")
134
+ writeFileSync(join(tmpDir, 'api', 'b.ts'), "import { a } from '../ui/a.js'\nexport const b = a\n")
135
+
136
+ const svg = generateArchitectureSvg(tmpDir, {
137
+ layers: [
138
+ {
139
+ name: 'ui',
140
+ patterns: [`${tmpDir.replace(/\\/g, '/')}/ui/**`],
141
+ canImportFrom: ['api'],
142
+ },
143
+ {
144
+ name: 'api',
145
+ patterns: [`${tmpDir.replace(/\\/g, '/')}/api/**`],
146
+ canImportFrom: [],
147
+ },
148
+ ],
149
+ })
150
+
151
+ expect(svg).toContain('data-kind="cycle"')
152
+ expect(svg).toContain('data-kind="violation"')
153
+ })
154
+
155
+ it('falls back safely when plugin cannot be loaded', () => {
156
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-fallback-'))
157
+ writeFileSync(join(tmpDir, 'index.ts'), 'export const x = 1\n')
158
+
159
+ const reports = analyzeProject(tmpDir, {
160
+ plugins: ['./does-not-exist-plugin.js'],
161
+ })
162
+
163
+ const rules = reports.flatMap((report) => report.issues.map((issue) => issue.rule))
164
+ expect(rules).toContain('plugin-error')
165
+ })
166
+
167
+ it('supports fix preview and write modes', async () => {
168
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-fix-modes-'))
169
+ const file = join(tmpDir, 'sample.ts')
170
+ writeFileSync(file, "const x = 1\nconsole.log(x)\n")
171
+
172
+ const preview = await applyFixes(file, undefined, { preview: true })
173
+ expect(preview.length).toBeGreaterThan(0)
174
+ expect(readFileSync(file, 'utf8')).toContain('console.log')
175
+
176
+ const write = await applyFixes(file, undefined, { write: true })
177
+ expect(write.length).toBeGreaterThan(0)
178
+ expect(readFileSync(file, 'utf8')).not.toContain('console.log')
179
+ })
180
+ })
@@ -0,0 +1,107 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { analyzeProject } from '../src/analyzer.js'
6
+ import { buildReport } from '../src/reporter.js'
7
+ import { ingestSnapshotFromReport, getSaasSummary } from '../src/saas.js'
8
+
9
+ function createProjectDir(prefix: string): string {
10
+ const dir = mkdtempSync(join(tmpdir(), prefix))
11
+ writeFileSync(join(dir, 'index.ts'), 'export const value = 1\n')
12
+ return dir
13
+ }
14
+
15
+ function createReport(projectDir: string) {
16
+ const files = analyzeProject(projectDir)
17
+ return buildReport(projectDir, files)
18
+ }
19
+
20
+ describe('saas foundations', () => {
21
+ const dirs: string[] = []
22
+
23
+ afterEach(() => {
24
+ for (const dir of dirs.splice(0)) {
25
+ rmSync(dir, { recursive: true, force: true })
26
+ }
27
+ })
28
+
29
+ it('ingest creates snapshot and summary stays consistent', () => {
30
+ const projectDir = createProjectDir('drift-saas-ingest-')
31
+ dirs.push(projectDir)
32
+ const storeFile = join(projectDir, '.drift-cloud', 'store.json')
33
+
34
+ const report = createReport(projectDir)
35
+ const snapshot = ingestSnapshotFromReport(report, {
36
+ workspaceId: 'ws-1',
37
+ userId: 'user-1',
38
+ repoName: 'repo-1',
39
+ storeFile,
40
+ })
41
+
42
+ const summary = getSaasSummary({ storeFile })
43
+
44
+ expect(snapshot.workspaceId).toBe('ws-1')
45
+ expect(summary.usersRegistered).toBe(1)
46
+ expect(summary.workspacesActive).toBe(1)
47
+ expect(summary.reposActive).toBe(1)
48
+ expect(summary.totalSnapshots).toBe(1)
49
+ expect(summary.phase).toBe('free')
50
+ })
51
+
52
+ it('blocks ingest when workspace exceeds repo limit', () => {
53
+ const projectDir = createProjectDir('drift-saas-repo-limit-')
54
+ dirs.push(projectDir)
55
+ const storeFile = join(projectDir, '.drift-cloud', 'store.json')
56
+ const report = createReport(projectDir)
57
+
58
+ ingestSnapshotFromReport(report, {
59
+ workspaceId: 'ws-2',
60
+ userId: 'user-1',
61
+ repoName: 'repo-a',
62
+ storeFile,
63
+ policy: { maxReposPerWorkspace: 1 },
64
+ })
65
+
66
+ expect(() => {
67
+ ingestSnapshotFromReport(report, {
68
+ workspaceId: 'ws-2',
69
+ userId: 'user-1',
70
+ repoName: 'repo-b',
71
+ storeFile,
72
+ policy: { maxReposPerWorkspace: 1 },
73
+ })
74
+ }).toThrow(/max repos/i)
75
+
76
+ const summary = getSaasSummary({ storeFile, policy: { maxReposPerWorkspace: 1 } })
77
+ expect(summary.totalSnapshots).toBe(1)
78
+ })
79
+
80
+ it('summary reflects free to paid threshold transition', () => {
81
+ const projectDir = createProjectDir('drift-saas-threshold-')
82
+ dirs.push(projectDir)
83
+ const storeFile = join(projectDir, '.drift-cloud', 'store.json')
84
+ const report = createReport(projectDir)
85
+
86
+ ingestSnapshotFromReport(report, {
87
+ workspaceId: 'ws-3',
88
+ userId: 'u-1',
89
+ repoName: 'repo-a',
90
+ storeFile,
91
+ policy: { freeUserThreshold: 2 },
92
+ })
93
+ ingestSnapshotFromReport(report, {
94
+ workspaceId: 'ws-4',
95
+ userId: 'u-2',
96
+ repoName: 'repo-b',
97
+ storeFile,
98
+ policy: { freeUserThreshold: 2 },
99
+ })
100
+
101
+ const summary = getSaasSummary({ storeFile, policy: { freeUserThreshold: 2 } })
102
+ expect(summary.usersRegistered).toBe(2)
103
+ expect(summary.thresholdReached).toBe(true)
104
+ expect(summary.phase).toBe('paid')
105
+ expect(summary.freeUsersRemaining).toBe(0)
106
+ })
107
+ })