@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
package/dist/saas.js CHANGED
@@ -1,17 +1,78 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
- const STORE_VERSION = 1;
3
+ const STORE_VERSION = 3;
4
4
  const ACTIVE_WINDOW_DAYS = 30;
5
+ const DEFAULT_ORGANIZATION_ID = 'default-org';
6
+ const VALID_ROLES = ['owner', 'member', 'viewer'];
7
+ const VALID_PLANS = ['free', 'sponsor', 'team', 'business'];
8
+ const ROLE_PRIORITY = { viewer: 1, member: 2, owner: 3 };
9
+ const REQUIRED_ROLE_BY_OPERATION = {
10
+ 'snapshot:write': 'member',
11
+ 'snapshot:read': 'viewer',
12
+ 'summary:read': 'viewer',
13
+ 'billing:write': 'owner',
14
+ 'billing:read': 'viewer',
15
+ };
16
+ export class SaasPermissionError extends Error {
17
+ code = 'SAAS_PERMISSION_DENIED';
18
+ operation;
19
+ organizationId;
20
+ workspaceId;
21
+ actorUserId;
22
+ requiredRole;
23
+ actorRole;
24
+ constructor(context, requiredRole, actorRole) {
25
+ const actor = context.actorUserId ?? 'unknown-actor';
26
+ const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : '';
27
+ const actualRole = actorRole ?? 'none';
28
+ super(`Permission denied for operation '${context.operation}'. actor='${actor}' organization='${context.organizationId}'${workspaceSuffix} requiredRole='${requiredRole}' actualRole='${actualRole}'.`);
29
+ this.name = 'SaasPermissionError';
30
+ this.operation = context.operation;
31
+ this.organizationId = context.organizationId;
32
+ this.workspaceId = context.workspaceId;
33
+ this.actorUserId = context.actorUserId;
34
+ this.requiredRole = requiredRole;
35
+ this.actorRole = actorRole;
36
+ }
37
+ }
38
+ export class SaasActorRequiredError extends Error {
39
+ code = 'SAAS_ACTOR_REQUIRED';
40
+ operation;
41
+ organizationId;
42
+ workspaceId;
43
+ constructor(context) {
44
+ const workspaceSuffix = context.workspaceId ? ` workspace='${context.workspaceId}'` : '';
45
+ super(`Actor is required for operation '${context.operation}'. organization='${context.organizationId}'${workspaceSuffix}.`);
46
+ this.name = 'SaasActorRequiredError';
47
+ this.operation = context.operation;
48
+ this.organizationId = context.organizationId;
49
+ this.workspaceId = context.workspaceId;
50
+ }
51
+ }
5
52
  export const DEFAULT_SAAS_POLICY = {
6
53
  freeUserThreshold: 7500,
7
54
  maxRunsPerWorkspacePerMonth: 500,
8
55
  maxReposPerWorkspace: 20,
9
56
  retentionDays: 90,
57
+ strictActorEnforcement: false,
58
+ maxWorkspacesPerOrganizationByPlan: {
59
+ free: 20,
60
+ sponsor: 50,
61
+ team: 200,
62
+ business: 1000,
63
+ },
10
64
  };
11
65
  export function resolveSaasPolicy(policy) {
66
+ const customPlanLimits = (policy && 'maxWorkspacesPerOrganizationByPlan' in policy)
67
+ ? (policy.maxWorkspacesPerOrganizationByPlan ?? {})
68
+ : {};
12
69
  return {
13
70
  ...DEFAULT_SAAS_POLICY,
14
71
  ...(policy ?? {}),
72
+ maxWorkspacesPerOrganizationByPlan: {
73
+ ...DEFAULT_SAAS_POLICY.maxWorkspacesPerOrganizationByPlan,
74
+ ...customPlanLimits,
75
+ },
15
76
  };
16
77
  }
17
78
  export function defaultSaasStorePath(root = '.') {
@@ -31,11 +92,95 @@ function createEmptyStore(policy) {
31
92
  version: STORE_VERSION,
32
93
  policy: resolveSaasPolicy(policy),
33
94
  users: {},
95
+ organizations: {},
34
96
  workspaces: {},
97
+ memberships: {},
35
98
  repos: {},
36
99
  snapshots: [],
100
+ planChanges: [],
101
+ };
102
+ }
103
+ function normalizePlan(plan) {
104
+ if (!plan)
105
+ return 'free';
106
+ return VALID_PLANS.includes(plan) ? plan : 'free';
107
+ }
108
+ function normalizeRole(role) {
109
+ if (!role)
110
+ return 'member';
111
+ return VALID_ROLES.includes(role) ? role : 'member';
112
+ }
113
+ function hasRoleAtLeast(role, requiredRole) {
114
+ if (!role)
115
+ return false;
116
+ return ROLE_PRIORITY[role] >= ROLE_PRIORITY[requiredRole];
117
+ }
118
+ function resolveActorRole(store, organizationId, actorUserId, workspaceId) {
119
+ if (workspaceId) {
120
+ const scopedMembershipId = membershipKey(organizationId, workspaceId, actorUserId);
121
+ return store.memberships[scopedMembershipId]?.role;
122
+ }
123
+ let highestRole;
124
+ for (const membership of Object.values(store.memberships)) {
125
+ if (membership.organizationId !== organizationId)
126
+ continue;
127
+ if (membership.userId !== actorUserId)
128
+ continue;
129
+ if (!highestRole || ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highestRole]) {
130
+ highestRole = membership.role;
131
+ if (highestRole === 'owner')
132
+ break;
133
+ }
134
+ }
135
+ return highestRole;
136
+ }
137
+ function assertPermissionInStore(store, context) {
138
+ const requiredRole = REQUIRED_ROLE_BY_OPERATION[context.operation];
139
+ if (!context.actorUserId) {
140
+ if (store.policy.strictActorEnforcement) {
141
+ throw new SaasActorRequiredError(context);
142
+ }
143
+ return { requiredRole };
144
+ }
145
+ const actorRole = resolveActorRole(store, context.organizationId, context.actorUserId, context.workspaceId);
146
+ if (!hasRoleAtLeast(actorRole, requiredRole)) {
147
+ throw new SaasPermissionError(context, requiredRole, actorRole);
148
+ }
149
+ return { requiredRole, actorRole };
150
+ }
151
+ export function getRequiredRoleForOperation(operation) {
152
+ return REQUIRED_ROLE_BY_OPERATION[operation];
153
+ }
154
+ export function assertSaasPermission(context) {
155
+ const storeFile = resolve(context.storeFile ?? defaultSaasStorePath());
156
+ const store = loadStoreInternal(storeFile, context.policy);
157
+ return assertPermissionInStore(store, context);
158
+ }
159
+ export function getSaasEffectiveLimits(input) {
160
+ const policy = resolveSaasPolicy(input.policy);
161
+ return {
162
+ plan: input.plan,
163
+ maxWorkspaces: policy.maxWorkspacesPerOrganizationByPlan[input.plan],
164
+ maxReposPerWorkspace: policy.maxReposPerWorkspace,
165
+ maxRunsPerWorkspacePerMonth: policy.maxRunsPerWorkspacePerMonth,
166
+ retentionDays: policy.retentionDays,
37
167
  };
38
168
  }
169
+ export function getOrganizationEffectiveLimits(options) {
170
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
171
+ const store = loadStoreInternal(storeFile, options.policy);
172
+ const plan = normalizePlan(store.organizations[options.organizationId]?.plan);
173
+ return getSaasEffectiveLimits({ plan, policy: store.policy });
174
+ }
175
+ function workspaceKey(organizationId, workspaceId) {
176
+ return `${organizationId}:${workspaceId}`;
177
+ }
178
+ function repoKey(organizationId, workspaceId, repoName) {
179
+ return `${workspaceKey(organizationId, workspaceId)}:${repoName}`;
180
+ }
181
+ function membershipKey(organizationId, workspaceId, userId) {
182
+ return `${workspaceKey(organizationId, workspaceId)}:${userId}`;
183
+ }
39
184
  function monthKey(isoDate) {
40
185
  const date = new Date(isoDate);
41
186
  const month = String(date.getUTCMonth() + 1).padStart(2, '0');
@@ -61,10 +206,47 @@ function loadStoreInternal(storeFile, policy) {
61
206
  const merged = createEmptyStore(parsed.policy);
62
207
  merged.version = parsed.version ?? STORE_VERSION;
63
208
  merged.users = parsed.users ?? {};
209
+ merged.organizations = parsed.organizations ?? {};
64
210
  merged.workspaces = parsed.workspaces ?? {};
211
+ merged.memberships = parsed.memberships ?? {};
65
212
  merged.repos = parsed.repos ?? {};
66
213
  merged.snapshots = parsed.snapshots ?? [];
214
+ merged.planChanges = parsed.planChanges ?? [];
67
215
  merged.policy = resolveSaasPolicy({ ...merged.policy, ...policy });
216
+ for (const workspace of Object.values(merged.workspaces)) {
217
+ if (!workspace.organizationId)
218
+ workspace.organizationId = DEFAULT_ORGANIZATION_ID;
219
+ }
220
+ for (const repo of Object.values(merged.repos)) {
221
+ if (!repo.organizationId)
222
+ repo.organizationId = DEFAULT_ORGANIZATION_ID;
223
+ }
224
+ for (const snapshot of merged.snapshots) {
225
+ if (!snapshot.organizationId)
226
+ snapshot.organizationId = DEFAULT_ORGANIZATION_ID;
227
+ if (!snapshot.plan)
228
+ snapshot.plan = 'free';
229
+ if (!snapshot.role)
230
+ snapshot.role = 'member';
231
+ }
232
+ for (const workspace of Object.values(merged.workspaces)) {
233
+ const orgId = workspace.organizationId;
234
+ const existingOrg = merged.organizations[orgId];
235
+ if (!existingOrg) {
236
+ merged.organizations[orgId] = {
237
+ id: orgId,
238
+ plan: 'free',
239
+ createdAt: workspace.createdAt,
240
+ lastSeenAt: workspace.lastSeenAt,
241
+ workspaceIds: [workspace.id],
242
+ };
243
+ continue;
244
+ }
245
+ if (!existingOrg.workspaceIds.includes(workspace.id))
246
+ existingOrg.workspaceIds.push(workspace.id);
247
+ if (workspace.lastSeenAt > existingOrg.lastSeenAt)
248
+ existingOrg.lastSeenAt = workspace.lastSeenAt;
249
+ }
68
250
  applyRetention(merged);
69
251
  return merged;
70
252
  }
@@ -74,7 +256,28 @@ function isWorkspaceActive(workspace) {
74
256
  function isRepoActive(repo) {
75
257
  return new Date(repo.lastSeenAt).getTime() >= daysAgo(ACTIVE_WINDOW_DAYS);
76
258
  }
259
+ function resolveScopedIdentity(options) {
260
+ const organizationId = options.organizationId ?? DEFAULT_ORGANIZATION_ID;
261
+ const workspaceId = options.workspaceId;
262
+ const repoName = options.repoName ?? 'default';
263
+ return {
264
+ organizationId,
265
+ workspaceId,
266
+ workspaceKey: workspaceKey(organizationId, workspaceId),
267
+ repoName,
268
+ repoId: repoKey(organizationId, workspaceId, repoName),
269
+ };
270
+ }
77
271
  function assertGuardrails(store, options, nowIso) {
272
+ const scoped = resolveScopedIdentity(options);
273
+ const organization = store.organizations[scoped.organizationId];
274
+ const effectivePlan = normalizePlan(options.plan ?? organization?.plan);
275
+ const workspaceLimit = store.policy.maxWorkspacesPerOrganizationByPlan[effectivePlan];
276
+ const workspaceExists = Boolean(store.workspaces[scoped.workspaceKey]);
277
+ const workspaceCount = organization?.workspaceIds.length ?? 0;
278
+ if (!workspaceExists && workspaceCount >= workspaceLimit) {
279
+ throw new Error(`Organization '${scoped.organizationId}' on plan '${effectivePlan}' reached max workspaces (${workspaceLimit}).`);
280
+ }
78
281
  const usersRegistered = Object.keys(store.users).length;
79
282
  const isFreePhase = usersRegistered < store.policy.freeUserThreshold;
80
283
  if (!isFreePhase)
@@ -82,26 +285,146 @@ function assertGuardrails(store, options, nowIso) {
82
285
  if (!store.users[options.userId] && usersRegistered + 1 > store.policy.freeUserThreshold) {
83
286
  throw new Error(`Free threshold reached (${store.policy.freeUserThreshold} users).`);
84
287
  }
85
- const workspace = store.workspaces[options.workspaceId];
86
- const repoName = options.repoName ?? 'default';
87
- const repoId = `${options.workspaceId}:${repoName}`;
88
- const repoExists = Boolean(store.repos[repoId]);
288
+ const workspace = store.workspaces[scoped.workspaceKey];
289
+ const repoExists = Boolean(store.repos[scoped.repoId]);
89
290
  const repoCount = workspace?.repoIds.length ?? 0;
90
291
  if (!repoExists && repoCount >= store.policy.maxReposPerWorkspace) {
91
- throw new Error(`Workspace '${options.workspaceId}' reached max repos (${store.policy.maxReposPerWorkspace}).`);
292
+ throw new Error(`Workspace '${scoped.workspaceId}' reached max repos (${store.policy.maxReposPerWorkspace}).`);
92
293
  }
93
294
  const currentMonth = monthKey(nowIso);
94
295
  const runsThisMonth = store.snapshots.filter((snapshot) => {
95
- return snapshot.workspaceId === options.workspaceId && monthKey(snapshot.createdAt) === currentMonth;
296
+ return (snapshot.organizationId === scoped.organizationId
297
+ && snapshot.workspaceId === scoped.workspaceId
298
+ && monthKey(snapshot.createdAt) === currentMonth);
96
299
  }).length;
97
300
  if (runsThisMonth >= store.policy.maxRunsPerWorkspacePerMonth) {
98
- throw new Error(`Workspace '${options.workspaceId}' reached max monthly runs (${store.policy.maxRunsPerWorkspacePerMonth}).`);
301
+ throw new Error(`Workspace '${scoped.workspaceId}' reached max monthly runs (${store.policy.maxRunsPerWorkspacePerMonth}).`);
302
+ }
303
+ }
304
+ function appendPlanChange(store, input) {
305
+ const change = {
306
+ id: `${input.changedAt}-${Math.random().toString(16).slice(2, 10)}`,
307
+ organizationId: input.organizationId,
308
+ fromPlan: input.fromPlan,
309
+ toPlan: input.toPlan,
310
+ changedAt: input.changedAt,
311
+ changedByUserId: input.changedByUserId,
312
+ reason: input.reason,
313
+ };
314
+ store.planChanges.push(change);
315
+ return change;
316
+ }
317
+ export function changeOrganizationPlan(options) {
318
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
319
+ const store = loadStoreInternal(storeFile, options.policy);
320
+ const nowIso = new Date().toISOString();
321
+ const organization = store.organizations[options.organizationId];
322
+ if (!organization) {
323
+ throw new Error(`Organization '${options.organizationId}' does not exist.`);
99
324
  }
325
+ assertPermissionInStore(store, {
326
+ operation: 'billing:write',
327
+ organizationId: options.organizationId,
328
+ actorUserId: options.actorUserId,
329
+ });
330
+ const nextPlan = normalizePlan(options.newPlan);
331
+ if (organization.plan === nextPlan) {
332
+ const unchanged = appendPlanChange(store, {
333
+ organizationId: organization.id,
334
+ fromPlan: organization.plan,
335
+ toPlan: nextPlan,
336
+ changedAt: nowIso,
337
+ changedByUserId: options.actorUserId,
338
+ reason: options.reason,
339
+ });
340
+ saveStore(storeFile, store);
341
+ return unchanged;
342
+ }
343
+ const previousPlan = organization.plan;
344
+ organization.plan = nextPlan;
345
+ organization.lastSeenAt = nowIso;
346
+ const change = appendPlanChange(store, {
347
+ organizationId: organization.id,
348
+ fromPlan: previousPlan,
349
+ toPlan: nextPlan,
350
+ changedAt: nowIso,
351
+ changedByUserId: options.actorUserId,
352
+ reason: options.reason,
353
+ });
354
+ saveStore(storeFile, store);
355
+ return change;
356
+ }
357
+ export function listOrganizationPlanChanges(options) {
358
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
359
+ const store = loadStoreInternal(storeFile, options.policy);
360
+ assertPermissionInStore(store, {
361
+ operation: 'billing:read',
362
+ organizationId: options.organizationId,
363
+ actorUserId: options.actorUserId,
364
+ });
365
+ return store.planChanges
366
+ .filter((change) => change.organizationId === options.organizationId)
367
+ .sort((a, b) => b.changedAt.localeCompare(a.changedAt));
368
+ }
369
+ export function getOrganizationUsageSnapshot(options) {
370
+ const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
371
+ const store = loadStoreInternal(storeFile, options.policy);
372
+ assertPermissionInStore(store, {
373
+ operation: 'billing:read',
374
+ organizationId: options.organizationId,
375
+ actorUserId: options.actorUserId,
376
+ });
377
+ const organization = store.organizations[options.organizationId];
378
+ if (!organization) {
379
+ throw new Error(`Organization '${options.organizationId}' does not exist.`);
380
+ }
381
+ const month = options.month ?? monthKey(new Date().toISOString());
382
+ const organizationRunSnapshots = store.snapshots.filter((snapshot) => snapshot.organizationId === options.organizationId);
383
+ return {
384
+ organizationId: options.organizationId,
385
+ plan: organization.plan,
386
+ capturedAt: new Date().toISOString(),
387
+ workspaceCount: organization.workspaceIds.length,
388
+ repoCount: organization.workspaceIds
389
+ .map((workspaceId) => store.workspaces[workspaceKey(options.organizationId, workspaceId)])
390
+ .filter((workspace) => Boolean(workspace))
391
+ .reduce((count, workspace) => count + workspace.repoIds.length, 0),
392
+ runCount: organizationRunSnapshots.length,
393
+ runCountThisMonth: organizationRunSnapshots.filter((snapshot) => monthKey(snapshot.createdAt) === month).length,
394
+ };
100
395
  }
101
396
  export function ingestSnapshotFromReport(report, options) {
102
397
  const storeFile = resolve(options.storeFile ?? defaultSaasStorePath());
103
398
  const store = loadStoreInternal(storeFile, options.policy);
104
399
  const nowIso = new Date().toISOString();
400
+ const scoped = resolveScopedIdentity(options);
401
+ const requestedPlan = normalizePlan(options.plan);
402
+ if (store.policy.strictActorEnforcement && !options.actorUserId) {
403
+ throw new SaasActorRequiredError({
404
+ operation: 'snapshot:write',
405
+ organizationId: scoped.organizationId,
406
+ workspaceId: scoped.workspaceId,
407
+ });
408
+ }
409
+ const workspaceExists = Boolean(store.workspaces[scoped.workspaceKey]);
410
+ const organizationExists = Boolean(store.organizations[scoped.organizationId]);
411
+ if (options.actorUserId) {
412
+ if (workspaceExists) {
413
+ assertPermissionInStore(store, {
414
+ operation: 'snapshot:write',
415
+ organizationId: scoped.organizationId,
416
+ workspaceId: scoped.workspaceId,
417
+ actorUserId: options.actorUserId,
418
+ });
419
+ }
420
+ else if (organizationExists) {
421
+ assertPermissionInStore(store, {
422
+ operation: 'billing:write',
423
+ organizationId: scoped.organizationId,
424
+ actorUserId: options.actorUserId,
425
+ });
426
+ }
427
+ }
105
428
  assertGuardrails(store, options, nowIso);
106
429
  const user = store.users[options.userId];
107
430
  if (user) {
@@ -114,47 +437,108 @@ export function ingestSnapshotFromReport(report, options) {
114
437
  lastSeenAt: nowIso,
115
438
  };
116
439
  }
117
- const workspace = store.workspaces[options.workspaceId];
440
+ const existingOrg = store.organizations[scoped.organizationId];
441
+ const plan = normalizePlan(existingOrg?.plan ?? requestedPlan);
442
+ if (existingOrg) {
443
+ existingOrg.lastSeenAt = nowIso;
444
+ if (options.plan && existingOrg.plan !== requestedPlan) {
445
+ if (options.actorUserId) {
446
+ assertPermissionInStore(store, {
447
+ operation: 'billing:write',
448
+ organizationId: scoped.organizationId,
449
+ actorUserId: options.actorUserId,
450
+ });
451
+ }
452
+ const previousPlan = existingOrg.plan;
453
+ existingOrg.plan = requestedPlan;
454
+ appendPlanChange(store, {
455
+ organizationId: scoped.organizationId,
456
+ fromPlan: previousPlan,
457
+ toPlan: requestedPlan,
458
+ changedAt: nowIso,
459
+ changedByUserId: options.actorUserId ?? options.userId,
460
+ reason: 'ingest-option-plan-change',
461
+ });
462
+ }
463
+ }
464
+ else {
465
+ store.organizations[scoped.organizationId] = {
466
+ id: scoped.organizationId,
467
+ plan,
468
+ createdAt: nowIso,
469
+ lastSeenAt: nowIso,
470
+ workspaceIds: [],
471
+ };
472
+ }
473
+ const workspace = store.workspaces[scoped.workspaceKey];
118
474
  if (workspace) {
119
475
  workspace.lastSeenAt = nowIso;
120
476
  if (!workspace.userIds.includes(options.userId))
121
477
  workspace.userIds.push(options.userId);
122
478
  }
123
479
  else {
124
- store.workspaces[options.workspaceId] = {
125
- id: options.workspaceId,
480
+ store.workspaces[scoped.workspaceKey] = {
481
+ id: scoped.workspaceId,
482
+ organizationId: scoped.organizationId,
126
483
  createdAt: nowIso,
127
484
  lastSeenAt: nowIso,
128
485
  userIds: [options.userId],
129
486
  repoIds: [],
130
487
  };
488
+ const org = store.organizations[scoped.organizationId];
489
+ if (!org.workspaceIds.includes(scoped.workspaceId))
490
+ org.workspaceIds.push(scoped.workspaceId);
131
491
  }
132
- const repoName = options.repoName ?? 'default';
133
- const repoId = `${options.workspaceId}:${repoName}`;
134
- const repo = store.repos[repoId];
492
+ const membershipId = membershipKey(scoped.organizationId, scoped.workspaceId, options.userId);
493
+ const membership = store.memberships[membershipId];
494
+ let role = normalizeRole(options.role);
495
+ if (!membership && !workspace)
496
+ role = 'owner';
497
+ if (membership) {
498
+ membership.lastSeenAt = nowIso;
499
+ if (options.role)
500
+ membership.role = normalizeRole(options.role);
501
+ role = membership.role;
502
+ }
503
+ else {
504
+ store.memberships[membershipId] = {
505
+ id: membershipId,
506
+ organizationId: scoped.organizationId,
507
+ workspaceId: scoped.workspaceId,
508
+ userId: options.userId,
509
+ role,
510
+ createdAt: nowIso,
511
+ lastSeenAt: nowIso,
512
+ };
513
+ }
514
+ const repo = store.repos[scoped.repoId];
135
515
  if (repo) {
136
516
  repo.lastSeenAt = nowIso;
137
517
  }
138
518
  else {
139
- store.repos[repoId] = {
140
- id: repoId,
141
- workspaceId: options.workspaceId,
142
- name: repoName,
519
+ store.repos[scoped.repoId] = {
520
+ id: scoped.repoId,
521
+ organizationId: scoped.organizationId,
522
+ workspaceId: scoped.workspaceId,
523
+ name: scoped.repoName,
143
524
  createdAt: nowIso,
144
525
  lastSeenAt: nowIso,
145
526
  };
146
- const ws = store.workspaces[options.workspaceId];
147
- if (!ws.repoIds.includes(repoId))
148
- ws.repoIds.push(repoId);
527
+ const ws = store.workspaces[scoped.workspaceKey];
528
+ if (!ws.repoIds.includes(scoped.repoId))
529
+ ws.repoIds.push(scoped.repoId);
149
530
  }
150
531
  const snapshot = {
151
532
  id: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
152
533
  createdAt: nowIso,
153
534
  scannedAt: report.scannedAt,
154
- workspaceId: options.workspaceId,
535
+ organizationId: scoped.organizationId,
536
+ workspaceId: scoped.workspaceId,
155
537
  userId: options.userId,
156
- repoId,
157
- repoName,
538
+ role,
539
+ plan: normalizePlan(store.organizations[scoped.organizationId]?.plan ?? requestedPlan),
540
+ repoId: scoped.repoId,
541
+ repoName: scoped.repoName,
158
542
  targetPath: report.targetPath,
159
543
  totalScore: report.totalScore,
160
544
  totalIssues: report.totalIssues,
@@ -170,15 +554,69 @@ export function ingestSnapshotFromReport(report, options) {
170
554
  saveStore(storeFile, store);
171
555
  return snapshot;
172
556
  }
557
+ function matchesTenantScope(snapshot, options) {
558
+ if (!options?.organizationId && !options?.workspaceId)
559
+ return true;
560
+ if (options.organizationId && snapshot.organizationId !== options.organizationId)
561
+ return false;
562
+ if (options.workspaceId && snapshot.workspaceId !== options.workspaceId)
563
+ return false;
564
+ return true;
565
+ }
566
+ export function listSaasSnapshots(options) {
567
+ const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath());
568
+ const store = loadStoreInternal(storeFile, options?.policy);
569
+ const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId);
570
+ if (options?.actorUserId || shouldEnforceActorForScope) {
571
+ const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID;
572
+ assertPermissionInStore(store, {
573
+ operation: 'snapshot:read',
574
+ organizationId,
575
+ workspaceId: options?.workspaceId,
576
+ actorUserId: options?.actorUserId,
577
+ });
578
+ }
579
+ saveStore(storeFile, store);
580
+ return store.snapshots
581
+ .filter((snapshot) => matchesTenantScope(snapshot, options))
582
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
583
+ }
173
584
  export function getSaasSummary(options) {
174
585
  const storeFile = resolve(options?.storeFile ?? defaultSaasStorePath());
175
586
  const store = loadStoreInternal(storeFile, options?.policy);
587
+ const shouldEnforceActorForScope = store.policy.strictActorEnforcement && Boolean(options?.organizationId || options?.workspaceId);
588
+ if (options?.actorUserId || shouldEnforceActorForScope) {
589
+ const organizationId = options?.organizationId ?? DEFAULT_ORGANIZATION_ID;
590
+ assertPermissionInStore(store, {
591
+ operation: 'summary:read',
592
+ organizationId,
593
+ workspaceId: options?.workspaceId,
594
+ actorUserId: options?.actorUserId,
595
+ });
596
+ }
176
597
  saveStore(storeFile, store);
177
- const usersRegistered = Object.keys(store.users).length;
178
- const workspacesActive = Object.values(store.workspaces).filter(isWorkspaceActive).length;
179
- const reposActive = Object.values(store.repos).filter(isRepoActive).length;
598
+ const scopedSnapshots = store.snapshots.filter((snapshot) => matchesTenantScope(snapshot, options));
599
+ const scopedWorkspaces = Object.values(store.workspaces).filter((workspace) => {
600
+ if (options?.organizationId && workspace.organizationId !== options.organizationId)
601
+ return false;
602
+ if (options?.workspaceId && workspace.id !== options.workspaceId)
603
+ return false;
604
+ return true;
605
+ });
606
+ const scopedRepos = Object.values(store.repos).filter((repo) => {
607
+ if (options?.organizationId && repo.organizationId !== options.organizationId)
608
+ return false;
609
+ if (options?.workspaceId && repo.workspaceId !== options.workspaceId)
610
+ return false;
611
+ return true;
612
+ });
613
+ const usersRegistered = options?.organizationId || options?.workspaceId
614
+ ? new Set(scopedSnapshots.map((snapshot) => snapshot.userId)).size
615
+ : Object.keys(store.users).length;
616
+ const workspacesActive = scopedWorkspaces.filter(isWorkspaceActive).length;
617
+ const reposActive = scopedRepos.filter(isRepoActive).length;
180
618
  const runsPerMonth = {};
181
- for (const snapshot of store.snapshots) {
619
+ for (const snapshot of scopedSnapshots) {
182
620
  const key = monthKey(snapshot.createdAt);
183
621
  runsPerMonth[key] = (runsPerMonth[key] ?? 0) + 1;
184
622
  }
@@ -189,7 +627,7 @@ export function getSaasSummary(options) {
189
627
  workspacesActive,
190
628
  reposActive,
191
629
  runsPerMonth,
192
- totalSnapshots: store.snapshots.length,
630
+ totalSnapshots: scopedSnapshots.length,
193
631
  phase: thresholdReached ? 'paid' : 'free',
194
632
  thresholdReached,
195
633
  freeUsersRemaining: Math.max(0, store.policy.freeUserThreshold - usersRegistered),
@@ -209,13 +647,16 @@ export function generateSaasDashboardHtml(options) {
209
647
  const summary = getSaasSummary(options);
210
648
  const workspaceStats = Object.values(store.workspaces)
211
649
  .map((workspace) => {
212
- const snapshots = store.snapshots.filter((snapshot) => snapshot.workspaceId === workspace.id);
650
+ const snapshots = store.snapshots.filter((snapshot) => {
651
+ return snapshot.organizationId === workspace.organizationId && snapshot.workspaceId === workspace.id;
652
+ });
213
653
  const runs = snapshots.length;
214
654
  const avgScore = runs === 0
215
655
  ? 0
216
656
  : Math.round(snapshots.reduce((sum, snapshot) => sum + snapshot.totalScore, 0) / runs);
217
657
  const lastRun = snapshots.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]?.createdAt ?? 'n/a';
218
658
  return {
659
+ organizationId: workspace.organizationId,
219
660
  id: workspace.id,
220
661
  runs,
221
662
  avgScore,
@@ -247,7 +688,7 @@ export function generateSaasDashboardHtml(options) {
247
688
  })
248
689
  .join('');
249
690
  const workspaceRows = workspaceStats
250
- .map((workspace) => `<tr><td>${escapeHtml(workspace.id)}</td><td>${workspace.runs}</td><td>${workspace.avgScore}</td><td>${escapeHtml(workspace.lastRun)}</td></tr>`)
691
+ .map((workspace) => `<tr><td>${escapeHtml(workspace.organizationId)}</td><td>${escapeHtml(workspace.id)}</td><td>${workspace.runs}</td><td>${workspace.avgScore}</td><td>${escapeHtml(workspace.lastRun)}</td></tr>`)
251
692
  .join('');
252
693
  const repoRows = repoStats
253
694
  .map((repo) => `<tr><td>${escapeHtml(repo.workspaceId)}</td><td>${escapeHtml(repo.name)}</td><td>${repo.runs}</td><td>${repo.avgScore}</td></tr>`)
@@ -300,12 +741,12 @@ export function generateSaasDashboardHtml(options) {
300
741
  </section>
301
742
 
302
743
  <section class="section">
303
- <h2>Workspace Hotspots</h2>
304
- <table>
305
- <thead><tr><th>Workspace</th><th>Runs</th><th>Avg Score</th><th>Last Run</th></tr></thead>
306
- <tbody>${workspaceRows || '<tr><td colspan="4">No workspace data</td></tr>'}</tbody>
307
- </table>
308
- </section>
744
+ <h2>Workspace Hotspots</h2>
745
+ <table>
746
+ <thead><tr><th>Organization</th><th>Workspace</th><th>Runs</th><th>Avg Score</th><th>Last Run</th></tr></thead>
747
+ <tbody>${workspaceRows || '<tr><td colspan="5">No workspace data</td></tr>'}</tbody>
748
+ </table>
749
+ </section>
309
750
 
310
751
  <section class="section">
311
752
  <h2>Repo Hotspots</h2>
@@ -0,0 +1,9 @@
1
+ import type { DriftTrustReport, TrustKpiReport } from './types.js';
2
+ export interface TrustKpiOptions {
3
+ cwd?: string;
4
+ }
5
+ export declare function computeTrustKpis(input: string, options?: TrustKpiOptions): TrustKpiReport;
6
+ export declare function formatTrustKpiConsole(kpi: TrustKpiReport): string;
7
+ export declare function formatTrustKpiJson(kpi: TrustKpiReport): string;
8
+ export declare function computeTrustKpisFromReports(reports: DriftTrustReport[]): TrustKpiReport;
9
+ //# sourceMappingURL=trust-kpi.d.ts.map