@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.
- package/.github/actions/drift-scan/README.md +61 -0
- package/.github/actions/drift-scan/action.yml +65 -0
- package/.github/workflows/publish-vscode.yml +3 -1
- package/.github/workflows/review-pr.yml +61 -0
- package/AGENTS.md +53 -11
- package/README.md +106 -1
- package/dist/analyzer.d.ts +6 -2
- package/dist/analyzer.js +116 -3
- package/dist/badge.js +40 -22
- package/dist/ci.js +32 -18
- package/dist/cli.js +179 -6
- package/dist/diff.d.ts +0 -7
- package/dist/diff.js +26 -25
- package/dist/fix.d.ts +4 -0
- package/dist/fix.js +59 -47
- package/dist/git/trend.js +1 -0
- package/dist/git.d.ts +0 -9
- package/dist/git.js +25 -19
- package/dist/index.d.ts +7 -1
- package/dist/index.js +4 -0
- package/dist/map.d.ts +4 -0
- package/dist/map.js +191 -0
- package/dist/metrics.d.ts +4 -0
- package/dist/metrics.js +176 -0
- package/dist/plugins.d.ts +6 -0
- package/dist/plugins.js +74 -0
- package/dist/printer.js +20 -0
- package/dist/report.js +34 -0
- package/dist/reporter.js +85 -2
- package/dist/review.d.ts +15 -0
- package/dist/review.js +80 -0
- package/dist/rules/comments.d.ts +4 -0
- package/dist/rules/comments.js +45 -0
- package/dist/rules/complexity.d.ts +4 -0
- package/dist/rules/complexity.js +51 -0
- package/dist/rules/coupling.d.ts +4 -0
- package/dist/rules/coupling.js +19 -0
- package/dist/rules/magic.d.ts +4 -0
- package/dist/rules/magic.js +33 -0
- package/dist/rules/nesting.d.ts +5 -0
- package/dist/rules/nesting.js +82 -0
- package/dist/rules/phase0-basic.js +14 -7
- package/dist/rules/phase1-complexity.d.ts +6 -30
- package/dist/rules/phase1-complexity.js +7 -276
- package/dist/rules/phase2-crossfile.d.ts +0 -4
- package/dist/rules/phase2-crossfile.js +52 -39
- package/dist/rules/phase3-arch.d.ts +0 -8
- package/dist/rules/phase3-arch.js +26 -23
- package/dist/rules/phase3-configurable.d.ts +6 -0
- package/dist/rules/phase3-configurable.js +97 -0
- package/dist/rules/phase8-semantic.d.ts +0 -5
- package/dist/rules/phase8-semantic.js +30 -29
- package/dist/rules/promise.d.ts +4 -0
- package/dist/rules/promise.js +24 -0
- package/dist/saas.d.ts +83 -0
- package/dist/saas.js +321 -0
- package/dist/snapshot.d.ts +19 -0
- package/dist/snapshot.js +119 -0
- package/dist/types.d.ts +75 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +1 -0
- package/docs/AGENTS.md +146 -0
- package/docs/PRD.md +157 -0
- package/package.json +1 -1
- package/packages/eslint-plugin-drift/src/index.ts +1 -1
- package/packages/vscode-drift/package.json +1 -1
- package/packages/vscode-drift/src/analyzer.ts +2 -0
- package/packages/vscode-drift/src/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +98 -63
- package/packages/vscode-drift/src/statusbar.ts +13 -5
- package/packages/vscode-drift/src/treeview.ts +2 -0
- package/src/analyzer.ts +144 -12
- package/src/badge.ts +38 -16
- package/src/ci.ts +38 -17
- package/src/cli.ts +206 -7
- package/src/diff.ts +36 -30
- package/src/fix.ts +77 -53
- package/src/git/trend.ts +3 -2
- package/src/git.ts +31 -22
- package/src/index.ts +31 -1
- package/src/map.ts +219 -0
- package/src/metrics.ts +200 -0
- package/src/plugins.ts +76 -0
- package/src/printer.ts +20 -0
- package/src/report.ts +35 -0
- package/src/reporter.ts +95 -2
- package/src/review.ts +98 -0
- package/src/rules/comments.ts +56 -0
- package/src/rules/complexity.ts +57 -0
- package/src/rules/coupling.ts +23 -0
- package/src/rules/magic.ts +38 -0
- package/src/rules/nesting.ts +88 -0
- package/src/rules/phase0-basic.ts +14 -7
- package/src/rules/phase1-complexity.ts +8 -302
- package/src/rules/phase2-crossfile.ts +68 -40
- package/src/rules/phase3-arch.ts +34 -30
- package/src/rules/phase3-configurable.ts +132 -0
- package/src/rules/phase8-semantic.ts +33 -29
- package/src/rules/promise.ts +29 -0
- package/src/saas.ts +433 -0
- package/src/snapshot.ts +175 -0
- package/src/types.ts +81 -1
- package/src/utils.ts +3 -1
- package/tests/new-features.test.ts +180 -0
- package/tests/saas-foundation.test.ts +107 -0
package/src/snapshot.ts
ADDED
|
@@ -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
|
@@ -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
|
+
})
|