@eduardbar/drift 1.2.0 → 1.4.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 (195) hide show
  1. package/.gga +50 -0
  2. package/.github/actions/drift-review/README.md +60 -0
  3. package/.github/actions/drift-review/action.yml +131 -0
  4. package/.github/actions/drift-scan/README.md +28 -32
  5. package/.github/actions/drift-scan/action.yml +78 -14
  6. package/.github/workflows/publish-vscode.yml +3 -3
  7. package/.github/workflows/publish.yml +3 -3
  8. package/.github/workflows/review-pr.yml +94 -9
  9. package/AGENTS.md +75 -245
  10. package/CHANGELOG.md +28 -0
  11. package/README.md +308 -51
  12. package/ROADMAP.md +6 -5
  13. package/dist/analyzer.d.ts +2 -2
  14. package/dist/analyzer.js +420 -159
  15. package/dist/benchmark.d.ts +2 -0
  16. package/dist/benchmark.js +204 -0
  17. package/dist/cli.js +693 -67
  18. package/dist/config.js +16 -2
  19. package/dist/diff.js +66 -10
  20. package/dist/doctor.d.ts +5 -0
  21. package/dist/doctor.js +133 -0
  22. package/dist/format.d.ts +17 -0
  23. package/dist/format.js +45 -0
  24. package/dist/git.js +12 -0
  25. package/dist/guard-types.d.ts +57 -0
  26. package/dist/guard-types.js +2 -0
  27. package/dist/guard.d.ts +14 -0
  28. package/dist/guard.js +239 -0
  29. package/dist/index.d.ts +12 -3
  30. package/dist/index.js +6 -1
  31. package/dist/init.d.ts +15 -0
  32. package/dist/init.js +273 -0
  33. package/dist/map-cycles.d.ts +2 -0
  34. package/dist/map-cycles.js +34 -0
  35. package/dist/map-svg.d.ts +19 -0
  36. package/dist/map-svg.js +97 -0
  37. package/dist/map.js +78 -138
  38. package/dist/metrics.js +70 -55
  39. package/dist/output-metadata.d.ts +13 -0
  40. package/dist/output-metadata.js +17 -0
  41. package/dist/plugins-capabilities.d.ts +4 -0
  42. package/dist/plugins-capabilities.js +21 -0
  43. package/dist/plugins-messages.d.ts +10 -0
  44. package/dist/plugins-messages.js +16 -0
  45. package/dist/plugins-rules.d.ts +9 -0
  46. package/dist/plugins-rules.js +137 -0
  47. package/dist/plugins.d.ts +2 -1
  48. package/dist/plugins.js +80 -28
  49. package/dist/printer.js +4 -0
  50. package/dist/reporter-constants.d.ts +16 -0
  51. package/dist/reporter-constants.js +39 -0
  52. package/dist/reporter.d.ts +3 -3
  53. package/dist/reporter.js +35 -55
  54. package/dist/review.d.ts +2 -1
  55. package/dist/review.js +4 -3
  56. package/dist/rules/comments.js +2 -2
  57. package/dist/rules/complexity.js +2 -7
  58. package/dist/rules/nesting.js +3 -13
  59. package/dist/rules/phase0-basic.js +10 -10
  60. package/dist/rules/phase3-configurable.js +23 -15
  61. package/dist/rules/shared.d.ts +2 -0
  62. package/dist/rules/shared.js +27 -3
  63. package/dist/saas/constants.d.ts +15 -0
  64. package/dist/saas/constants.js +48 -0
  65. package/dist/saas/dashboard.d.ts +8 -0
  66. package/dist/saas/dashboard.js +132 -0
  67. package/dist/saas/errors.d.ts +19 -0
  68. package/dist/saas/errors.js +37 -0
  69. package/dist/saas/helpers.d.ts +21 -0
  70. package/dist/saas/helpers.js +110 -0
  71. package/dist/saas/ingest.d.ts +3 -0
  72. package/dist/saas/ingest.js +249 -0
  73. package/dist/saas/organization.d.ts +5 -0
  74. package/dist/saas/organization.js +82 -0
  75. package/dist/saas/plan-change.d.ts +10 -0
  76. package/dist/saas/plan-change.js +15 -0
  77. package/dist/saas/store.d.ts +21 -0
  78. package/dist/saas/store.js +159 -0
  79. package/dist/saas/types.d.ts +191 -0
  80. package/dist/saas/types.js +2 -0
  81. package/dist/saas.d.ts +8 -82
  82. package/dist/saas.js +7 -320
  83. package/dist/sarif.d.ts +74 -0
  84. package/dist/sarif.js +122 -0
  85. package/dist/trust-advanced.d.ts +14 -0
  86. package/dist/trust-advanced.js +65 -0
  87. package/dist/trust-kpi-fs.d.ts +3 -0
  88. package/dist/trust-kpi-fs.js +141 -0
  89. package/dist/trust-kpi-parse.d.ts +7 -0
  90. package/dist/trust-kpi-parse.js +186 -0
  91. package/dist/trust-kpi-types.d.ts +16 -0
  92. package/dist/trust-kpi-types.js +2 -0
  93. package/dist/trust-kpi.d.ts +7 -0
  94. package/dist/trust-kpi.js +185 -0
  95. package/dist/trust-policy.d.ts +32 -0
  96. package/dist/trust-policy.js +160 -0
  97. package/dist/trust-render.d.ts +9 -0
  98. package/dist/trust-render.js +54 -0
  99. package/dist/trust-scoring.d.ts +9 -0
  100. package/dist/trust-scoring.js +208 -0
  101. package/dist/trust.d.ts +37 -0
  102. package/dist/trust.js +168 -0
  103. package/dist/types/app.d.ts +30 -0
  104. package/dist/types/app.js +2 -0
  105. package/dist/types/config.d.ts +25 -0
  106. package/dist/types/config.js +2 -0
  107. package/dist/types/core.d.ts +100 -0
  108. package/dist/types/core.js +2 -0
  109. package/dist/types/diff.d.ts +55 -0
  110. package/dist/types/diff.js +2 -0
  111. package/dist/types/plugin.d.ts +41 -0
  112. package/dist/types/plugin.js +2 -0
  113. package/dist/types/trust.d.ts +120 -0
  114. package/dist/types/trust.js +2 -0
  115. package/dist/types.d.ts +8 -211
  116. package/docs/PRD.md +187 -109
  117. package/docs/plugin-contract.md +61 -0
  118. package/docs/release-notes-draft.md +40 -0
  119. package/docs/rules-catalog.md +49 -0
  120. package/docs/trust-core-release-checklist.md +87 -0
  121. package/package.json +6 -3
  122. package/packages/vscode-drift/src/code-actions.ts +1 -1
  123. package/schemas/drift-ai-output.v1.json +162 -0
  124. package/schemas/drift-report.v1.json +151 -0
  125. package/schemas/drift-trust.v1.json +131 -0
  126. package/scripts/smoke-repo.mjs +394 -0
  127. package/src/analyzer.ts +484 -155
  128. package/src/benchmark.ts +266 -0
  129. package/src/cli.ts +840 -85
  130. package/src/config.ts +19 -2
  131. package/src/diff.ts +84 -10
  132. package/src/doctor.ts +173 -0
  133. package/src/format.ts +81 -0
  134. package/src/git.ts +16 -0
  135. package/src/guard-types.ts +64 -0
  136. package/src/guard.ts +324 -0
  137. package/src/index.ts +83 -0
  138. package/src/init.ts +298 -0
  139. package/src/map-cycles.ts +38 -0
  140. package/src/map-svg.ts +124 -0
  141. package/src/map.ts +111 -142
  142. package/src/metrics.ts +78 -59
  143. package/src/output-metadata.ts +30 -0
  144. package/src/plugins-capabilities.ts +36 -0
  145. package/src/plugins-messages.ts +35 -0
  146. package/src/plugins-rules.ts +296 -0
  147. package/src/plugins.ts +148 -27
  148. package/src/printer.ts +4 -0
  149. package/src/reporter-constants.ts +46 -0
  150. package/src/reporter.ts +64 -65
  151. package/src/review.ts +6 -4
  152. package/src/rules/comments.ts +2 -2
  153. package/src/rules/complexity.ts +2 -7
  154. package/src/rules/nesting.ts +3 -13
  155. package/src/rules/phase0-basic.ts +11 -12
  156. package/src/rules/phase3-configurable.ts +39 -26
  157. package/src/rules/shared.ts +31 -3
  158. package/src/saas/constants.ts +56 -0
  159. package/src/saas/dashboard.ts +172 -0
  160. package/src/saas/errors.ts +45 -0
  161. package/src/saas/helpers.ts +140 -0
  162. package/src/saas/ingest.ts +278 -0
  163. package/src/saas/organization.ts +99 -0
  164. package/src/saas/plan-change.ts +19 -0
  165. package/src/saas/store.ts +172 -0
  166. package/src/saas/types.ts +216 -0
  167. package/src/saas.ts +49 -433
  168. package/src/sarif.ts +232 -0
  169. package/src/trust-advanced.ts +99 -0
  170. package/src/trust-kpi-fs.ts +169 -0
  171. package/src/trust-kpi-parse.ts +219 -0
  172. package/src/trust-kpi-types.ts +19 -0
  173. package/src/trust-kpi.ts +210 -0
  174. package/src/trust-policy.ts +246 -0
  175. package/src/trust-render.ts +61 -0
  176. package/src/trust-scoring.ts +231 -0
  177. package/src/trust.ts +260 -0
  178. package/src/types/app.ts +30 -0
  179. package/src/types/config.ts +27 -0
  180. package/src/types/core.ts +105 -0
  181. package/src/types/diff.ts +61 -0
  182. package/src/types/plugin.ts +46 -0
  183. package/src/types/trust.ts +134 -0
  184. package/src/types.ts +78 -238
  185. package/tests/cli-sarif.test.ts +92 -0
  186. package/tests/diff.test.ts +124 -0
  187. package/tests/format.test.ts +157 -0
  188. package/tests/new-features.test.ts +80 -1
  189. package/tests/phase1-init-doctor-guard.test.ts +199 -0
  190. package/tests/plugins.test.ts +219 -0
  191. package/tests/rules.test.ts +23 -1
  192. package/tests/saas-foundation.test.ts +358 -1
  193. package/tests/sarif.test.ts +160 -0
  194. package/tests/trust-kpi.test.ts +147 -0
  195. package/tests/trust.test.ts +602 -0
@@ -0,0 +1,172 @@
1
+ import { resolve } from 'node:path'
2
+ import type { SaasPolicyOverrides, SaasQueryOptions, SaasSnapshot, SaasSummary } from './types.js'
3
+ import { DASHBOARD_BAR_MIN_WIDTH, DASHBOARD_BAR_UNIT, DASHBOARD_REPO_LIMIT } from './constants.js'
4
+ import { assertPermissionInStore, defaultSaasStorePath, loadStoreInternal, saveStore } from './store.js'
5
+ import {
6
+ DEFAULT_ORGANIZATION_ID,
7
+ computeRunsPerMonth,
8
+ computeUsersRegistered,
9
+ escapeHtml,
10
+ isRepoActive,
11
+ isWorkspaceActive,
12
+ matchesRepoScope,
13
+ matchesTenantScope,
14
+ matchesWorkspaceScope,
15
+ } from './helpers.js'
16
+
17
+ function assertSummaryReadPermission(store: ReturnType<typeof loadStoreInternal>, options?: SaasQueryOptions): void {
18
+ const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId)
19
+ if (!options?.actorUserId && !shouldEnforceActorForScope) return
20
+
21
+ const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID
22
+ assertPermissionInStore(store, {
23
+ operation: 'summary:read',
24
+ organizationId,
25
+ workspaceId: options?.workspaceId,
26
+ actorUserId: options?.actorUserId,
27
+ })
28
+ }
29
+
30
+ function buildWorkspaceStats(store: ReturnType<typeof loadStoreInternal>): Array<{
31
+ organizationId: string
32
+ id: string
33
+ runs: number
34
+ avgScore: number
35
+ lastRun: string
36
+ }> {
37
+ return Object.values(store.workspaces)
38
+ .map((workspace) => {
39
+ const snapshots = store.snapshots.filter((snapshot) => snapshot.organizationId === workspace.organizationId && snapshot.workspaceId === workspace.id)
40
+ const runs = snapshots.length
41
+ const avgScore = runs === 0 ? 0 : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs)
42
+ const lastRun = snapshots.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]?.createdAt ?? 'n/a'
43
+ return {
44
+ organizationId: workspace.organizationId,
45
+ id: workspace.id,
46
+ runs,
47
+ avgScore,
48
+ lastRun,
49
+ }
50
+ })
51
+ .sort((a, b) => b.avgScore - a.avgScore)
52
+ }
53
+
54
+ function buildRepoStats(store: ReturnType<typeof loadStoreInternal>): Array<{ workspaceId: string; name: string; runs: number; avgScore: number }> {
55
+ return Object.values(store.repos)
56
+ .map((repo) => {
57
+ const snapshots = store.snapshots.filter((snapshot) => snapshot.repoId === repo.id)
58
+ const runs = snapshots.length
59
+ const avgScore = runs === 0 ? 0 : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs)
60
+ return {
61
+ workspaceId: repo.workspaceId,
62
+ name: repo.name,
63
+ runs,
64
+ avgScore,
65
+ }
66
+ })
67
+ .sort((a, b) => b.avgScore - a.avgScore)
68
+ .slice(0, DASHBOARD_REPO_LIMIT)
69
+ }
70
+
71
+ function buildRunsRows(summary: SaasSummary): string {
72
+ return Object.entries(summary.runsPerMonth)
73
+ .sort(([a], [b]) => a.localeCompare(b))
74
+ .map(([month, count]) => {
75
+ const width = Math.max(DASHBOARD_BAR_MIN_WIDTH, count * DASHBOARD_BAR_UNIT)
76
+ return `<tr><td>${escapeHtml(month)}</td><td>${count}</td><td><div class="bar" style="width:${width}px"></div></td></tr>`
77
+ })
78
+ .join('')
79
+ }
80
+
81
+ function buildWorkspaceRows(workspaceStats: Array<{ organizationId: string; id: string; runs: number; avgScore: number; lastRun: string }>): string {
82
+ return workspaceStats
83
+ .map((workspace) => `<tr><td>${escapeHtml(workspace.organizationId)}</td><td>${escapeHtml(workspace.id)}</td><td>${workspace.runs}</td><td>${workspace.avgScore}</td><td>${escapeHtml(workspace.lastRun)}</td></tr>`)
84
+ .join('')
85
+ }
86
+
87
+ function buildRepoRows(repoStats: Array<{ workspaceId: string; name: string; runs: number; avgScore: number }>): string {
88
+ return repoStats
89
+ .map((repo) => `<tr><td>${escapeHtml(repo.workspaceId)}</td><td>${escapeHtml(repo.name)}</td><td>${repo.runs}</td><td>${repo.avgScore}</td></tr>`)
90
+ .join('')
91
+ }
92
+
93
+ function renderDashboardHtmlDocument(input: {
94
+ storeFile: string
95
+ summary: SaasSummary
96
+ runsRows: string
97
+ workspaceRows: string
98
+ repoRows: string
99
+ }): string {
100
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>drift cloud dashboard</title><style>:root { color-scheme: light; } body { margin: 0; font-family: "Segoe UI", Arial, sans-serif; background: #f4f7fb; color: #0f172a; } main { max-width: 980px; margin: 0 auto; padding: 24px; } h1 { margin: 0 0 6px; } p.meta { margin: 0 0 20px; color: #475569; } .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; } .card { background: #ffffff; border-radius: 10px; padding: 14px; border: 1px solid #dbe3ef; } .card .label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.08em; } .card .value { font-size: 26px; font-weight: 700; margin-top: 4px; } table { width: 100%; border-collapse: collapse; margin-top: 10px; background: #ffffff; border: 1px solid #dbe3ef; border-radius: 10px; overflow: hidden; } th, td { padding: 10px; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 14px; } th { background: #eef2f9; } .section { margin-top: 18px; } .bar { height: 10px; background: linear-gradient(90deg, #0ea5e9, #22c55e); border-radius: 999px; } .pill { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; font-weight: 600; } .pill.free { background: #dcfce7; color: #166534; } .pill.paid { background: #fee2e2; color: #991b1b; }</style></head><body><main><h1>drift cloud dashboard</h1><p class="meta">Store: ${escapeHtml(input.storeFile)}</p><div class="cards"><div class="card"><div class="label">Plan Phase</div><div class="value"><span class="pill ${input.summary.phase}">${input.summary.phase.toUpperCase()}</span></div></div><div class="card"><div class="label">Users</div><div class="value">${input.summary.usersRegistered}</div></div><div class="card"><div class="label">Active Workspaces</div><div class="value">${input.summary.workspacesActive}</div></div><div class="card"><div class="label">Active Repos</div><div class="value">${input.summary.reposActive}</div></div><div class="card"><div class="label">Snapshots</div><div class="value">${input.summary.totalSnapshots}</div></div><div class="card"><div class="label">Free Seats Left</div><div class="value">${input.summary.freeUsersRemaining}</div></div></div><section class="section"><h2>Runs Per Month</h2><table><thead><tr><th>Month</th><th>Runs</th><th>Trend</th></tr></thead><tbody>${input.runsRows || '<tr><td colspan="3">No runs yet</td></tr>'}</tbody></table></section><section class="section"><h2>Workspace Hotspots</h2><table><thead><tr><th>Organization</th><th>Workspace</th><th>Runs</th><th>Avg Score</th><th>Last Run</th></tr></thead><tbody>${input.workspaceRows || '<tr><td colspan="5">No workspace data</td></tr>'}</tbody></table></section><section class="section"><h2>Repo Hotspots</h2><table><thead><tr><th>Workspace</th><th>Repo</th><th>Runs</th><th>Avg Score</th></tr></thead><tbody>${input.repoRows || '<tr><td colspan="4">No repo data</td></tr>'}</tbody></table></section></main></body></html>`
101
+ }
102
+
103
+ export function listSaasSnapshots(options?: SaasQueryOptions): SaasSnapshot[] {
104
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
105
+ const store = loadStoreInternal(storeFile, options?.policy)
106
+
107
+ const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId)
108
+ if (options?.actorUserId || shouldEnforceActorForScope) {
109
+ const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID
110
+ assertPermissionInStore(store, {
111
+ operation: 'snapshot:read',
112
+ organizationId,
113
+ workspaceId: options?.workspaceId,
114
+ actorUserId: options?.actorUserId,
115
+ })
116
+ }
117
+
118
+ saveStore(storeFile, store)
119
+ return store.snapshots
120
+ .filter((snapshot) => matchesTenantScope(snapshot, options))
121
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
122
+ }
123
+
124
+ export function getSaasSummary(options?: SaasQueryOptions): SaasSummary {
125
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
126
+ const store = loadStoreInternal(storeFile, options?.policy)
127
+
128
+ assertSummaryReadPermission(store, options)
129
+ saveStore(storeFile, store)
130
+
131
+ const scopedSnapshots = store.snapshots.filter((snapshot) => matchesTenantScope(snapshot, options))
132
+ const scopedWorkspaces = Object.values(store.workspaces).filter((workspace) => matchesWorkspaceScope(workspace, options))
133
+ const scopedRepos = Object.values(store.repos).filter((repo) => matchesRepoScope(repo, options))
134
+
135
+ const usersRegistered = computeUsersRegistered(store, scopedSnapshots, options)
136
+ const workspacesActive = scopedWorkspaces.filter((workspace) => isWorkspaceActive(workspace)).length
137
+ const reposActive = scopedRepos.filter((repo) => isRepoActive(repo)).length
138
+ const runsPerMonth = computeRunsPerMonth(scopedSnapshots)
139
+ const thresholdReached = usersRegistered >= store.policy.freeUserThreshold
140
+
141
+ return {
142
+ policy: store.policy,
143
+ usersRegistered,
144
+ workspacesActive,
145
+ reposActive,
146
+ runsPerMonth,
147
+ totalSnapshots: scopedSnapshots.length,
148
+ phase: thresholdReached ? 'paid' : 'free',
149
+ thresholdReached,
150
+ freeUsersRemaining: Math.max(0, store.policy.freeUserThreshold - usersRegistered),
151
+ }
152
+ }
153
+
154
+ export function generateSaasDashboardHtml(options?: { storeFile?: string; policy?: SaasPolicyOverrides }): string {
155
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath())
156
+ const store = loadStoreInternal(storeFile, options?.policy)
157
+ const summary = getSaasSummary(options)
158
+
159
+ const workspaceStats = buildWorkspaceStats(store)
160
+ const repoStats = buildRepoStats(store)
161
+ const runsRows = buildRunsRows(summary)
162
+ const workspaceRows = buildWorkspaceRows(workspaceStats)
163
+ const repoRows = buildRepoRows(repoStats)
164
+
165
+ return renderDashboardHtmlDocument({
166
+ storeFile,
167
+ summary,
168
+ runsRows,
169
+ workspaceRows,
170
+ repoRows,
171
+ })
172
+ }
@@ -0,0 +1,45 @@
1
+ import type { SaasOperation, SaasPermissionContext, SaasRole } from './types.js'
2
+
3
+ export class SaasPermissionError extends Error {
4
+ readonly code = 'SAAS_PERMISSION_DENIED'
5
+ readonly operation: SaasOperation
6
+ readonly organizationId: string
7
+ readonly workspaceId?: string
8
+ readonly actorUserId?: string
9
+ readonly requiredRole: SaasRole
10
+ readonly actorRole?: SaasRole
11
+
12
+ constructor(context: SaasPermissionContext, requiredRole: SaasRole, actorRole?: SaasRole) {
13
+ const actor = context.actorUserId ?? 'unknown-actor'
14
+ const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : ''
15
+ const actualRole = actorRole ?? 'none'
16
+ super(
17
+ `Permission denied for operation '${context.operation}'. actor='${actor}' organization='${context.organizationId}'${workspaceSuffix} requiredRole='${requiredRole}' actualRole='${actualRole}'.`,
18
+ )
19
+ this.name = 'SaasPermissionError'
20
+ this.operation = context.operation
21
+ this.organizationId = context.organizationId
22
+ this.workspaceId = context.workspaceId
23
+ this.actorUserId = context.actorUserId
24
+ this.requiredRole = requiredRole
25
+ this.actorRole = actorRole
26
+ }
27
+ }
28
+
29
+ export class SaasActorRequiredError extends Error {
30
+ readonly code = 'SAAS_ACTOR_REQUIRED'
31
+ readonly operation: SaasOperation
32
+ readonly organizationId: string
33
+ readonly workspaceId?: string
34
+
35
+ constructor(context: SaasPermissionContext) {
36
+ const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : ''
37
+ super(
38
+ `Actor is required for operation '${context.operation}'. organization='${context.organizationId}'${workspaceSuffix}.`,
39
+ )
40
+ this.name = 'SaasActorRequiredError'
41
+ this.operation = context.operation
42
+ this.organizationId = context.organizationId
43
+ this.workspaceId = context.workspaceId
44
+ }
45
+ }
@@ -0,0 +1,140 @@
1
+ import type {
2
+ IngestOptions,
3
+ SaasPlan,
4
+ SaasPolicy,
5
+ SaasPolicyInput,
6
+ SaasPolicyOverrides,
7
+ SaasQueryOptions,
8
+ SaasRepo,
9
+ SaasRole,
10
+ SaasSnapshot,
11
+ SaasStore,
12
+ SaasWorkspace,
13
+ ScopedIdentity,
14
+ } from './types.js'
15
+ import {
16
+ ACTIVE_WINDOW_DAYS,
17
+ DEFAULT_ORGANIZATION_ID,
18
+ DEFAULT_SAAS_POLICY,
19
+ ROLE_PRIORITY,
20
+ VALID_PLANS,
21
+ VALID_ROLES,
22
+ daysAgo,
23
+ } from './constants.js'
24
+
25
+ export function resolveSaasPolicy(policy?: SaasPolicyInput): SaasPolicy {
26
+ const customPlanLimits = (policy && 'maxWorkspacesPerOrganizationByPlan' in policy)
27
+ ? (policy.maxWorkspacesPerOrganizationByPlan ?? {})
28
+ : {}
29
+
30
+ return {
31
+ ...DEFAULT_SAAS_POLICY,
32
+ ...(policy ?? {}),
33
+ maxWorkspacesPerOrganizationByPlan: {
34
+ ...DEFAULT_SAAS_POLICY.maxWorkspacesPerOrganizationByPlan,
35
+ ...customPlanLimits,
36
+ },
37
+ }
38
+ }
39
+
40
+ export function normalizePlan(plan?: string): SaasPlan {
41
+ if (!plan) return 'free'
42
+ return VALID_PLANS.includes(plan as SaasPlan) ? (plan as SaasPlan) : 'free'
43
+ }
44
+
45
+ export function normalizeRole(role?: string): SaasRole {
46
+ if (!role) return 'member'
47
+ return VALID_ROLES.includes(role as SaasRole) ? (role as SaasRole) : 'member'
48
+ }
49
+
50
+ export function hasRoleAtLeast(role: SaasRole | undefined, requiredRole: SaasRole): boolean {
51
+ if (!role) return false
52
+ return ROLE_PRIORITY[role] >= ROLE_PRIORITY[requiredRole]
53
+ }
54
+
55
+ export function workspaceKey(organizationId: string, workspaceId: string): string {
56
+ return `${organizationId}:${workspaceId}`
57
+ }
58
+
59
+ function repoKey(organizationId: string, workspaceId: string, repoName: string): string {
60
+ return `${workspaceKey(organizationId, workspaceId)}:${repoName}`
61
+ }
62
+
63
+ export function membershipKey(organizationId: string, workspaceId: string, userId: string): string {
64
+ return `${workspaceKey(organizationId, workspaceId)}:${userId}`
65
+ }
66
+
67
+ export function monthKey(isoDate: string): string {
68
+ const date = new Date(isoDate)
69
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0')
70
+ return `${date.getUTCFullYear()}-${month}`
71
+ }
72
+
73
+ export function resolveScopedIdentity(options: IngestOptions): ScopedIdentity {
74
+ const organizationId = options.organizationId ?? DEFAULT_ORGANIZATION_ID
75
+ const workspaceId = options.workspaceId
76
+ const repoName = options.repoName ?? 'default'
77
+ return {
78
+ organizationId,
79
+ workspaceId,
80
+ workspaceKey: workspaceKey(organizationId, workspaceId),
81
+ repoName,
82
+ repoId: repoKey(organizationId, workspaceId, repoName),
83
+ }
84
+ }
85
+
86
+ export function isWorkspaceActive(workspace: SaasWorkspace): boolean {
87
+ return new Date(workspace.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS)
88
+ }
89
+
90
+ export function isRepoActive(repo: SaasRepo): boolean {
91
+ return new Date(repo.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS)
92
+ }
93
+
94
+ export function matchesTenantScope(snapshot: SaasSnapshot, options?: SaasQueryOptions): boolean {
95
+ if (!options?.organizationId && !options?.workspaceId) return true
96
+ if (options.organizationId && snapshot.organizationId !== options.organizationId) return false
97
+ if (options.workspaceId && snapshot.workspaceId !== options.workspaceId) return false
98
+ return true
99
+ }
100
+
101
+ export function matchesWorkspaceScope(workspace: SaasWorkspace, options?: SaasQueryOptions): boolean {
102
+ if (options?.organizationId && workspace.organizationId !== options.organizationId) return false
103
+ if (options?.workspaceId && workspace.id !== options.workspaceId) return false
104
+ return true
105
+ }
106
+
107
+ export function matchesRepoScope(repo: SaasRepo, options?: SaasQueryOptions): boolean {
108
+ if (options?.organizationId && repo.organizationId !== options.organizationId) return false
109
+ if (options?.workspaceId && repo.workspaceId !== options.workspaceId) return false
110
+ return true
111
+ }
112
+
113
+ export function computeRunsPerMonth(snapshots: SaasSnapshot[]): Record<string, number> {
114
+ const runsPerMonth: Record<string, number> = {}
115
+ for (const snapshot of snapshots) {
116
+ const key = monthKey(snapshot.createdAt)
117
+ runsPerMonth[key] = (runsPerMonth[key] ?? 0) + 1
118
+ }
119
+ return runsPerMonth
120
+ }
121
+
122
+ export function computeUsersRegistered(store: SaasStore, snapshots: SaasSnapshot[], options?: SaasQueryOptions): number {
123
+ if (!options?.organizationId && !options?.workspaceId) return Object.keys(store.users).length
124
+ return new Set(snapshots.map((snapshot) => snapshot.userId)).size
125
+ }
126
+
127
+ export function escapeHtml(value: string): string {
128
+ return value
129
+ .replaceAll('&', '&amp;')
130
+ .replaceAll('<', '&lt;')
131
+ .replaceAll('>', '&gt;')
132
+ .replaceAll('"', '&quot;')
133
+ .replaceAll("'", '&#39;')
134
+ }
135
+
136
+ export function mergePolicy(policy: SaasPolicyOverrides | undefined, base: SaasStore['policy']): SaasPolicy {
137
+ return resolveSaasPolicy({ ...base, ...(policy ?? {}) })
138
+ }
139
+
140
+ export { DEFAULT_ORGANIZATION_ID }
@@ -0,0 +1,278 @@
1
+ import { resolve } from 'node:path'
2
+ import type { DriftReportInput, IngestMutationContext, IngestOptions, SaasPlan, SaasRole, SaasSnapshot, SaasStore } from './types.js'
3
+ import { createRandomId } from './constants.js'
4
+ import { SaasActorRequiredError } from './errors.js'
5
+ import { applyRetentionPolicy, assertPermissionInStore, defaultSaasStorePath, loadStoreInternal, saveStore } from './store.js'
6
+ import { membershipKey, monthKey, normalizePlan, normalizeRole, resolveScopedIdentity } from './helpers.js'
7
+ import { appendPlanChange } from './plan-change.js'
8
+
9
+ function assertWorkspaceLimit(store: SaasStore, scoped: IngestMutationContext['scoped'], effectivePlan: SaasPlan): void {
10
+ const organization = store.organizations[scoped.organizationId]
11
+ const workspaceLimit = store.policy.maxWorkspacesPerOrganizationByPlan[effectivePlan]
12
+ const workspaceExists = Boolean(store.workspaces[scoped.workspaceKey])
13
+ const workspaceCount = organization?.workspaceIds.length ?? 0
14
+
15
+ if (!workspaceExists && workspaceCount >= workspaceLimit) {
16
+ throw new Error(`Organization '${scoped.organizationId}' on plan '${effectivePlan}' reached max workspaces (${workspaceLimit}).`)
17
+ }
18
+ }
19
+
20
+ function assertFreeThresholdLimit(store: SaasStore, userId: string): void {
21
+ const usersRegistered = Object.keys(store.users).length
22
+ const isFreePhase = usersRegistered < store.policy.freeUserThreshold
23
+ if (!isFreePhase) return
24
+ if (!store.users[userId] && usersRegistered + 1 > store.policy.freeUserThreshold) {
25
+ throw new Error(`Free threshold reached (${store.policy.freeUserThreshold} users).`)
26
+ }
27
+ }
28
+
29
+ function assertRepoLimit(store: SaasStore, scoped: IngestMutationContext['scoped']): void {
30
+ const workspace = store.workspaces[scoped.workspaceKey]
31
+ const repoExists = Boolean(store.repos[scoped.repoId])
32
+ const repoCount = workspace?.repoIds.length ?? 0
33
+ if (!repoExists && repoCount >= store.policy.maxReposPerWorkspace) {
34
+ throw new Error(`Workspace '${scoped.workspaceId}' reached max repos (${store.policy.maxReposPerWorkspace}).`)
35
+ }
36
+ }
37
+
38
+ function countWorkspaceRunsThisMonth(store: SaasStore, scoped: IngestMutationContext['scoped'], currentMonth: string): number {
39
+ return store.snapshots.filter((snapshot) => {
40
+ return snapshot.organizationId === scoped.organizationId
41
+ && snapshot.workspaceId === scoped.workspaceId
42
+ && monthKey(snapshot.createdAt) === currentMonth
43
+ }).length
44
+ }
45
+
46
+ function assertGuardrails(store: SaasStore, options: IngestOptions, nowIso: string): void {
47
+ const scoped = resolveScopedIdentity(options)
48
+ const organization = store.organizations[scoped.organizationId]
49
+ const effectivePlan = normalizePlan(options.plan ?? organization?.plan)
50
+ assertWorkspaceLimit(store, scoped, effectivePlan)
51
+ assertFreeThresholdLimit(store, options.userId)
52
+ assertRepoLimit(store, scoped)
53
+
54
+ const currentMonth = monthKey(nowIso)
55
+ const runsThisMonth = countWorkspaceRunsThisMonth(store, scoped, currentMonth)
56
+ if (runsThisMonth >= store.policy.maxRunsPerWorkspacePerMonth) {
57
+ throw new Error(`Workspace '${scoped.workspaceId}' reached max monthly runs (${store.policy.maxRunsPerWorkspacePerMonth}).`)
58
+ }
59
+ }
60
+
61
+ function upsertUser(store: SaasStore, userId: string, nowIso: string): void {
62
+ const user = store.users[userId]
63
+ if (user) {
64
+ user.lastSeenAt = nowIso
65
+ return
66
+ }
67
+
68
+ store.users[userId] = {
69
+ id: userId,
70
+ createdAt: nowIso,
71
+ lastSeenAt: nowIso,
72
+ }
73
+ }
74
+
75
+ function maybeUpdateOrganizationPlanFromIngest(context: IngestMutationContext): void {
76
+ const { store, scoped, requestedPlan, options, nowIso } = context
77
+ const existingOrg = store.organizations[scoped.organizationId]
78
+ if (!existingOrg || !options.plan || existingOrg.plan === requestedPlan) return
79
+
80
+ if (options.actorUserId) {
81
+ assertPermissionInStore(store, {
82
+ operation: 'billing:write',
83
+ organizationId: scoped.organizationId,
84
+ actorUserId: options.actorUserId,
85
+ })
86
+ }
87
+
88
+ const previousPlan = existingOrg.plan
89
+ existingOrg.plan = requestedPlan
90
+ appendPlanChange(store, {
91
+ organizationId: scoped.organizationId,
92
+ fromPlan: previousPlan,
93
+ toPlan: requestedPlan,
94
+ changedAt: nowIso,
95
+ changedByUserId: options.actorUserId ?? options.userId,
96
+ reason: 'ingest-option-plan-change',
97
+ })
98
+ }
99
+
100
+ function ensureOrganizationForIngest(context: IngestMutationContext): void {
101
+ const { store, scoped, requestedPlan, nowIso } = context
102
+ const existingOrg = store.organizations[scoped.organizationId]
103
+ if (existingOrg) {
104
+ existingOrg.lastSeenAt = nowIso
105
+ maybeUpdateOrganizationPlanFromIngest(context)
106
+ return
107
+ }
108
+
109
+ store.organizations[scoped.organizationId] = {
110
+ id: scoped.organizationId,
111
+ plan: requestedPlan,
112
+ createdAt: nowIso,
113
+ lastSeenAt: nowIso,
114
+ workspaceIds: [],
115
+ }
116
+ }
117
+
118
+ function upsertWorkspaceForIngest(
119
+ store: SaasStore,
120
+ scoped: IngestMutationContext['scoped'],
121
+ userId: string,
122
+ nowIso: string,
123
+ ): { wasCreated: boolean } {
124
+ const workspace = store.workspaces[scoped.workspaceKey]
125
+ if (workspace) {
126
+ workspace.lastSeenAt = nowIso
127
+ if (!workspace.userIds.includes(userId)) workspace.userIds.push(userId)
128
+ return { wasCreated: false }
129
+ }
130
+
131
+ store.workspaces[scoped.workspaceKey] = {
132
+ id: scoped.workspaceId,
133
+ organizationId: scoped.organizationId,
134
+ createdAt: nowIso,
135
+ lastSeenAt: nowIso,
136
+ userIds: [userId],
137
+ repoIds: [],
138
+ }
139
+
140
+ const org = store.organizations[scoped.organizationId]
141
+ if (!org.workspaceIds.includes(scoped.workspaceId)) org.workspaceIds.push(scoped.workspaceId)
142
+ return { wasCreated: true }
143
+ }
144
+
145
+ function upsertMembershipForIngest(context: IngestMutationContext, workspaceWasCreated: boolean): SaasRole {
146
+ const { store, scoped, options, nowIso } = context
147
+ const membershipId = membershipKey(scoped.organizationId, scoped.workspaceId, options.userId)
148
+ const membership = store.memberships[membershipId]
149
+ let role = normalizeRole(options.role)
150
+ if (!membership && workspaceWasCreated) role = 'owner'
151
+
152
+ if (membership) {
153
+ membership.lastSeenAt = nowIso
154
+ if (options.role) membership.role = normalizeRole(options.role)
155
+ return membership.role
156
+ }
157
+
158
+ store.memberships[membershipId] = {
159
+ id: membershipId,
160
+ organizationId: scoped.organizationId,
161
+ workspaceId: scoped.workspaceId,
162
+ userId: options.userId,
163
+ role,
164
+ createdAt: nowIso,
165
+ lastSeenAt: nowIso,
166
+ }
167
+ return role
168
+ }
169
+
170
+ function upsertRepoForIngest(store: SaasStore, scoped: IngestMutationContext['scoped'], nowIso: string): void {
171
+ const repo = store.repos[scoped.repoId]
172
+ if (repo) {
173
+ repo.lastSeenAt = nowIso
174
+ return
175
+ }
176
+
177
+ store.repos[scoped.repoId] = {
178
+ id: scoped.repoId,
179
+ organizationId: scoped.organizationId,
180
+ workspaceId: scoped.workspaceId,
181
+ name: scoped.repoName,
182
+ createdAt: nowIso,
183
+ lastSeenAt: nowIso,
184
+ }
185
+
186
+ const workspace = store.workspaces[scoped.workspaceKey]
187
+ if (!workspace.repoIds.includes(scoped.repoId)) workspace.repoIds.push(scoped.repoId)
188
+ }
189
+
190
+ function createSnapshotFromReport(report: DriftReportInput, context: IngestMutationContext, role: SaasRole): SaasSnapshot {
191
+ const { store, scoped, options, nowIso, requestedPlan } = context
192
+ return {
193
+ id: createRandomId(String(Date.now())),
194
+ createdAt: nowIso,
195
+ scannedAt: report.scannedAt,
196
+ organizationId: scoped.organizationId,
197
+ workspaceId: scoped.workspaceId,
198
+ userId: options.userId,
199
+ role,
200
+ plan: normalizePlan(store.organizations[scoped.organizationId]?.plan ?? requestedPlan),
201
+ repoId: scoped.repoId,
202
+ repoName: scoped.repoName,
203
+ targetPath: report.targetPath,
204
+ totalScore: report.totalScore,
205
+ totalIssues: report.totalIssues,
206
+ totalFiles: report.totalFiles,
207
+ summary: {
208
+ errors: report.summary.errors,
209
+ warnings: report.summary.warnings,
210
+ infos: report.summary.infos,
211
+ },
212
+ }
213
+ }
214
+
215
+ function assertIngestActorRequirement(options: IngestOptions, scoped: IngestMutationContext['scoped'], store: SaasStore): void {
216
+ if (!store.policy.strictActorEnforcement || options.actorUserId) return
217
+ throw new SaasActorRequiredError({
218
+ operation: 'snapshot:write',
219
+ organizationId: scoped.organizationId,
220
+ workspaceId: scoped.workspaceId,
221
+ })
222
+ }
223
+
224
+ function assertIngestPermissionForActor(store: SaasStore, scoped: IngestMutationContext['scoped'], actorUserId?: string): void {
225
+ if (!actorUserId) return
226
+
227
+ const workspaceExists = Boolean(store.workspaces[scoped.workspaceKey])
228
+ const organizationExists = Boolean(store.organizations[scoped.organizationId])
229
+ if (workspaceExists) {
230
+ assertPermissionInStore(store, {
231
+ operation: 'snapshot:write',
232
+ organizationId: scoped.organizationId,
233
+ workspaceId: scoped.workspaceId,
234
+ actorUserId,
235
+ })
236
+ return
237
+ }
238
+
239
+ if (organizationExists) {
240
+ assertPermissionInStore(store, {
241
+ operation: 'billing:write',
242
+ organizationId: scoped.organizationId,
243
+ actorUserId,
244
+ })
245
+ }
246
+ }
247
+
248
+ export function ingestSnapshotFromReport(report: DriftReportInput, options: IngestOptions): SaasSnapshot {
249
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath())
250
+ const store = loadStoreInternal(storeFile, options.policy)
251
+ const nowIso = new Date().toISOString()
252
+ const scoped = resolveScopedIdentity(options)
253
+ const requestedPlan = normalizePlan(options.plan)
254
+ const context: IngestMutationContext = {
255
+ store,
256
+ scoped,
257
+ options,
258
+ nowIso,
259
+ requestedPlan,
260
+ }
261
+
262
+ assertIngestActorRequirement(options, scoped, store)
263
+ assertIngestPermissionForActor(store, scoped, options.actorUserId)
264
+ assertGuardrails(store, options, nowIso)
265
+
266
+ upsertUser(store, options.userId, nowIso)
267
+ ensureOrganizationForIngest(context)
268
+ const workspaceState = upsertWorkspaceForIngest(store, scoped, options.userId, nowIso)
269
+ const role = upsertMembershipForIngest(context, workspaceState.wasCreated)
270
+ upsertRepoForIngest(store, scoped, nowIso)
271
+
272
+ const snapshot = createSnapshotFromReport(report, context, role)
273
+ store.snapshots.push(snapshot)
274
+ applyRetentionPolicy(store)
275
+ saveStore(storeFile, store)
276
+
277
+ return snapshot
278
+ }