@eduardbar/drift 1.2.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.
Files changed (61) hide show
  1. package/.github/workflows/publish-vscode.yml +3 -3
  2. package/.github/workflows/publish.yml +3 -3
  3. package/.github/workflows/review-pr.yml +98 -6
  4. package/AGENTS.md +6 -0
  5. package/README.md +160 -10
  6. package/ROADMAP.md +6 -5
  7. package/dist/analyzer.d.ts +2 -2
  8. package/dist/analyzer.js +420 -159
  9. package/dist/benchmark.d.ts +2 -0
  10. package/dist/benchmark.js +185 -0
  11. package/dist/cli.js +453 -62
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.js +3 -1
  16. package/dist/plugins.d.ts +2 -1
  17. package/dist/plugins.js +177 -28
  18. package/dist/printer.js +4 -0
  19. package/dist/review.js +2 -2
  20. package/dist/rules/comments.js +2 -2
  21. package/dist/rules/complexity.js +2 -7
  22. package/dist/rules/nesting.js +3 -13
  23. package/dist/rules/phase0-basic.js +10 -10
  24. package/dist/rules/shared.d.ts +2 -0
  25. package/dist/rules/shared.js +27 -3
  26. package/dist/saas.d.ts +143 -7
  27. package/dist/saas.js +478 -37
  28. package/dist/trust-kpi.d.ts +9 -0
  29. package/dist/trust-kpi.js +445 -0
  30. package/dist/trust.d.ts +65 -0
  31. package/dist/trust.js +571 -0
  32. package/dist/types.d.ts +154 -0
  33. package/docs/PRD.md +187 -109
  34. package/docs/plugin-contract.md +61 -0
  35. package/docs/trust-core-release-checklist.md +55 -0
  36. package/package.json +5 -3
  37. package/src/analyzer.ts +484 -155
  38. package/src/benchmark.ts +244 -0
  39. package/src/cli.ts +562 -79
  40. package/src/diff.ts +75 -10
  41. package/src/git.ts +16 -0
  42. package/src/index.ts +48 -0
  43. package/src/plugins.ts +354 -26
  44. package/src/printer.ts +4 -0
  45. package/src/review.ts +2 -2
  46. package/src/rules/comments.ts +2 -2
  47. package/src/rules/complexity.ts +2 -7
  48. package/src/rules/nesting.ts +3 -13
  49. package/src/rules/phase0-basic.ts +11 -12
  50. package/src/rules/shared.ts +31 -3
  51. package/src/saas.ts +641 -43
  52. package/src/trust-kpi.ts +518 -0
  53. package/src/trust.ts +774 -0
  54. package/src/types.ts +171 -0
  55. package/tests/diff.test.ts +124 -0
  56. package/tests/new-features.test.ts +71 -0
  57. package/tests/plugins.test.ts +219 -0
  58. package/tests/rules.test.ts +23 -1
  59. package/tests/saas-foundation.test.ts +358 -1
  60. package/tests/trust-kpi.test.ts +120 -0
  61. package/tests/trust.test.ts +584 -0
@@ -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
  })
@@ -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
+ })