@eduardbar/drift 1.1.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/workflows/review-pr.yml +61 -0
- package/README.md +39 -1
- package/dist/cli.js +97 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/map.d.ts +3 -2
- package/dist/map.js +98 -10
- package/dist/saas.d.ts +83 -0
- package/dist/saas.js +321 -0
- package/dist/types.d.ts +6 -0
- package/docs/PRD.md +125 -176
- package/package.json +1 -1
- package/packages/vscode-drift/src/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +11 -0
- package/src/cli.ts +112 -3
- package/src/index.ts +15 -0
- package/src/map.ts +112 -10
- package/src/saas.ts +433 -0
- package/src/types.ts +6 -0
- package/tests/new-features.test.ts +27 -0
- package/tests/saas-foundation.test.ts +107 -0
package/src/map.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs'
|
|
2
2
|
import { resolve, relative } from 'node:path'
|
|
3
3
|
import { Project } from 'ts-morph'
|
|
4
|
+
import type { DriftConfig } from './types.js'
|
|
5
|
+
import { detectLayerViolations } from './rules/phase3-arch.js'
|
|
6
|
+
import { RULE_WEIGHTS } from './analyzer.js'
|
|
4
7
|
|
|
5
8
|
interface LayerNode {
|
|
6
9
|
name: string
|
|
7
10
|
files: Set<string>
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
interface MapEdge {
|
|
14
|
+
key: string
|
|
15
|
+
from: string
|
|
16
|
+
to: string
|
|
17
|
+
count: number
|
|
18
|
+
kind: 'normal' | 'cycle' | 'violation'
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
function detectLayer(relPath: string): string {
|
|
11
22
|
const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
12
23
|
const first = normalized.split('/')[0] || 'root'
|
|
@@ -17,7 +28,7 @@ function esc(value: string): string {
|
|
|
17
28
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
18
29
|
}
|
|
19
30
|
|
|
20
|
-
export function generateArchitectureSvg(targetPath: string): string {
|
|
31
|
+
export function generateArchitectureSvg(targetPath: string, config?: DriftConfig): string {
|
|
21
32
|
const project = new Project({
|
|
22
33
|
skipAddingFilesFromTsConfig: true,
|
|
23
34
|
compilerOptions: { allowJs: true, jsx: 1 },
|
|
@@ -36,24 +47,65 @@ export function generateArchitectureSvg(targetPath: string): string {
|
|
|
36
47
|
|
|
37
48
|
const layers = new Map<string, LayerNode>()
|
|
38
49
|
const edges = new Map<string, number>()
|
|
50
|
+
const layerAdjacency = new Map<string, Set<string>>()
|
|
51
|
+
const fileImportGraph = new Map<string, Set<string>>()
|
|
39
52
|
|
|
40
53
|
for (const file of project.getSourceFiles()) {
|
|
41
|
-
const
|
|
54
|
+
const filePath = file.getFilePath()
|
|
55
|
+
const rel = relative(targetPath, filePath).replace(/\\/g, '/')
|
|
42
56
|
const layerName = detectLayer(rel)
|
|
43
57
|
if (!layers.has(layerName)) layers.set(layerName, { name: layerName, files: new Set() })
|
|
44
58
|
layers.get(layerName)!.files.add(rel)
|
|
59
|
+
if (!fileImportGraph.has(filePath)) fileImportGraph.set(filePath, new Set())
|
|
45
60
|
|
|
46
61
|
for (const decl of file.getImportDeclarations()) {
|
|
47
62
|
const imported = decl.getModuleSpecifierSourceFile()
|
|
48
63
|
if (!imported) continue
|
|
64
|
+
fileImportGraph.get(filePath)!.add(imported.getFilePath())
|
|
49
65
|
const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/')
|
|
50
66
|
const importedLayer = detectLayer(importedRel)
|
|
51
67
|
if (importedLayer === layerName) continue
|
|
52
68
|
const key = `${layerName}->${importedLayer}`
|
|
53
69
|
edges.set(key, (edges.get(key) ?? 0) + 1)
|
|
70
|
+
if (!layerAdjacency.has(layerName)) layerAdjacency.set(layerName, new Set())
|
|
71
|
+
layerAdjacency.get(layerName)!.add(importedLayer)
|
|
54
72
|
}
|
|
55
73
|
}
|
|
56
74
|
|
|
75
|
+
const cycleEdges = detectCycleEdges(layerAdjacency)
|
|
76
|
+
const violationEdges = new Set<string>()
|
|
77
|
+
|
|
78
|
+
if (config?.layers && config.layers.length > 0) {
|
|
79
|
+
const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS)
|
|
80
|
+
for (const issues of violations.values()) {
|
|
81
|
+
for (const issue of issues) {
|
|
82
|
+
const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/)
|
|
83
|
+
if (!match) continue
|
|
84
|
+
const from = match[1]
|
|
85
|
+
const to = match[2]
|
|
86
|
+
violationEdges.add(`${from}->${to}`)
|
|
87
|
+
if (!layers.has(from)) layers.set(from, { name: from, files: new Set() })
|
|
88
|
+
if (!layers.has(to)) layers.set(to, { name: to, files: new Set() })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const edgeList: MapEdge[] = [...edges.entries()].map(([key, count]) => {
|
|
94
|
+
const [from, to] = key.split('->')
|
|
95
|
+
const kind = violationEdges.has(key)
|
|
96
|
+
? 'violation'
|
|
97
|
+
: cycleEdges.has(key)
|
|
98
|
+
? 'cycle'
|
|
99
|
+
: 'normal'
|
|
100
|
+
return { key, from, to, count, kind }
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
for (const key of violationEdges) {
|
|
104
|
+
if (edges.has(key)) continue
|
|
105
|
+
const [from, to] = key.split('->')
|
|
106
|
+
edgeList.push({ key, from, to, count: 1, kind: 'violation' })
|
|
107
|
+
}
|
|
108
|
+
|
|
57
109
|
const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
58
110
|
const width = 960
|
|
59
111
|
const rowHeight = 90
|
|
@@ -73,18 +125,23 @@ export function generateArchitectureSvg(targetPath: string): string {
|
|
|
73
125
|
|
|
74
126
|
const boxByName = new Map(boxes.map((box) => [box.name, box]))
|
|
75
127
|
|
|
76
|
-
const lines =
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const b = boxByName.get(to)
|
|
128
|
+
const lines = edgeList.map((edge) => {
|
|
129
|
+
const a = boxByName.get(edge.from)
|
|
130
|
+
const b = boxByName.get(edge.to)
|
|
80
131
|
if (!a || !b) return ''
|
|
81
132
|
const startX = a.x + boxWidth
|
|
82
133
|
const startY = a.y + boxHeight / 2
|
|
83
134
|
const endX = b.x
|
|
84
135
|
const endY = b.y + boxHeight / 2
|
|
136
|
+
const stroke = edge.kind === 'violation'
|
|
137
|
+
? '#ef4444'
|
|
138
|
+
: edge.kind === 'cycle'
|
|
139
|
+
? '#f59e0b'
|
|
140
|
+
: '#64748b'
|
|
141
|
+
const widthPx = edge.kind === 'normal' ? 2 : 3
|
|
85
142
|
return `
|
|
86
|
-
<line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="
|
|
87
|
-
<text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${count}</text>`
|
|
143
|
+
<line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="${stroke}" stroke-width="${widthPx}" marker-end="url(#arrow)" data-edge="${esc(edge.key)}" data-kind="${edge.kind}" />
|
|
144
|
+
<text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${edge.count}</text>`
|
|
88
145
|
}).join('')
|
|
89
146
|
|
|
90
147
|
const nodes = boxes.map((box) => `
|
|
@@ -94,6 +151,9 @@ export function generateArchitectureSvg(targetPath: string): string {
|
|
|
94
151
|
<text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
|
|
95
152
|
</g>`).join('')
|
|
96
153
|
|
|
154
|
+
const cycleCount = edgeList.filter((edge) => edge.kind === 'cycle').length
|
|
155
|
+
const violationCount = edgeList.filter((edge) => edge.kind === 'violation').length
|
|
156
|
+
|
|
97
157
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
98
158
|
<defs>
|
|
99
159
|
<marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
|
|
@@ -103,14 +163,56 @@ export function generateArchitectureSvg(targetPath: string): string {
|
|
|
103
163
|
<rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
|
|
104
164
|
<text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
|
|
105
165
|
<text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
|
|
166
|
+
<text x="28" y="72" fill="#94a3b8" font-size="11" font-family="monospace">Cycle edges: ${cycleCount} | Layer violations: ${violationCount}</text>
|
|
167
|
+
<line x1="520" y1="66" x2="560" y2="66" stroke="#f59e0b" stroke-width="3" /><text x="567" y="69" fill="#94a3b8" font-size="11" font-family="monospace">cycle</text>
|
|
168
|
+
<line x1="630" y1="66" x2="670" y2="66" stroke="#ef4444" stroke-width="3" /><text x="677" y="69" fill="#94a3b8" font-size="11" font-family="monospace">violation</text>
|
|
106
169
|
${lines}
|
|
107
170
|
${nodes}
|
|
108
171
|
</svg>`
|
|
109
172
|
}
|
|
110
173
|
|
|
111
|
-
|
|
174
|
+
function detectCycleEdges(adjacency: Map<string, Set<string>>): Set<string> {
|
|
175
|
+
const visited = new Set<string>()
|
|
176
|
+
const inStack = new Set<string>()
|
|
177
|
+
const stack: string[] = []
|
|
178
|
+
const cycleEdges = new Set<string>()
|
|
179
|
+
|
|
180
|
+
function dfs(node: string): void {
|
|
181
|
+
visited.add(node)
|
|
182
|
+
inStack.add(node)
|
|
183
|
+
stack.push(node)
|
|
184
|
+
|
|
185
|
+
for (const neighbor of adjacency.get(node) ?? []) {
|
|
186
|
+
if (!visited.has(neighbor)) {
|
|
187
|
+
dfs(neighbor)
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!inStack.has(neighbor)) continue
|
|
192
|
+
|
|
193
|
+
const startIndex = stack.indexOf(neighbor)
|
|
194
|
+
if (startIndex >= 0) {
|
|
195
|
+
for (let i = startIndex; i < stack.length - 1; i++) {
|
|
196
|
+
cycleEdges.add(`${stack[i]}->${stack[i + 1]}`)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
cycleEdges.add(`${node}->${neighbor}`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
stack.pop()
|
|
203
|
+
inStack.delete(node)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const node of adjacency.keys()) {
|
|
207
|
+
if (!visited.has(node)) dfs(node)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return cycleEdges
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function generateArchitectureMap(targetPath: string, outputFile = 'architecture.svg', config?: DriftConfig): string {
|
|
112
214
|
const resolvedTarget = resolve(targetPath)
|
|
113
|
-
const svg = generateArchitectureSvg(resolvedTarget)
|
|
215
|
+
const svg = generateArchitectureSvg(resolvedTarget, config)
|
|
114
216
|
const outPath = resolve(outputFile)
|
|
115
217
|
writeFileSync(outPath, svg, 'utf8')
|
|
116
218
|
return outPath
|
package/src/saas.ts
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import type { DriftReport, DriftConfig } from './types.js'
|
|
4
|
+
|
|
5
|
+
export interface SaasPolicy {
|
|
6
|
+
freeUserThreshold: number
|
|
7
|
+
maxRunsPerWorkspacePerMonth: number
|
|
8
|
+
maxReposPerWorkspace: number
|
|
9
|
+
retentionDays: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SaasUser {
|
|
13
|
+
id: string
|
|
14
|
+
createdAt: string
|
|
15
|
+
lastSeenAt: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SaasWorkspace {
|
|
19
|
+
id: string
|
|
20
|
+
createdAt: string
|
|
21
|
+
lastSeenAt: string
|
|
22
|
+
userIds: string[]
|
|
23
|
+
repoIds: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SaasRepo {
|
|
27
|
+
id: string
|
|
28
|
+
workspaceId: string
|
|
29
|
+
name: string
|
|
30
|
+
createdAt: string
|
|
31
|
+
lastSeenAt: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SaasSnapshot {
|
|
35
|
+
id: string
|
|
36
|
+
createdAt: string
|
|
37
|
+
scannedAt: string
|
|
38
|
+
workspaceId: string
|
|
39
|
+
userId: string
|
|
40
|
+
repoId: string
|
|
41
|
+
repoName: string
|
|
42
|
+
targetPath: string
|
|
43
|
+
totalScore: number
|
|
44
|
+
totalIssues: number
|
|
45
|
+
totalFiles: number
|
|
46
|
+
summary: {
|
|
47
|
+
errors: number
|
|
48
|
+
warnings: number
|
|
49
|
+
infos: number
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SaasStore {
|
|
54
|
+
version: number
|
|
55
|
+
policy: SaasPolicy
|
|
56
|
+
users: Record<string, SaasUser>
|
|
57
|
+
workspaces: Record<string, SaasWorkspace>
|
|
58
|
+
repos: Record<string, SaasRepo>
|
|
59
|
+
snapshots: SaasSnapshot[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SaasSummary {
|
|
63
|
+
policy: SaasPolicy
|
|
64
|
+
usersRegistered: number
|
|
65
|
+
workspacesActive: number
|
|
66
|
+
reposActive: number
|
|
67
|
+
runsPerMonth: Record<string, number>
|
|
68
|
+
totalSnapshots: number
|
|
69
|
+
phase: 'free' | 'paid'
|
|
70
|
+
thresholdReached: boolean
|
|
71
|
+
freeUsersRemaining: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface IngestOptions {
|
|
75
|
+
workspaceId: string
|
|
76
|
+
userId: string
|
|
77
|
+
repoName?: string
|
|
78
|
+
storeFile?: string
|
|
79
|
+
policy?: Partial<SaasPolicy>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const STORE_VERSION = 1
|
|
83
|
+
const ACTIVE_WINDOW_DAYS = 30
|
|
84
|
+
|
|
85
|
+
export const DEFAULT_SAAS_POLICY: SaasPolicy = {
|
|
86
|
+
freeUserThreshold: 7500,
|
|
87
|
+
maxRunsPerWorkspacePerMonth: 500,
|
|
88
|
+
maxReposPerWorkspace: 20,
|
|
89
|
+
retentionDays: 90,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveSaasPolicy(policy?: Partial<SaasPolicy> | DriftConfig['saas']): SaasPolicy {
|
|
93
|
+
return {
|
|
94
|
+
...DEFAULT_SAAS_POLICY,
|
|
95
|
+
...(policy ?? {}),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function defaultSaasStorePath(root = '.'): string {
|
|
100
|
+
return resolve(root, '.drift-cloud', 'store.json')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function ensureStoreFile(storeFile: string, policy?: Partial<SaasPolicy>): void {
|
|
104
|
+
const dir = dirname(storeFile)
|
|
105
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
106
|
+
if (!existsSync(storeFile)) {
|
|
107
|
+
const initial = createEmptyStore(policy)
|
|
108
|
+
writeFileSync(storeFile, JSON.stringify(initial, null, 2), 'utf8')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function createEmptyStore(policy?: Partial<SaasPolicy>): SaasStore {
|
|
113
|
+
return {
|
|
114
|
+
version: STORE_VERSION,
|
|
115
|
+
policy: resolveSaasPolicy(policy),
|
|
116
|
+
users: {},
|
|
117
|
+
workspaces: {},
|
|
118
|
+
repos: {},
|
|
119
|
+
snapshots: [],
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function monthKey(isoDate: string): string {
|
|
124
|
+
const date = new Date(isoDate)
|
|
125
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
|
126
|
+
return `${date.getUTCFullYear()}-${month}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function daysAgo(days: number): number {
|
|
130
|
+
const now = Date.now()
|
|
131
|
+
return now - days * 24 * 60 * 60 * 1000
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function applyRetention(store: SaasStore): void {
|
|
135
|
+
const cutoff = daysAgo(store.policy.retentionDays)
|
|
136
|
+
store.snapshots = store.snapshots.filter((snapshot) => {
|
|
137
|
+
return new Date(snapshot.createdAt).getTime() >= cutoff
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function saveStore(storeFile: string, store: SaasStore): void {
|
|
142
|
+
writeFileSync(storeFile, JSON.stringify(store, null, 2), 'utf8')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function loadStoreInternal(storeFile: string, policy?: Partial<SaasPolicy>): SaasStore {
|
|
146
|
+
ensureStoreFile(storeFile, policy)
|
|
147
|
+
const raw = readFileSync(storeFile, 'utf8')
|
|
148
|
+
const parsed = JSON.parse(raw) as Partial<SaasStore>
|
|
149
|
+
|
|
150
|
+
const merged = createEmptyStore(parsed.policy)
|
|
151
|
+
merged.version = parsed.version ?? STORE_VERSION
|
|
152
|
+
merged.users = parsed.users ?? {}
|
|
153
|
+
merged.workspaces = parsed.workspaces ?? {}
|
|
154
|
+
merged.repos = parsed.repos ?? {}
|
|
155
|
+
merged.snapshots = parsed.snapshots ?? []
|
|
156
|
+
merged.policy = resolveSaasPolicy({ ...merged.policy, ...policy })
|
|
157
|
+
applyRetention(merged)
|
|
158
|
+
|
|
159
|
+
return merged
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isWorkspaceActive(workspace: SaasWorkspace): boolean {
|
|
163
|
+
return new Date(workspace.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isRepoActive(repo: SaasRepo): boolean {
|
|
167
|
+
return new Date(repo.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function assertGuardrails(store: SaasStore, options: IngestOptions, nowIso: string): void {
|
|
171
|
+
const usersRegistered = Object.keys(store.users).length
|
|
172
|
+
const isFreePhase = usersRegistered < store.policy.freeUserThreshold
|
|
173
|
+
if (!isFreePhase) return
|
|
174
|
+
|
|
175
|
+
if (!store.users[options.userId] && usersRegistered + 1 > store.policy.freeUserThreshold) {
|
|
176
|
+
throw new Error(`Free threshold reached (${store.policy.freeUserThreshold} users).`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const workspace = store.workspaces[options.workspaceId]
|
|
180
|
+
const repoName = options.repoName ?? 'default'
|
|
181
|
+
const repoId = `${options.workspaceId}:${repoName}`
|
|
182
|
+
const repoExists = Boolean(store.repos[repoId])
|
|
183
|
+
const repoCount = workspace?.repoIds.length ?? 0
|
|
184
|
+
|
|
185
|
+
if (!repoExists && repoCount >= store.policy.maxReposPerWorkspace) {
|
|
186
|
+
throw new Error(`Workspace '${options.workspaceId}' reached max repos (${store.policy.maxReposPerWorkspace}).`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const currentMonth = monthKey(nowIso)
|
|
190
|
+
const runsThisMonth = store.snapshots.filter((snapshot) => {
|
|
191
|
+
return snapshot.workspaceId === options.workspaceId && monthKey(snapshot.createdAt) === currentMonth
|
|
192
|
+
}).length
|
|
193
|
+
|
|
194
|
+
if (runsThisMonth >= store.policy.maxRunsPerWorkspacePerMonth) {
|
|
195
|
+
throw new Error(`Workspace '${options.workspaceId}' reached max monthly runs (${store.policy.maxRunsPerWorkspacePerMonth}).`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function ingestSnapshotFromReport(report: DriftReport, options: IngestOptions): SaasSnapshot {
|
|
200
|
+
const storeFile = resolve(options.storeFile ?? defaultSaasStorePath())
|
|
201
|
+
const store = loadStoreInternal(storeFile, options.policy)
|
|
202
|
+
const nowIso = new Date().toISOString()
|
|
203
|
+
|
|
204
|
+
assertGuardrails(store, options, nowIso)
|
|
205
|
+
|
|
206
|
+
const user = store.users[options.userId]
|
|
207
|
+
if (user) {
|
|
208
|
+
user.lastSeenAt = nowIso
|
|
209
|
+
} else {
|
|
210
|
+
store.users[options.userId] = {
|
|
211
|
+
id: options.userId,
|
|
212
|
+
createdAt: nowIso,
|
|
213
|
+
lastSeenAt: nowIso,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const workspace = store.workspaces[options.workspaceId]
|
|
218
|
+
if (workspace) {
|
|
219
|
+
workspace.lastSeenAt = nowIso
|
|
220
|
+
if (!workspace.userIds.includes(options.userId)) workspace.userIds.push(options.userId)
|
|
221
|
+
} else {
|
|
222
|
+
store.workspaces[options.workspaceId] = {
|
|
223
|
+
id: options.workspaceId,
|
|
224
|
+
createdAt: nowIso,
|
|
225
|
+
lastSeenAt: nowIso,
|
|
226
|
+
userIds: [options.userId],
|
|
227
|
+
repoIds: [],
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const repoName = options.repoName ?? 'default'
|
|
232
|
+
const repoId = `${options.workspaceId}:${repoName}`
|
|
233
|
+
const repo = store.repos[repoId]
|
|
234
|
+
if (repo) {
|
|
235
|
+
repo.lastSeenAt = nowIso
|
|
236
|
+
} else {
|
|
237
|
+
store.repos[repoId] = {
|
|
238
|
+
id: repoId,
|
|
239
|
+
workspaceId: options.workspaceId,
|
|
240
|
+
name: repoName,
|
|
241
|
+
createdAt: nowIso,
|
|
242
|
+
lastSeenAt: nowIso,
|
|
243
|
+
}
|
|
244
|
+
const ws = store.workspaces[options.workspaceId]
|
|
245
|
+
if (!ws.repoIds.includes(repoId)) ws.repoIds.push(repoId)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const snapshot: SaasSnapshot = {
|
|
249
|
+
id: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
|
|
250
|
+
createdAt: nowIso,
|
|
251
|
+
scannedAt: report.scannedAt,
|
|
252
|
+
workspaceId: options.workspaceId,
|
|
253
|
+
userId: options.userId,
|
|
254
|
+
repoId,
|
|
255
|
+
repoName,
|
|
256
|
+
targetPath: report.targetPath,
|
|
257
|
+
totalScore: report.totalScore,
|
|
258
|
+
totalIssues: report.totalIssues,
|
|
259
|
+
totalFiles: report.totalFiles,
|
|
260
|
+
summary: {
|
|
261
|
+
errors: report.summary.errors,
|
|
262
|
+
warnings: report.summary.warnings,
|
|
263
|
+
infos: report.summary.infos,
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
store.snapshots.push(snapshot)
|
|
268
|
+
applyRetention(store)
|
|
269
|
+
saveStore(storeFile, store)
|
|
270
|
+
|
|
271
|
+
return snapshot
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function getSaasSummary(options?: { storeFile?: string; policy?: Partial<SaasPolicy> }): SaasSummary {
|
|
275
|
+
const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
|
|
276
|
+
const store = loadStoreInternal(storeFile, options?.policy)
|
|
277
|
+
saveStore(storeFile, store)
|
|
278
|
+
|
|
279
|
+
const usersRegistered = Object.keys(store.users).length
|
|
280
|
+
const workspacesActive = Object.values(store.workspaces).filter(isWorkspaceActive).length
|
|
281
|
+
const reposActive = Object.values(store.repos).filter(isRepoActive).length
|
|
282
|
+
|
|
283
|
+
const runsPerMonth: Record<string, number> = {}
|
|
284
|
+
for (const snapshot of store.snapshots) {
|
|
285
|
+
const key = monthKey(snapshot.createdAt)
|
|
286
|
+
runsPerMonth[key] = (runsPerMonth[key] ?? 0) + 1
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const thresholdReached = usersRegistered >= store.policy.freeUserThreshold
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
policy: store.policy,
|
|
293
|
+
usersRegistered,
|
|
294
|
+
workspacesActive,
|
|
295
|
+
reposActive,
|
|
296
|
+
runsPerMonth,
|
|
297
|
+
totalSnapshots: store.snapshots.length,
|
|
298
|
+
phase: thresholdReached ? 'paid' : 'free',
|
|
299
|
+
thresholdReached,
|
|
300
|
+
freeUsersRemaining: Math.max(0, store.policy.freeUserThreshold - usersRegistered),
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function escapeHtml(value: string): string {
|
|
305
|
+
return value
|
|
306
|
+
.replaceAll('&', '&')
|
|
307
|
+
.replaceAll('<', '<')
|
|
308
|
+
.replaceAll('>', '>')
|
|
309
|
+
.replaceAll('"', '"')
|
|
310
|
+
.replaceAll("'", ''')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function generateSaasDashboardHtml(options?: { storeFile?: string; policy?: Partial<SaasPolicy> }): string {
|
|
314
|
+
const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
|
|
315
|
+
const store = loadStoreInternal(storeFile, options?.policy)
|
|
316
|
+
const summary = getSaasSummary(options)
|
|
317
|
+
|
|
318
|
+
const workspaceStats = Object.values(store.workspaces)
|
|
319
|
+
.map((workspace) => {
|
|
320
|
+
const snapshots = store.snapshots.filter((snapshot) => snapshot.workspaceId === workspace.id)
|
|
321
|
+
const runs = snapshots.length
|
|
322
|
+
const avgScore = runs === 0
|
|
323
|
+
? 0
|
|
324
|
+
: Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs)
|
|
325
|
+
const lastRun = snapshots.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]?.createdAt ?? 'n/a'
|
|
326
|
+
return {
|
|
327
|
+
id: workspace.id,
|
|
328
|
+
runs,
|
|
329
|
+
avgScore,
|
|
330
|
+
lastRun,
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
.sort((a, b) => b.avgScore - a.avgScore)
|
|
334
|
+
|
|
335
|
+
const repoStats = Object.values(store.repos)
|
|
336
|
+
.map((repo) => {
|
|
337
|
+
const snapshots = store.snapshots.filter((snapshot) => snapshot.repoId === repo.id)
|
|
338
|
+
const runs = snapshots.length
|
|
339
|
+
const avgScore = runs === 0
|
|
340
|
+
? 0
|
|
341
|
+
: Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs)
|
|
342
|
+
return {
|
|
343
|
+
workspaceId: repo.workspaceId,
|
|
344
|
+
name: repo.name,
|
|
345
|
+
runs,
|
|
346
|
+
avgScore,
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
.sort((a, b) => b.avgScore - a.avgScore)
|
|
350
|
+
.slice(0, 15)
|
|
351
|
+
|
|
352
|
+
const runsRows = Object.entries(summary.runsPerMonth)
|
|
353
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
354
|
+
.map(([month, count]) => {
|
|
355
|
+
const width = Math.max(8, count * 8)
|
|
356
|
+
return `<tr><td>${escapeHtml(month)}</td><td>${count}</td><td><div class="bar" style="width:${width}px"></div></td></tr>`
|
|
357
|
+
})
|
|
358
|
+
.join('')
|
|
359
|
+
|
|
360
|
+
const workspaceRows = workspaceStats
|
|
361
|
+
.map((workspace) => `<tr><td>${escapeHtml(workspace.id)}</td><td>${workspace.runs}</td><td>${workspace.avgScore}</td><td>${escapeHtml(workspace.lastRun)}</td></tr>`)
|
|
362
|
+
.join('')
|
|
363
|
+
|
|
364
|
+
const repoRows = repoStats
|
|
365
|
+
.map((repo) => `<tr><td>${escapeHtml(repo.workspaceId)}</td><td>${escapeHtml(repo.name)}</td><td>${repo.runs}</td><td>${repo.avgScore}</td></tr>`)
|
|
366
|
+
.join('')
|
|
367
|
+
|
|
368
|
+
return `<!doctype html>
|
|
369
|
+
<html lang="en">
|
|
370
|
+
<head>
|
|
371
|
+
<meta charset="utf-8" />
|
|
372
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
373
|
+
<title>drift cloud dashboard</title>
|
|
374
|
+
<style>
|
|
375
|
+
:root { color-scheme: light; }
|
|
376
|
+
body { margin: 0; font-family: "Segoe UI", Arial, sans-serif; background: #f4f7fb; color: #0f172a; }
|
|
377
|
+
main { max-width: 980px; margin: 0 auto; padding: 24px; }
|
|
378
|
+
h1 { margin: 0 0 6px; }
|
|
379
|
+
p.meta { margin: 0 0 20px; color: #475569; }
|
|
380
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
|
381
|
+
.card { background: #ffffff; border-radius: 10px; padding: 14px; border: 1px solid #dbe3ef; }
|
|
382
|
+
.card .label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
383
|
+
.card .value { font-size: 26px; font-weight: 700; margin-top: 4px; }
|
|
384
|
+
table { width: 100%; border-collapse: collapse; margin-top: 10px; background: #ffffff; border: 1px solid #dbe3ef; border-radius: 10px; overflow: hidden; }
|
|
385
|
+
th, td { padding: 10px; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 14px; }
|
|
386
|
+
th { background: #eef2f9; }
|
|
387
|
+
.section { margin-top: 18px; }
|
|
388
|
+
.bar { height: 10px; background: linear-gradient(90deg, #0ea5e9, #22c55e); border-radius: 999px; }
|
|
389
|
+
.pill { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; font-weight: 600; }
|
|
390
|
+
.pill.free { background: #dcfce7; color: #166534; }
|
|
391
|
+
.pill.paid { background: #fee2e2; color: #991b1b; }
|
|
392
|
+
</style>
|
|
393
|
+
</head>
|
|
394
|
+
<body>
|
|
395
|
+
<main>
|
|
396
|
+
<h1>drift cloud dashboard</h1>
|
|
397
|
+
<p class="meta">Store: ${escapeHtml(storeFile)}</p>
|
|
398
|
+
<div class="cards">
|
|
399
|
+
<div class="card"><div class="label">Plan Phase</div><div class="value"><span class="pill ${summary.phase}">${summary.phase.toUpperCase()}</span></div></div>
|
|
400
|
+
<div class="card"><div class="label">Users</div><div class="value">${summary.usersRegistered}</div></div>
|
|
401
|
+
<div class="card"><div class="label">Active Workspaces</div><div class="value">${summary.workspacesActive}</div></div>
|
|
402
|
+
<div class="card"><div class="label">Active Repos</div><div class="value">${summary.reposActive}</div></div>
|
|
403
|
+
<div class="card"><div class="label">Snapshots</div><div class="value">${summary.totalSnapshots}</div></div>
|
|
404
|
+
<div class="card"><div class="label">Free Seats Left</div><div class="value">${summary.freeUsersRemaining}</div></div>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
<section class="section">
|
|
408
|
+
<h2>Runs Per Month</h2>
|
|
409
|
+
<table>
|
|
410
|
+
<thead><tr><th>Month</th><th>Runs</th><th>Trend</th></tr></thead>
|
|
411
|
+
<tbody>${runsRows || '<tr><td colspan="3">No runs yet</td></tr>'}</tbody>
|
|
412
|
+
</table>
|
|
413
|
+
</section>
|
|
414
|
+
|
|
415
|
+
<section class="section">
|
|
416
|
+
<h2>Workspace Hotspots</h2>
|
|
417
|
+
<table>
|
|
418
|
+
<thead><tr><th>Workspace</th><th>Runs</th><th>Avg Score</th><th>Last Run</th></tr></thead>
|
|
419
|
+
<tbody>${workspaceRows || '<tr><td colspan="4">No workspace data</td></tr>'}</tbody>
|
|
420
|
+
</table>
|
|
421
|
+
</section>
|
|
422
|
+
|
|
423
|
+
<section class="section">
|
|
424
|
+
<h2>Repo Hotspots</h2>
|
|
425
|
+
<table>
|
|
426
|
+
<thead><tr><th>Workspace</th><th>Repo</th><th>Runs</th><th>Avg Score</th></tr></thead>
|
|
427
|
+
<tbody>${repoRows || '<tr><td colspan="4">No repo data</td></tr>'}</tbody>
|
|
428
|
+
</table>
|
|
429
|
+
</section>
|
|
430
|
+
</main>
|
|
431
|
+
</body>
|
|
432
|
+
</html>`
|
|
433
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -132,6 +132,12 @@ export interface DriftConfig {
|
|
|
132
132
|
serviceNoHttp?: boolean
|
|
133
133
|
maxFunctionLines?: number
|
|
134
134
|
}
|
|
135
|
+
saas?: {
|
|
136
|
+
freeUserThreshold?: number
|
|
137
|
+
maxRunsPerWorkspacePerMonth?: number
|
|
138
|
+
maxReposPerWorkspace?: number
|
|
139
|
+
retentionDays?: number
|
|
140
|
+
}
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
export interface PluginRuleContext {
|
|
@@ -125,6 +125,33 @@ describe('new feature MVP', () => {
|
|
|
125
125
|
expect(svg).toContain('domain')
|
|
126
126
|
})
|
|
127
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
|
+
|
|
128
155
|
it('falls back safely when plugin cannot be loaded', () => {
|
|
129
156
|
tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-fallback-'))
|
|
130
157
|
writeFileSync(join(tmpDir, 'index.ts'), 'export const x = 1\n')
|