@eduardbar/drift 1.3.0 → 1.5.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 (198) hide show
  1. package/.gga +50 -0
  2. package/.github/actions/drift-review/README.md +62 -0
  3. package/.github/actions/drift-review/action.yml +148 -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 +1 -3
  7. package/.github/workflows/publish.yml +8 -0
  8. package/.github/workflows/quality.yml +15 -0
  9. package/.github/workflows/reusable-quality-checks.yml +95 -0
  10. package/.github/workflows/review-pr.yml +33 -41
  11. package/AGENTS.md +75 -251
  12. package/CHANGELOG.md +41 -0
  13. package/README.md +177 -43
  14. package/benchmarks/fixtures/critical/drift.config.ts +21 -0
  15. package/benchmarks/fixtures/critical/src/app/user-service.ts +30 -0
  16. package/benchmarks/fixtures/critical/src/domain/entities.ts +19 -0
  17. package/benchmarks/fixtures/critical/src/domain/policies.ts +22 -0
  18. package/benchmarks/fixtures/critical/src/index.ts +10 -0
  19. package/benchmarks/fixtures/critical/src/infra/memory-user-repo.ts +14 -0
  20. package/benchmarks/perf-budget.v1.json +27 -0
  21. package/dist/benchmark.d.ts +1 -1
  22. package/dist/benchmark.js +83 -52
  23. package/dist/cli.js +243 -8
  24. package/dist/config.js +16 -2
  25. package/dist/diff.js +42 -50
  26. package/dist/doctor.d.ts +26 -0
  27. package/dist/doctor.js +140 -0
  28. package/dist/format.d.ts +17 -0
  29. package/dist/format.js +45 -0
  30. package/dist/guard-baseline.d.ts +12 -0
  31. package/dist/guard-baseline.js +57 -0
  32. package/dist/guard-metrics.d.ts +6 -0
  33. package/dist/guard-metrics.js +39 -0
  34. package/dist/guard-types.d.ts +58 -0
  35. package/dist/guard-types.js +2 -0
  36. package/dist/guard.d.ts +16 -0
  37. package/dist/guard.js +178 -0
  38. package/dist/index.d.ts +10 -3
  39. package/dist/index.js +4 -1
  40. package/dist/init.d.ts +15 -0
  41. package/dist/init.js +273 -0
  42. package/dist/map-cycles.d.ts +2 -0
  43. package/dist/map-cycles.js +34 -0
  44. package/dist/map-svg.d.ts +19 -0
  45. package/dist/map-svg.js +97 -0
  46. package/dist/map.js +78 -138
  47. package/dist/metrics.js +70 -55
  48. package/dist/output-metadata.d.ts +15 -0
  49. package/dist/output-metadata.js +19 -0
  50. package/dist/plugins-capabilities.d.ts +4 -0
  51. package/dist/plugins-capabilities.js +21 -0
  52. package/dist/plugins-messages.d.ts +10 -0
  53. package/dist/plugins-messages.js +16 -0
  54. package/dist/plugins-rules.d.ts +9 -0
  55. package/dist/plugins-rules.js +137 -0
  56. package/dist/plugins.d.ts +1 -1
  57. package/dist/plugins.js +45 -142
  58. package/dist/reporter-constants.d.ts +16 -0
  59. package/dist/reporter-constants.js +39 -0
  60. package/dist/reporter.d.ts +3 -3
  61. package/dist/reporter.js +35 -55
  62. package/dist/review.d.ts +2 -1
  63. package/dist/review.js +2 -1
  64. package/dist/rules/phase3-configurable.js +23 -15
  65. package/dist/saas/constants.d.ts +15 -0
  66. package/dist/saas/constants.js +48 -0
  67. package/dist/saas/dashboard.d.ts +8 -0
  68. package/dist/saas/dashboard.js +132 -0
  69. package/dist/saas/errors.d.ts +19 -0
  70. package/dist/saas/errors.js +37 -0
  71. package/dist/saas/helpers.d.ts +21 -0
  72. package/dist/saas/helpers.js +110 -0
  73. package/dist/saas/ingest.d.ts +3 -0
  74. package/dist/saas/ingest.js +249 -0
  75. package/dist/saas/organization.d.ts +5 -0
  76. package/dist/saas/organization.js +82 -0
  77. package/dist/saas/plan-change.d.ts +10 -0
  78. package/dist/saas/plan-change.js +15 -0
  79. package/dist/saas/store.d.ts +21 -0
  80. package/dist/saas/store.js +159 -0
  81. package/dist/saas/types.d.ts +191 -0
  82. package/dist/saas/types.js +2 -0
  83. package/dist/saas.d.ts +8 -218
  84. package/dist/saas.js +7 -761
  85. package/dist/sarif.d.ts +74 -0
  86. package/dist/sarif.js +122 -0
  87. package/dist/trust-advanced.d.ts +14 -0
  88. package/dist/trust-advanced.js +65 -0
  89. package/dist/trust-kpi-fs.d.ts +3 -0
  90. package/dist/trust-kpi-fs.js +141 -0
  91. package/dist/trust-kpi-parse.d.ts +7 -0
  92. package/dist/trust-kpi-parse.js +186 -0
  93. package/dist/trust-kpi-types.d.ts +16 -0
  94. package/dist/trust-kpi-types.js +2 -0
  95. package/dist/trust-kpi.d.ts +1 -3
  96. package/dist/trust-kpi.js +6 -266
  97. package/dist/trust-policy.d.ts +32 -0
  98. package/dist/trust-policy.js +160 -0
  99. package/dist/trust-render.d.ts +9 -0
  100. package/dist/trust-render.js +54 -0
  101. package/dist/trust-scoring.d.ts +9 -0
  102. package/dist/trust-scoring.js +208 -0
  103. package/dist/trust.d.ts +5 -32
  104. package/dist/trust.js +29 -432
  105. package/dist/types/app.d.ts +30 -0
  106. package/dist/types/app.js +2 -0
  107. package/dist/types/config.d.ts +25 -0
  108. package/dist/types/config.js +2 -0
  109. package/dist/types/core.d.ts +100 -0
  110. package/dist/types/core.js +2 -0
  111. package/dist/types/diff.d.ts +55 -0
  112. package/dist/types/diff.js +2 -0
  113. package/dist/types/plugin.d.ts +41 -0
  114. package/dist/types/plugin.js +2 -0
  115. package/dist/types/trust.d.ts +120 -0
  116. package/dist/types/trust.js +2 -0
  117. package/dist/types.d.ts +8 -365
  118. package/docs/AGENTS.md +1 -1
  119. package/docs/release-notes-draft.md +40 -0
  120. package/docs/rules-catalog.md +49 -0
  121. package/docs/trust-core-release-checklist.md +37 -5
  122. package/package.json +11 -4
  123. package/packages/vscode-drift/src/code-actions.ts +1 -1
  124. package/schemas/drift-ai-output.v1.json +162 -0
  125. package/schemas/drift-doctor.v1.json +57 -0
  126. package/schemas/drift-guard.v1.json +298 -0
  127. package/schemas/drift-report.v1.json +151 -0
  128. package/schemas/drift-trust.v1.json +131 -0
  129. package/scripts/check-docs-drift.mjs +154 -0
  130. package/scripts/check-performance-budget.mjs +360 -0
  131. package/scripts/check-runtime-policy.mjs +66 -0
  132. package/scripts/smoke-repo.mjs +394 -0
  133. package/src/benchmark.ts +92 -53
  134. package/src/cli.ts +285 -13
  135. package/src/config.ts +19 -2
  136. package/src/diff.ts +57 -48
  137. package/src/doctor.ts +185 -0
  138. package/src/format.ts +81 -0
  139. package/src/guard-baseline.ts +74 -0
  140. package/src/guard-metrics.ts +52 -0
  141. package/src/guard-types.ts +66 -0
  142. package/src/guard.ts +248 -0
  143. package/src/index.ts +36 -0
  144. package/src/init.ts +298 -0
  145. package/src/map-cycles.ts +38 -0
  146. package/src/map-svg.ts +124 -0
  147. package/src/map.ts +111 -142
  148. package/src/metrics.ts +78 -59
  149. package/src/output-metadata.ts +32 -0
  150. package/src/plugins-capabilities.ts +36 -0
  151. package/src/plugins-messages.ts +35 -0
  152. package/src/plugins-rules.ts +296 -0
  153. package/src/plugins.ts +76 -283
  154. package/src/reporter-constants.ts +46 -0
  155. package/src/reporter.ts +64 -65
  156. package/src/review.ts +4 -2
  157. package/src/rules/phase3-configurable.ts +39 -26
  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 -1031
  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 +8 -316
  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 +62 -576
  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 +79 -409
  185. package/tests/ci-quality-matrix.test.ts +37 -0
  186. package/tests/ci-smoke-gate.test.ts +26 -0
  187. package/tests/ci-version-alignment.test.ts +93 -0
  188. package/tests/cli-sarif.test.ts +92 -0
  189. package/tests/docs-drift-check.test.ts +115 -0
  190. package/tests/format.test.ts +157 -0
  191. package/tests/new-features.test.ts +11 -3
  192. package/tests/perf-budget-check.test.ts +146 -0
  193. package/tests/phase1-init-doctor-guard.test.ts +301 -0
  194. package/tests/runtime-policy-alignment.test.ts +46 -0
  195. package/tests/sarif.test.ts +160 -0
  196. package/tests/trust-kpi.test.ts +31 -4
  197. package/tests/trust.test.ts +18 -0
  198. package/vitest.config.ts +2 -0
@@ -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
+ }
@@ -0,0 +1,99 @@
1
+ import { resolve } from 'node:path'
2
+ import type {
3
+ ChangeOrganizationPlanOptions,
4
+ SaasOrganizationUsageSnapshot,
5
+ SaasPlanChange,
6
+ SaasPlanChangeQueryOptions,
7
+ SaasUsageQueryOptions,
8
+ } from './types.js'
9
+ import { assertPermissionInStore, defaultSaasStorePath, loadStoreInternal, saveStore } from './store.js'
10
+ import { monthKey, normalizePlan, workspaceKey } from './helpers.js'
11
+ import { appendPlanChange } from './plan-change.js'
12
+
13
+ export function changeOrganizationPlan(options: ChangeOrganizationPlanOptions): SaasPlanChange {
14
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath())
15
+ const store = loadStoreInternal(storeFile, options.policy)
16
+ const nowIso = new Date().toISOString()
17
+
18
+ const organization = store.organizations[options.organizationId]
19
+ if (!organization) throw new Error(`Organization '${options.organizationId}' does not exist.`)
20
+
21
+ assertPermissionInStore(store, {
22
+ operation: 'billing:write',
23
+ organizationId: options.organizationId,
24
+ actorUserId: options.actorUserId,
25
+ })
26
+
27
+ const nextPlan = normalizePlan(options.newPlan)
28
+ if (organization.plan === nextPlan) {
29
+ const unchanged = appendPlanChange(store, {
30
+ organizationId: organization.id,
31
+ fromPlan: organization.plan,
32
+ toPlan: nextPlan,
33
+ changedAt: nowIso,
34
+ changedByUserId: options.actorUserId,
35
+ reason: options.reason,
36
+ })
37
+ saveStore(storeFile, store)
38
+ return unchanged
39
+ }
40
+
41
+ const previousPlan = organization.plan
42
+ organization.plan = nextPlan
43
+ organization.lastSeenAt = nowIso
44
+ const change = appendPlanChange(store, {
45
+ organizationId: organization.id,
46
+ fromPlan: previousPlan,
47
+ toPlan: nextPlan,
48
+ changedAt: nowIso,
49
+ changedByUserId: options.actorUserId,
50
+ reason: options.reason,
51
+ })
52
+ saveStore(storeFile, store)
53
+ return change
54
+ }
55
+
56
+ export function listOrganizationPlanChanges(options: SaasPlanChangeQueryOptions): SaasPlanChange[] {
57
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath())
58
+ const store = loadStoreInternal(storeFile, options.policy)
59
+
60
+ assertPermissionInStore(store, {
61
+ operation: 'billing:read',
62
+ organizationId: options.organizationId,
63
+ actorUserId: options.actorUserId,
64
+ })
65
+
66
+ return store.planChanges
67
+ .filter((change) => change.organizationId === options.organizationId)
68
+ .sort((a, b) => b.changedAt.localeCompare(a.changedAt))
69
+ }
70
+
71
+ export function getOrganizationUsageSnapshot(options: SaasUsageQueryOptions): SaasOrganizationUsageSnapshot {
72
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath())
73
+ const store = loadStoreInternal(storeFile, options.policy)
74
+
75
+ assertPermissionInStore(store, {
76
+ operation: 'billing:read',
77
+ organizationId: options.organizationId,
78
+ actorUserId: options.actorUserId,
79
+ })
80
+
81
+ const organization = store.organizations[options.organizationId]
82
+ if (!organization) throw new Error(`Organization '${options.organizationId}' does not exist.`)
83
+
84
+ const month = options.month ?? monthKey(new Date().toISOString())
85
+ const organizationRunSnapshots = store.snapshots.filter((snapshot) => snapshot.organizationId === options.organizationId)
86
+
87
+ return {
88
+ organizationId: options.organizationId,
89
+ plan: organization.plan,
90
+ capturedAt: new Date().toISOString(),
91
+ workspaceCount: organization.workspaceIds.length,
92
+ repoCount: organization.workspaceIds
93
+ .map((workspaceId) => store.workspaces[workspaceKey(options.organizationId, workspaceId)])
94
+ .filter((workspace) => Boolean(workspace))
95
+ .reduce((count, workspace) => count + workspace.repoIds.length, 0),
96
+ runCount: organizationRunSnapshots.length,
97
+ runCountThisMonth: organizationRunSnapshots.filter((snapshot) => monthKey(snapshot.createdAt) === month).length,
98
+ }
99
+ }
@@ -0,0 +1,19 @@
1
+ import type { SaasPlan, SaasPlanChange, SaasStore } from './types.js'
2
+ import { createRandomId } from './constants.js'
3
+
4
+ export function appendPlanChange(
5
+ store: SaasStore,
6
+ input: { organizationId: string; fromPlan: SaasPlan; toPlan: SaasPlan; changedByUserId: string; reason?: string; changedAt: string },
7
+ ): SaasPlanChange {
8
+ const change: SaasPlanChange = {
9
+ id: createRandomId(input.changedAt),
10
+ organizationId: input.organizationId,
11
+ fromPlan: input.fromPlan,
12
+ toPlan: input.toPlan,
13
+ changedAt: input.changedAt,
14
+ changedByUserId: input.changedByUserId,
15
+ reason: input.reason,
16
+ }
17
+ store.planChanges.push(change)
18
+ return change
19
+ }