@eduardbar/drift 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +153 -0
- package/AGENTS.md +6 -0
- package/README.md +192 -4
- package/ROADMAP.md +6 -5
- package/dist/analyzer.d.ts +2 -2
- package/dist/analyzer.js +420 -159
- package/dist/benchmark.d.ts +2 -0
- package/dist/benchmark.js +185 -0
- package/dist/cli.js +509 -23
- package/dist/diff.js +74 -10
- package/dist/git.js +12 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/map.d.ts +3 -2
- package/dist/map.js +98 -10
- package/dist/plugins.d.ts +2 -1
- package/dist/plugins.js +177 -28
- package/dist/printer.js +4 -0
- package/dist/review.js +2 -2
- package/dist/rules/comments.js +2 -2
- package/dist/rules/complexity.js +2 -7
- package/dist/rules/nesting.js +3 -13
- package/dist/rules/phase0-basic.js +10 -10
- package/dist/rules/shared.d.ts +2 -0
- package/dist/rules/shared.js +27 -3
- package/dist/saas.d.ts +219 -0
- package/dist/saas.js +762 -0
- package/dist/trust-kpi.d.ts +9 -0
- package/dist/trust-kpi.js +445 -0
- package/dist/trust.d.ts +65 -0
- package/dist/trust.js +571 -0
- package/dist/types.d.ts +160 -0
- package/docs/PRD.md +199 -172
- package/docs/plugin-contract.md +61 -0
- package/docs/trust-core-release-checklist.md +55 -0
- package/package.json +5 -3
- package/packages/vscode-drift/src/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +11 -0
- package/src/analyzer.ts +484 -155
- package/src/benchmark.ts +244 -0
- package/src/cli.ts +628 -36
- package/src/diff.ts +75 -10
- package/src/git.ts +16 -0
- package/src/index.ts +63 -0
- package/src/map.ts +112 -10
- package/src/plugins.ts +354 -26
- package/src/printer.ts +4 -0
- package/src/review.ts +2 -2
- package/src/rules/comments.ts +2 -2
- package/src/rules/complexity.ts +2 -7
- package/src/rules/nesting.ts +3 -13
- package/src/rules/phase0-basic.ts +11 -12
- package/src/rules/shared.ts +31 -3
- package/src/saas.ts +1031 -0
- package/src/trust-kpi.ts +518 -0
- package/src/trust.ts +774 -0
- package/src/types.ts +177 -0
- package/tests/diff.test.ts +124 -0
- package/tests/new-features.test.ts +98 -0
- package/tests/plugins.test.ts +219 -0
- package/tests/rules.test.ts +23 -1
- package/tests/saas-foundation.test.ts +464 -0
- package/tests/trust-kpi.test.ts +120 -0
- package/tests/trust.test.ts +584 -0
package/tests/rules.test.ts
CHANGED
|
@@ -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
|
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } 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
|
+
import { buildReport } from '../src/reporter.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'
|
|
20
|
+
|
|
21
|
+
function createProjectDir(prefix: string): string {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), prefix))
|
|
23
|
+
writeFileSync(join(dir, 'index.ts'), 'export const value = 1\n')
|
|
24
|
+
return dir
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createReport(projectDir: string) {
|
|
28
|
+
const files = analyzeProject(projectDir)
|
|
29
|
+
return buildReport(projectDir, files)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('saas foundations', () => {
|
|
33
|
+
const dirs: string[] = []
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
for (const dir of dirs.splice(0)) {
|
|
37
|
+
rmSync(dir, { recursive: true, force: true })
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('ingest creates snapshot and summary stays consistent', () => {
|
|
42
|
+
const projectDir = createProjectDir('drift-saas-ingest-')
|
|
43
|
+
dirs.push(projectDir)
|
|
44
|
+
const storeFile = join(projectDir, '.drift-cloud', 'store.json')
|
|
45
|
+
|
|
46
|
+
const report = createReport(projectDir)
|
|
47
|
+
const snapshot = ingestSnapshotFromReport(report, {
|
|
48
|
+
workspaceId: 'ws-1',
|
|
49
|
+
userId: 'user-1',
|
|
50
|
+
repoName: 'repo-1',
|
|
51
|
+
storeFile,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const summary = getSaasSummary({ storeFile })
|
|
55
|
+
|
|
56
|
+
expect(snapshot.workspaceId).toBe('ws-1')
|
|
57
|
+
expect(summary.usersRegistered).toBe(1)
|
|
58
|
+
expect(summary.workspacesActive).toBe(1)
|
|
59
|
+
expect(summary.reposActive).toBe(1)
|
|
60
|
+
expect(summary.totalSnapshots).toBe(1)
|
|
61
|
+
expect(summary.phase).toBe('free')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('blocks ingest when workspace exceeds repo limit', () => {
|
|
65
|
+
const projectDir = createProjectDir('drift-saas-repo-limit-')
|
|
66
|
+
dirs.push(projectDir)
|
|
67
|
+
const storeFile = join(projectDir, '.drift-cloud', 'store.json')
|
|
68
|
+
const report = createReport(projectDir)
|
|
69
|
+
|
|
70
|
+
ingestSnapshotFromReport(report, {
|
|
71
|
+
workspaceId: 'ws-2',
|
|
72
|
+
userId: 'user-1',
|
|
73
|
+
repoName: 'repo-a',
|
|
74
|
+
storeFile,
|
|
75
|
+
policy: { maxReposPerWorkspace: 1 },
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(() => {
|
|
79
|
+
ingestSnapshotFromReport(report, {
|
|
80
|
+
workspaceId: 'ws-2',
|
|
81
|
+
userId: 'user-1',
|
|
82
|
+
repoName: 'repo-b',
|
|
83
|
+
storeFile,
|
|
84
|
+
policy: { maxReposPerWorkspace: 1 },
|
|
85
|
+
})
|
|
86
|
+
}).toThrow(/max repos/i)
|
|
87
|
+
|
|
88
|
+
const summary = getSaasSummary({ storeFile, policy: { maxReposPerWorkspace: 1 } })
|
|
89
|
+
expect(summary.totalSnapshots).toBe(1)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('summary reflects free to paid threshold transition', () => {
|
|
93
|
+
const projectDir = createProjectDir('drift-saas-threshold-')
|
|
94
|
+
dirs.push(projectDir)
|
|
95
|
+
const storeFile = join(projectDir, '.drift-cloud', 'store.json')
|
|
96
|
+
const report = createReport(projectDir)
|
|
97
|
+
|
|
98
|
+
ingestSnapshotFromReport(report, {
|
|
99
|
+
workspaceId: 'ws-3',
|
|
100
|
+
userId: 'u-1',
|
|
101
|
+
repoName: 'repo-a',
|
|
102
|
+
storeFile,
|
|
103
|
+
policy: { freeUserThreshold: 2 },
|
|
104
|
+
})
|
|
105
|
+
ingestSnapshotFromReport(report, {
|
|
106
|
+
workspaceId: 'ws-4',
|
|
107
|
+
userId: 'u-2',
|
|
108
|
+
repoName: 'repo-b',
|
|
109
|
+
storeFile,
|
|
110
|
+
policy: { freeUserThreshold: 2 },
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const summary = getSaasSummary({ storeFile, policy: { freeUserThreshold: 2 } })
|
|
114
|
+
expect(summary.usersRegistered).toBe(2)
|
|
115
|
+
expect(summary.thresholdReached).toBe(true)
|
|
116
|
+
expect(summary.phase).toBe('paid')
|
|
117
|
+
expect(summary.freeUsersRemaining).toBe(0)
|
|
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
|
+
})
|
|
464
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { computeTrustKpis } from '../src/trust-kpi.js'
|
|
6
|
+
|
|
7
|
+
describe('trust KPI aggregation', () => {
|
|
8
|
+
let tempDir = ''
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (tempDir) rmSync(tempDir, { recursive: true, force: true })
|
|
12
|
+
tempDir = ''
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('aggregates trust KPIs and diff trends from JSON artifacts', () => {
|
|
16
|
+
tempDir = mkdtempSync(join(tmpdir(), 'drift-kpi-aggregate-'))
|
|
17
|
+
|
|
18
|
+
writeFileSync(join(tempDir, 'trust-a.json'), JSON.stringify({
|
|
19
|
+
trust_score: 80,
|
|
20
|
+
merge_risk: 'LOW',
|
|
21
|
+
diff_context: {
|
|
22
|
+
baseRef: 'origin/main',
|
|
23
|
+
status: 'improved',
|
|
24
|
+
scoreDelta: -5,
|
|
25
|
+
newIssues: 1,
|
|
26
|
+
resolvedIssues: 3,
|
|
27
|
+
filesChanged: 2,
|
|
28
|
+
penalty: 0,
|
|
29
|
+
bonus: 7,
|
|
30
|
+
netImpact: -7,
|
|
31
|
+
},
|
|
32
|
+
}, null, 2))
|
|
33
|
+
|
|
34
|
+
writeFileSync(join(tempDir, 'trust-b.json'), JSON.stringify({
|
|
35
|
+
trust_score: 60,
|
|
36
|
+
merge_risk: 'MEDIUM',
|
|
37
|
+
diff_context: {
|
|
38
|
+
baseRef: 'origin/main',
|
|
39
|
+
status: 'regressed',
|
|
40
|
+
scoreDelta: 8,
|
|
41
|
+
newIssues: 4,
|
|
42
|
+
resolvedIssues: 1,
|
|
43
|
+
filesChanged: 3,
|
|
44
|
+
penalty: 9,
|
|
45
|
+
bonus: 0,
|
|
46
|
+
netImpact: 9,
|
|
47
|
+
},
|
|
48
|
+
}, null, 2))
|
|
49
|
+
|
|
50
|
+
writeFileSync(join(tempDir, 'trust-c.json'), JSON.stringify({
|
|
51
|
+
trust_score: 30,
|
|
52
|
+
merge_risk: 'HIGH',
|
|
53
|
+
}, null, 2))
|
|
54
|
+
|
|
55
|
+
const kpi = computeTrustKpis(tempDir)
|
|
56
|
+
|
|
57
|
+
expect(kpi.files).toEqual({ matched: 3, parsed: 3, malformed: 0 })
|
|
58
|
+
expect(kpi.prsEvaluated).toBe(3)
|
|
59
|
+
expect(kpi.mergeRiskDistribution).toEqual({ LOW: 1, MEDIUM: 1, HIGH: 1, CRITICAL: 0 })
|
|
60
|
+
expect(kpi.highRiskRatio).toBe(0.3333)
|
|
61
|
+
expect(kpi.trustScore).toEqual({ average: 56.67, median: 60, min: 30, max: 80 })
|
|
62
|
+
|
|
63
|
+
expect(kpi.diffTrend.available).toBe(true)
|
|
64
|
+
expect(kpi.diffTrend.samples).toBe(2)
|
|
65
|
+
expect(kpi.diffTrend.statusDistribution).toEqual({ improved: 1, regressed: 1, neutral: 0 })
|
|
66
|
+
expect(kpi.diffTrend.scoreDelta).toEqual({ average: 1.5, median: 1.5 })
|
|
67
|
+
expect(kpi.diffTrend.issues).toEqual({ newTotal: 5, resolvedTotal: 4, netNew: 1 })
|
|
68
|
+
expect(kpi.diagnostics).toEqual([])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('keeps parsing resilient and reports diagnostics for malformed artifacts', () => {
|
|
72
|
+
tempDir = mkdtempSync(join(tmpdir(), 'drift-kpi-parse-'))
|
|
73
|
+
|
|
74
|
+
writeFileSync(join(tempDir, 'valid.json'), JSON.stringify({
|
|
75
|
+
trust_score: 70,
|
|
76
|
+
merge_risk: 'MEDIUM',
|
|
77
|
+
diff_context: {
|
|
78
|
+
scoreDelta: 2,
|
|
79
|
+
newIssues: 3,
|
|
80
|
+
resolvedIssues: 1,
|
|
81
|
+
},
|
|
82
|
+
}, null, 2))
|
|
83
|
+
|
|
84
|
+
writeFileSync(join(tempDir, 'broken.json'), '{"trust_score":70')
|
|
85
|
+
writeFileSync(join(tempDir, 'invalid-shape.json'), JSON.stringify({ trust_score: 70 }, null, 2))
|
|
86
|
+
writeFileSync(join(tempDir, 'bad-diff.json'), JSON.stringify({
|
|
87
|
+
trust_score: 50,
|
|
88
|
+
merge_risk: 'HIGH',
|
|
89
|
+
diff_context: 'oops',
|
|
90
|
+
}, null, 2))
|
|
91
|
+
|
|
92
|
+
const kpi = computeTrustKpis(tempDir)
|
|
93
|
+
|
|
94
|
+
expect(kpi.files.matched).toBe(4)
|
|
95
|
+
expect(kpi.files.parsed).toBe(2)
|
|
96
|
+
expect(kpi.files.malformed).toBe(2)
|
|
97
|
+
expect(kpi.prsEvaluated).toBe(2)
|
|
98
|
+
|
|
99
|
+
const byCode = new Set(kpi.diagnostics.map((diagnostic) => diagnostic.code))
|
|
100
|
+
expect(byCode.has('parse-failed')).toBe(true)
|
|
101
|
+
expect(byCode.has('invalid-shape')).toBe(true)
|
|
102
|
+
expect(byCode.has('invalid-diff-context')).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('supports glob input selection for trust artifacts', () => {
|
|
106
|
+
tempDir = mkdtempSync(join(tmpdir(), 'drift-kpi-glob-'))
|
|
107
|
+
mkdirSync(join(tempDir, 'nested'))
|
|
108
|
+
|
|
109
|
+
writeFileSync(join(tempDir, 'trust-1.json'), JSON.stringify({ trust_score: 90, merge_risk: 'LOW' }))
|
|
110
|
+
writeFileSync(join(tempDir, 'nested', 'trust-2.json'), JSON.stringify({ trust_score: 20, merge_risk: 'CRITICAL' }))
|
|
111
|
+
writeFileSync(join(tempDir, 'other.json'), JSON.stringify({ trust_score: 55, merge_risk: 'MEDIUM' }))
|
|
112
|
+
|
|
113
|
+
const pattern = join(tempDir, '**', 'trust-*.json')
|
|
114
|
+
const kpi = computeTrustKpis(pattern)
|
|
115
|
+
|
|
116
|
+
expect(kpi.files).toEqual({ matched: 2, parsed: 2, malformed: 0 })
|
|
117
|
+
expect(kpi.mergeRiskDistribution).toEqual({ LOW: 1, MEDIUM: 0, HIGH: 0, CRITICAL: 1 })
|
|
118
|
+
expect(kpi.trustScore.average).toBe(55)
|
|
119
|
+
})
|
|
120
|
+
})
|