@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/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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 rel = relative(targetPath, file.getFilePath()).replace(/\\/g, '/')
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 = [...edges.entries()].map(([key, count]) => {
77
- const [from, to] = key.split('->')
78
- const a = boxByName.get(from)
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="#64748b" stroke-width="2" marker-end="url(#arrow)" />
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
- export function generateArchitectureMap(targetPath: string, outputFile = 'architecture.svg'): string {
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('&', '&amp;')
307
+ .replaceAll('<', '&lt;')
308
+ .replaceAll('>', '&gt;')
309
+ .replaceAll('"', '&quot;')
310
+ .replaceAll("'", '&#39;')
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')