@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,219 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { analyzeProject } from '../src/analyzer.js'
6
+
7
+ describe('plugin contract hardening', () => {
8
+ let tmpDir = ''
9
+
10
+ afterEach(() => {
11
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
12
+ tmpDir = ''
13
+ })
14
+
15
+ it('keeps legacy plugins compatible when apiVersion is missing', () => {
16
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-legacy-compatible-'))
17
+ writeFileSync(join(tmpDir, 'index.ts'), 'export const foo = 1\n')
18
+ writeFileSync(join(tmpDir, 'legacy-plugin.js'), [
19
+ 'module.exports = {',
20
+ " name: 'legacy-plugin',",
21
+ ' rules: [',
22
+ ' {',
23
+ " id: 'no-foo-export',",
24
+ " severity: 'error',",
25
+ ' weight: 12,',
26
+ ' detect(file) {',
27
+ " if (!file.getFullText().includes('foo')) return []",
28
+ ' return [{',
29
+ " message: 'Avoid exporting foo',",
30
+ ' line: 1,',
31
+ ' column: 1,',
32
+ " snippet: 'export const foo = 1',",
33
+ ' }]',
34
+ ' }',
35
+ ' },',
36
+ ' {',
37
+ " id: 'Legacy Rule Name',",
38
+ ' detect() { return [] }',
39
+ ' }',
40
+ ' ]',
41
+ '}',
42
+ ].join('\n'))
43
+
44
+ const reports = analyzeProject(tmpDir, {
45
+ plugins: ['./legacy-plugin.js'],
46
+ })
47
+
48
+ const allIssues = reports.flatMap((report) => report.issues)
49
+ expect(allIssues.some((issue) => issue.rule === 'legacy-plugin/no-foo-export')).toBe(true)
50
+ expect(allIssues.some((issue) => issue.rule === 'plugin-error')).toBe(false)
51
+ expect(allIssues.some((issue) => issue.rule === 'plugin-warning' && issue.message.includes('[plugin-api-version-implicit]'))).toBe(true)
52
+ expect(allIssues.some((issue) => issue.rule === 'plugin-warning' && issue.message.includes('[plugin-rule-id-format-legacy]'))).toBe(true)
53
+ })
54
+
55
+ it('reports actionable diagnostics for invalid plugin contract', () => {
56
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-invalid-contract-'))
57
+ writeFileSync(join(tmpDir, 'index.ts'), 'export const ok = true\n')
58
+ writeFileSync(join(tmpDir, 'broken-plugin.js'), [
59
+ 'module.exports = {',
60
+ " name: 'broken-plugin',",
61
+ " apiVersion: 1,",
62
+ ' rules: [',
63
+ ' {',
64
+ " name: 'broken rule id',",
65
+ " severity: 'fatal',",
66
+ ' weight: 999,',
67
+ " detect: 'not-a-function',",
68
+ ' }',
69
+ ' ]',
70
+ '}',
71
+ ].join('\n'))
72
+
73
+ const reports = analyzeProject(tmpDir, {
74
+ plugins: ['./broken-plugin.js'],
75
+ })
76
+
77
+ const pluginIssues = reports
78
+ .flatMap((report) => report.issues)
79
+ .filter((issue) => issue.rule === 'plugin-error')
80
+
81
+ expect(pluginIssues.length).toBeGreaterThan(0)
82
+ expect(pluginIssues.some((issue) => issue.message.includes('broken-plugin.js'))).toBe(true)
83
+ expect(pluginIssues.some((issue) => issue.message.includes('[plugin-rule-detect-invalid]'))).toBe(true)
84
+ })
85
+
86
+ it('rejects plugins with unsupported apiVersion', () => {
87
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-version-mismatch-'))
88
+ writeFileSync(join(tmpDir, 'index.ts'), 'export const ok = true\n')
89
+ writeFileSync(join(tmpDir, 'version-mismatch-plugin.js'), [
90
+ 'module.exports = {',
91
+ " name: 'version-mismatch-plugin',",
92
+ ' apiVersion: 99,',
93
+ ' rules: [',
94
+ ' {',
95
+ " id: 'valid-rule-id',",
96
+ ' detect() { return [] }',
97
+ ' }',
98
+ ' ]',
99
+ '}',
100
+ ].join('\n'))
101
+
102
+ const reports = analyzeProject(tmpDir, {
103
+ plugins: ['./version-mismatch-plugin.js'],
104
+ })
105
+
106
+ const issues = reports.flatMap((report) => report.issues)
107
+ expect(issues.some((issue) => issue.rule === 'plugin-error' && issue.message.includes('[plugin-api-version-unsupported]'))).toBe(true)
108
+ expect(issues.some((issue) => issue.rule === 'version-mismatch-plugin/valid-rule-id')).toBe(false)
109
+ })
110
+
111
+ it('rejects plugins with invalid apiVersion format', () => {
112
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-version-invalid-'))
113
+ writeFileSync(join(tmpDir, 'index.ts'), 'export const ok = true\n')
114
+ writeFileSync(join(tmpDir, 'version-invalid-plugin.js'), [
115
+ 'module.exports = {',
116
+ " name: 'version-invalid-plugin',",
117
+ " apiVersion: '1',",
118
+ ' rules: [',
119
+ ' {',
120
+ " id: 'valid-rule-id',",
121
+ ' detect() { return [] }',
122
+ ' }',
123
+ ' ]',
124
+ '}',
125
+ ].join('\n'))
126
+
127
+ const reports = analyzeProject(tmpDir, {
128
+ plugins: ['./version-invalid-plugin.js'],
129
+ })
130
+
131
+ const issues = reports.flatMap((report) => report.issues)
132
+ expect(issues.some((issue) => issue.rule === 'plugin-error' && issue.message.includes('[plugin-api-version-invalid]'))).toBe(true)
133
+ expect(issues.some((issue) => issue.rule === 'version-invalid-plugin/valid-rule-id')).toBe(false)
134
+ })
135
+
136
+ it('rejects duplicate rule IDs within the same plugin', () => {
137
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-duplicate-rules-'))
138
+ writeFileSync(join(tmpDir, 'index.ts'), 'export const ok = true\n')
139
+ writeFileSync(join(tmpDir, 'duplicate-rules-plugin.js'), [
140
+ 'module.exports = {',
141
+ " name: 'duplicate-rules-plugin',",
142
+ ' apiVersion: 1,',
143
+ ' rules: [',
144
+ ' {',
145
+ " id: 'duplicate-rule',",
146
+ ' detect() {',
147
+ ' return [{',
148
+ " message: 'first duplicate still runs',",
149
+ ' line: 1,',
150
+ ' column: 1,',
151
+ " snippet: 'export const ok = true',",
152
+ ' }]',
153
+ ' }',
154
+ ' },',
155
+ ' {',
156
+ " id: 'duplicate-rule',",
157
+ ' detect() { return [] }',
158
+ ' }',
159
+ ' ]',
160
+ '}',
161
+ ].join('\n'))
162
+
163
+ const reports = analyzeProject(tmpDir, {
164
+ plugins: ['./duplicate-rules-plugin.js'],
165
+ })
166
+
167
+ const issues = reports.flatMap((report) => report.issues)
168
+ expect(issues.some((issue) => issue.rule === 'plugin-error' && issue.message.includes('[plugin-rule-id-duplicate]'))).toBe(true)
169
+ expect(issues.some((issue) => issue.rule === 'duplicate-rules-plugin/duplicate-rule')).toBe(true)
170
+ })
171
+
172
+ it('isolates plugin runtime failures and continues analysis', () => {
173
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-runtime-isolation-'))
174
+ writeFileSync(join(tmpDir, 'index.ts'), [
175
+ 'export function run(input: any) {',
176
+ ' return input',
177
+ '}',
178
+ ].join('\n'))
179
+ writeFileSync(join(tmpDir, 'mixed-plugin.js'), [
180
+ 'module.exports = {',
181
+ " name: 'mixed-plugin',",
182
+ ' apiVersion: 1,',
183
+ ' capabilities: {',
184
+ ' fixes: true,',
185
+ ' runtimeSafe: true',
186
+ ' },',
187
+ ' rules: [',
188
+ ' {',
189
+ " name: 'throwing-rule',",
190
+ ' detect() {',
191
+ " throw new Error('boom')",
192
+ ' }',
193
+ ' },',
194
+ ' {',
195
+ " name: 'safe-rule',",
196
+ ' detect() {',
197
+ ' return [{',
198
+ " message: 'Safe rule still runs',",
199
+ ' line: 1,',
200
+ ' column: 1,',
201
+ " snippet: 'export function run(input: any)',",
202
+ ' }]',
203
+ ' }',
204
+ ' }',
205
+ ' ]',
206
+ '}',
207
+ ].join('\n'))
208
+
209
+ const reports = analyzeProject(tmpDir, {
210
+ plugins: ['./mixed-plugin.js'],
211
+ })
212
+
213
+ const rules = reports.flatMap((report) => report.issues.map((issue) => issue.rule))
214
+ expect(rules).toContain('mixed-plugin/safe-rule')
215
+ expect(rules).toContain('plugin-error')
216
+ expect(rules).toContain('any-abuse')
217
+ expect(rules).not.toContain('plugin-warning')
218
+ })
219
+ })
@@ -124,6 +124,17 @@ describe('dead-code', () => {
124
124
  const code = `import fs from 'fs'\nconst x = 1`
125
125
  expect(getRules(code)).not.toContain('dead-code')
126
126
  })
127
+
128
+ it('keeps used named imports clean even with many identifiers', () => {
129
+ const code = [
130
+ `import { join } from 'path'`,
131
+ `const a = 'x'`,
132
+ `const b = 'y'`,
133
+ `const c = 'z'`,
134
+ `const p = join(a, b + c)`,
135
+ ].join('\n')
136
+ expect(getRules(code)).not.toContain('dead-code')
137
+ })
127
138
  })
128
139
 
129
140
  // ─────────────────────────────────────────────────────────────────────────────
@@ -262,6 +273,12 @@ describe('high-complexity', () => {
262
273
  }`
263
274
  expect(getRules(code)).toContain('high-complexity')
264
275
  })
276
+
277
+ it('detects high complexity in class methods', () => {
278
+ const ifs = Array.from({ length: 11 }, (_, i) => ` if (x === ${i}) return ${i}`).join('\n')
279
+ const code = `class C {\n run(x: number): number {\n${ifs}\n return -1\n }\n}`
280
+ expect(getRules(code)).toContain('high-complexity')
281
+ })
265
282
  })
266
283
 
267
284
  // ─────────────────────────────────────────────────────────────────────────────
@@ -344,6 +361,11 @@ describe('too-many-params', () => {
344
361
  const code = `const fn = (a: string, b: number, c: boolean, d: string, e: number): void => {}`
345
362
  expect(getRules(code)).toContain('too-many-params')
346
363
  })
364
+
365
+ it('detects class method with too many params', () => {
366
+ const code = `class C {\n run(a: string, b: number, c: boolean, d: string, e: number): void {}\n}`
367
+ expect(getRules(code)).toContain('too-many-params')
368
+ })
347
369
  })
348
370
 
349
371
  // ─────────────────────────────────────────────────────────────────────────────
@@ -685,7 +707,7 @@ abstract class Processor {
685
707
  abstract process(x: number): void
686
708
  }
687
709
  `
688
- expect(getRules(code2, 'src/processor.ts')).toContain('unnecessary-abstraction')
710
+ expect(getRules(code2, undefined, 'src/processor.ts')).toContain('unnecessary-abstraction')
689
711
  })
690
712
  })
691
713
 
@@ -4,7 +4,19 @@ import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { analyzeProject } from '../src/analyzer.js'
6
6
  import { buildReport } from '../src/reporter.js'
7
- import { ingestSnapshotFromReport, getSaasSummary } from '../src/saas.js'
7
+ import {
8
+ SaasActorRequiredError,
9
+ SaasPermissionError,
10
+ assertSaasPermission,
11
+ changeOrganizationPlan,
12
+ getOrganizationEffectiveLimits,
13
+ getOrganizationUsageSnapshot,
14
+ getSaasEffectiveLimits,
15
+ getSaasSummary,
16
+ ingestSnapshotFromReport,
17
+ listOrganizationPlanChanges,
18
+ listSaasSnapshots,
19
+ } from '../src/saas.js'
8
20
 
9
21
  function createProjectDir(prefix: string): string {
10
22
  const dir = mkdtempSync(join(tmpdir(), prefix))
@@ -104,4 +116,349 @@ describe('saas foundations', () => {
104
116
  expect(summary.phase).toBe('paid')
105
117
  expect(summary.freeUsersRemaining).toBe(0)
106
118
  })
119
+
120
+ it('isolates tenant data by organization and workspace filters', () => {
121
+ const projectDir = createProjectDir('drift-saas-tenant-scope-')
122
+ dirs.push(projectDir)
123
+ const storeFile = join(projectDir, '.drift-cloud', 'store.json')
124
+ const report = createReport(projectDir)
125
+
126
+ ingestSnapshotFromReport(report, {
127
+ organizationId: 'org-a',
128
+ workspaceId: 'ws-shared',
129
+ userId: 'u-1',
130
+ repoName: 'repo-a',
131
+ storeFile,
132
+ })
133
+ ingestSnapshotFromReport(report, {
134
+ organizationId: 'org-b',
135
+ workspaceId: 'ws-shared',
136
+ userId: 'u-2',
137
+ repoName: 'repo-b',
138
+ storeFile,
139
+ })
140
+
141
+ const orgASummary = getSaasSummary({ storeFile, organizationId: 'org-a' })
142
+ const orgBSummary = getSaasSummary({ storeFile, organizationId: 'org-b' })
143
+ const orgASnapshots = listSaasSnapshots({ storeFile, organizationId: 'org-a', workspaceId: 'ws-shared' })
144
+
145
+ expect(orgASummary.totalSnapshots).toBe(1)
146
+ expect(orgBSummary.totalSnapshots).toBe(1)
147
+ expect(orgASnapshots).toHaveLength(1)
148
+ expect(orgASnapshots[0]?.organizationId).toBe('org-a')
149
+ })
150
+
151
+ it('enforces workspace plan limit and allows plan upgrade', () => {
152
+ const projectDir = createProjectDir('drift-saas-plan-limit-')
153
+ dirs.push(projectDir)
154
+ const storeFile = join(projectDir, '.drift-cloud', 'store.json')
155
+ const report = createReport(projectDir)
156
+ const policy = {
157
+ maxWorkspacesPerOrganizationByPlan: {
158
+ free: 1,
159
+ sponsor: 2,
160
+ team: 4,
161
+ business: 8,
162
+ },
163
+ }
164
+
165
+ ingestSnapshotFromReport(report, {
166
+ organizationId: 'org-plan',
167
+ workspaceId: 'ws-1',
168
+ userId: 'owner-1',
169
+ plan: 'free',
170
+ storeFile,
171
+ policy,
172
+ })
173
+
174
+ expect(() => {
175
+ ingestSnapshotFromReport(report, {
176
+ organizationId: 'org-plan',
177
+ workspaceId: 'ws-2',
178
+ userId: 'owner-1',
179
+ plan: 'free',
180
+ storeFile,
181
+ policy,
182
+ })
183
+ }).toThrow(/max workspaces/i)
184
+
185
+ expect(() => {
186
+ ingestSnapshotFromReport(report, {
187
+ organizationId: 'org-plan',
188
+ workspaceId: 'ws-2',
189
+ userId: 'owner-1',
190
+ plan: 'sponsor',
191
+ storeFile,
192
+ policy,
193
+ })
194
+ }).not.toThrow()
195
+ })
196
+
197
+ it('stores role primitives for workspace members', () => {
198
+ const projectDir = createProjectDir('drift-saas-roles-')
199
+ dirs.push(projectDir)
200
+ const storeFile = join(projectDir, '.drift-cloud', 'store.json')
201
+ const report = createReport(projectDir)
202
+
203
+ const ownerSnapshot = ingestSnapshotFromReport(report, {
204
+ organizationId: 'org-role',
205
+ workspaceId: 'ws-role',
206
+ userId: 'u-owner',
207
+ storeFile,
208
+ })
209
+
210
+ const viewerSnapshot = ingestSnapshotFromReport(report, {
211
+ organizationId: 'org-role',
212
+ workspaceId: 'ws-role',
213
+ userId: 'u-viewer',
214
+ role: 'viewer',
215
+ storeFile,
216
+ })
217
+
218
+ expect(ownerSnapshot.role).toBe('owner')
219
+ expect(viewerSnapshot.role).toBe('viewer')
220
+ })
221
+
222
+ it('enforces deterministic permission errors when actor is unauthorized', () => {
223
+ const projectDir = createProjectDir('drift-saas-authz-')
224
+ dirs.push(projectDir)
225
+ const storeFile = join(projectDir, '.drift-cloud', 'store.json')
226
+ const report = createReport(projectDir)
227
+
228
+ ingestSnapshotFromReport(report, {
229
+ organizationId: 'org-auth',
230
+ workspaceId: 'ws-auth',
231
+ userId: 'u-owner',
232
+ storeFile,
233
+ })
234
+
235
+ ingestSnapshotFromReport(report, {
236
+ organizationId: 'org-auth',
237
+ workspaceId: 'ws-auth',
238
+ userId: 'u-viewer',
239
+ role: 'viewer',
240
+ storeFile,
241
+ })
242
+
243
+ expect(() => {
244
+ ingestSnapshotFromReport(report, {
245
+ organizationId: 'org-auth',
246
+ workspaceId: 'ws-auth',
247
+ userId: 'u-viewer',
248
+ actorUserId: 'u-viewer',
249
+ storeFile,
250
+ })
251
+ }).toThrowError(SaasPermissionError)
252
+
253
+ try {
254
+ ingestSnapshotFromReport(report, {
255
+ organizationId: 'org-auth',
256
+ workspaceId: 'ws-auth',
257
+ userId: 'u-viewer',
258
+ actorUserId: 'u-viewer',
259
+ storeFile,
260
+ })
261
+ } catch (error) {
262
+ expect(error).toBeInstanceOf(SaasPermissionError)
263
+ const permissionError = error as SaasPermissionError
264
+ expect(permissionError.code).toBe('SAAS_PERMISSION_DENIED')
265
+ expect(permissionError.operation).toBe('snapshot:write')
266
+ expect(permissionError.requiredRole).toBe('member')
267
+ expect(permissionError.actorRole).toBe('viewer')
268
+ }
269
+ })
270
+
271
+ it('tracks billing plan lifecycle and usage snapshots', () => {
272
+ const projectDir = createProjectDir('drift-saas-billing-')
273
+ dirs.push(projectDir)
274
+ const storeFile = join(projectDir, '.drift-cloud', 'store.json')
275
+ const report = createReport(projectDir)
276
+
277
+ ingestSnapshotFromReport(report, {
278
+ organizationId: 'org-billing',
279
+ workspaceId: 'ws-1',
280
+ userId: 'u-owner',
281
+ storeFile,
282
+ plan: 'free',
283
+ })
284
+ ingestSnapshotFromReport(report, {
285
+ organizationId: 'org-billing',
286
+ workspaceId: 'ws-1',
287
+ userId: 'u-owner',
288
+ repoName: 'repo-2',
289
+ storeFile,
290
+ })
291
+
292
+ ingestSnapshotFromReport(report, {
293
+ organizationId: 'org-billing',
294
+ workspaceId: 'ws-1',
295
+ userId: 'u-member',
296
+ role: 'member',
297
+ storeFile,
298
+ })
299
+
300
+ expect(() => {
301
+ changeOrganizationPlan({
302
+ organizationId: 'org-billing',
303
+ actorUserId: 'u-member',
304
+ newPlan: 'team',
305
+ storeFile,
306
+ })
307
+ }).toThrowError(SaasPermissionError)
308
+
309
+ const planChange = changeOrganizationPlan({
310
+ organizationId: 'org-billing',
311
+ actorUserId: 'u-owner',
312
+ newPlan: 'team',
313
+ reason: 'need more workspace capacity',
314
+ storeFile,
315
+ })
316
+
317
+ expect(planChange.fromPlan).toBe('free')
318
+ expect(planChange.toPlan).toBe('team')
319
+ expect(planChange.reason).toBe('need more workspace capacity')
320
+
321
+ const changes = listOrganizationPlanChanges({
322
+ organizationId: 'org-billing',
323
+ actorUserId: 'u-owner',
324
+ storeFile,
325
+ })
326
+ expect(changes).toHaveLength(1)
327
+ expect(changes[0]?.changedByUserId).toBe('u-owner')
328
+
329
+ const usage = getOrganizationUsageSnapshot({
330
+ organizationId: 'org-billing',
331
+ actorUserId: 'u-owner',
332
+ storeFile,
333
+ })
334
+ expect(usage.workspaceCount).toBe(1)
335
+ expect(usage.repoCount).toBe(2)
336
+ expect(usage.runCount).toBe(3)
337
+ expect(usage.runCountThisMonth).toBe(3)
338
+ expect(usage.plan).toBe('team')
339
+
340
+ const limitsByPlan = getSaasEffectiveLimits({ plan: 'team' })
341
+ const limitsByOrg = getOrganizationEffectiveLimits({ organizationId: 'org-billing', storeFile })
342
+ expect(limitsByPlan.plan).toBe('team')
343
+ expect(limitsByOrg.plan).toBe('team')
344
+ expect(limitsByOrg.maxWorkspaces).toBe(limitsByPlan.maxWorkspaces)
345
+ })
346
+
347
+ it('supports explicit authorization checks for scoped reads', () => {
348
+ const projectDir = createProjectDir('drift-saas-read-authz-')
349
+ dirs.push(projectDir)
350
+ const storeFile = join(projectDir, '.drift-cloud', 'store.json')
351
+ const report = createReport(projectDir)
352
+
353
+ ingestSnapshotFromReport(report, {
354
+ organizationId: 'org-read',
355
+ workspaceId: 'ws-read',
356
+ userId: 'u-owner',
357
+ storeFile,
358
+ })
359
+
360
+ ingestSnapshotFromReport(report, {
361
+ organizationId: 'org-read',
362
+ workspaceId: 'ws-read',
363
+ userId: 'u-viewer',
364
+ role: 'viewer',
365
+ storeFile,
366
+ })
367
+
368
+ const allowed = assertSaasPermission({
369
+ operation: 'summary:read',
370
+ organizationId: 'org-read',
371
+ workspaceId: 'ws-read',
372
+ actorUserId: 'u-viewer',
373
+ storeFile,
374
+ })
375
+ expect(allowed.requiredRole).toBe('viewer')
376
+ expect(allowed.actorRole).toBe('viewer')
377
+
378
+ expect(() => {
379
+ assertSaasPermission({
380
+ operation: 'billing:write',
381
+ organizationId: 'org-read',
382
+ actorUserId: 'u-viewer',
383
+ storeFile,
384
+ })
385
+ }).toThrowError(SaasPermissionError)
386
+ })
387
+
388
+ it('enforces missing actor deterministically when strict actor mode is enabled', () => {
389
+ const projectDir = createProjectDir('drift-saas-strict-actor-')
390
+ dirs.push(projectDir)
391
+ const storeFile = join(projectDir, '.drift-cloud', 'store.json')
392
+ const report = createReport(projectDir)
393
+ const policy = { strictActorEnforcement: true }
394
+
395
+ expect(() => {
396
+ ingestSnapshotFromReport(report, {
397
+ organizationId: 'org-strict',
398
+ workspaceId: 'ws-strict',
399
+ userId: 'u-owner',
400
+ storeFile,
401
+ policy,
402
+ })
403
+ }).toThrowError(SaasActorRequiredError)
404
+
405
+ ingestSnapshotFromReport(report, {
406
+ organizationId: 'org-strict',
407
+ workspaceId: 'ws-strict',
408
+ userId: 'u-owner',
409
+ actorUserId: 'u-owner',
410
+ storeFile,
411
+ policy,
412
+ })
413
+
414
+ expect(() => {
415
+ getSaasSummary({
416
+ organizationId: 'org-strict',
417
+ workspaceId: 'ws-strict',
418
+ storeFile,
419
+ policy,
420
+ })
421
+ }).toThrowError(SaasActorRequiredError)
422
+
423
+ try {
424
+ getSaasSummary({
425
+ organizationId: 'org-strict',
426
+ workspaceId: 'ws-strict',
427
+ storeFile,
428
+ policy,
429
+ })
430
+ } catch (error) {
431
+ expect(error).toBeInstanceOf(SaasActorRequiredError)
432
+ const actorRequired = error as SaasActorRequiredError
433
+ expect(actorRequired.code).toBe('SAAS_ACTOR_REQUIRED')
434
+ expect(actorRequired.operation).toBe('summary:read')
435
+ expect(actorRequired.organizationId).toBe('org-strict')
436
+ expect(actorRequired.workspaceId).toBe('ws-strict')
437
+ }
438
+ })
439
+
440
+ it('keeps backward compatibility when strict actor mode is disabled', () => {
441
+ const projectDir = createProjectDir('drift-saas-compat-')
442
+ dirs.push(projectDir)
443
+ const storeFile = join(projectDir, '.drift-cloud', 'store.json')
444
+ const report = createReport(projectDir)
445
+
446
+ expect(() => {
447
+ ingestSnapshotFromReport(report, {
448
+ organizationId: 'org-compat',
449
+ workspaceId: 'ws-compat',
450
+ userId: 'u-owner',
451
+ storeFile,
452
+ })
453
+ }).not.toThrow()
454
+
455
+ const scopedSummary = getSaasSummary({
456
+ organizationId: 'org-compat',
457
+ workspaceId: 'ws-compat',
458
+ storeFile,
459
+ })
460
+
461
+ expect(scopedSummary.totalSnapshots).toBe(1)
462
+ expect(scopedSummary.usersRegistered).toBe(1)
463
+ })
107
464
  })