@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
@@ -1,3 +1,5 @@
1
+ // drift-ignore-file
2
+
1
3
  import * as crypto from 'node:crypto'
2
4
  import {
3
5
  SourceFile,
@@ -17,22 +19,18 @@ export type FunctionLikeNode = FunctionDeclaration | ArrowFunction | FunctionExp
17
19
  * with canonical tokens so that two functions with identical logic but
18
20
  * different identifiers produce the same fingerprint.
19
21
  */
20
- export function normalizeFunctionBody(fn: FunctionLikeNode): string {
21
- // Build a substitution map: localName → canonical token
22
+ function buildSubstitutionMap(fn: FunctionLikeNode): Map<string, string> {
22
23
  const subst = new Map<string, string>()
23
24
 
24
- // Map parameters first
25
25
  for (const [i, param] of fn.getParameters().entries()) {
26
26
  const name = param.getName()
27
27
  if (name && name !== '_') subst.set(name, `P${i}`)
28
28
  }
29
29
 
30
- // Map locally declared variables (VariableDeclaration)
31
30
  let varIdx = 0
32
31
  fn.forEachDescendant(node => {
33
32
  if (node.getKind() === SyntaxKind.VariableDeclaration) {
34
33
  const nameNode = (node as import('ts-morph').VariableDeclaration).getNameNode()
35
- // Support destructuring — getNameNode() may be a BindingPattern
36
34
  if (nameNode.getKind() === SyntaxKind.Identifier) {
37
35
  const name = nameNode.getText()
38
36
  if (!subst.has(name)) subst.set(name, `V${varIdx++}`)
@@ -40,37 +38,43 @@ export function normalizeFunctionBody(fn: FunctionLikeNode): string {
40
38
  }
41
39
  })
42
40
 
43
- function serializeNode(node: Node): string {
44
- const kind = node.getKindName()
41
+ return subst
42
+ }
45
43
 
46
- switch (node.getKind()) {
47
- case SyntaxKind.Identifier: {
48
- const text = node.getText()
49
- return subst.get(text) ?? text // external refs (Math, console) kept as-is
50
- }
51
- case SyntaxKind.NumericLiteral:
52
- return 'NL'
53
- case SyntaxKind.StringLiteral:
54
- case SyntaxKind.NoSubstitutionTemplateLiteral:
55
- return 'SL'
56
- case SyntaxKind.TrueKeyword:
57
- return 'TRUE'
58
- case SyntaxKind.FalseKeyword:
59
- return 'FALSE'
60
- case SyntaxKind.NullKeyword:
61
- return 'NULL'
44
+ function serializeNode(node: Node, subst: Map<string, string>): string {
45
+ const kind = node.getKindName()
46
+
47
+ switch (node.getKind()) {
48
+ case SyntaxKind.Identifier: {
49
+ const text = node.getText()
50
+ return subst.get(text) ?? text
62
51
  }
52
+ case SyntaxKind.NumericLiteral:
53
+ return 'NL'
54
+ case SyntaxKind.StringLiteral:
55
+ case SyntaxKind.NoSubstitutionTemplateLiteral:
56
+ return 'SL'
57
+ case SyntaxKind.TrueKeyword:
58
+ return 'TRUE'
59
+ case SyntaxKind.FalseKeyword:
60
+ return 'FALSE'
61
+ case SyntaxKind.NullKeyword:
62
+ return 'NULL'
63
+ }
63
64
 
64
- const children = node.getChildren()
65
- if (children.length === 0) return kind
65
+ const children = node.getChildren()
66
+ if (children.length === 0) return kind
66
67
 
67
- const childStr = children.map(serializeNode).join('|')
68
- return `${kind}(${childStr})`
69
- }
68
+ const childStr = children.map(c => serializeNode(c, subst)).join('|')
69
+ return `${kind}(${childStr})`
70
+ }
71
+
72
+ export function normalizeFunctionBody(fn: FunctionLikeNode): string {
73
+ const subst = buildSubstitutionMap(fn)
70
74
 
71
75
  const body = fn.getBody()
72
76
  if (!body) return ''
73
- return serializeNode(body)
77
+ return serializeNode(body, subst)
74
78
  }
75
79
 
76
80
  /** Return a SHA-256 fingerprint for a function body (normalized). */
@@ -0,0 +1,29 @@
1
+ import { SourceFile, SyntaxKind } from 'ts-morph'
2
+ import type { DriftIssue } from '../types.js'
3
+
4
+ export function detectPromiseStyleMix(file: SourceFile): DriftIssue[] {
5
+ const text = file.getFullText()
6
+
7
+ const hasThen = file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).some((node) => {
8
+ const name = node.getName()
9
+ return name === 'then' || name === 'catch'
10
+ })
11
+
12
+ const hasAsync =
13
+ file.getDescendantsOfKind(SyntaxKind.AsyncKeyword).length > 0 ||
14
+ /\bawait\b/.test(text)
15
+
16
+ if (hasThen && hasAsync) {
17
+ return [
18
+ {
19
+ rule: 'promise-style-mix',
20
+ severity: 'warning',
21
+ message: `File mixes async/await with .then()/.catch(). AI generates both styles without picking one.`,
22
+ line: 1,
23
+ column: 1,
24
+ snippet: `// mixed promise styles detected`,
25
+ },
26
+ ]
27
+ }
28
+ return []
29
+ }
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
+ }