@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.
- package/.github/workflows/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +98 -6
- package/AGENTS.md +6 -0
- package/README.md +160 -10
- 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 +453 -62
- package/dist/diff.js +74 -10
- package/dist/git.js +12 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -1
- 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 +143 -7
- package/dist/saas.js +478 -37
- 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 +154 -0
- package/docs/PRD.md +187 -109
- package/docs/plugin-contract.md +61 -0
- package/docs/trust-core-release-checklist.md +55 -0
- package/package.json +5 -3
- package/src/analyzer.ts +484 -155
- package/src/benchmark.ts +244 -0
- package/src/cli.ts +562 -79
- package/src/diff.ts +75 -10
- package/src/git.ts +16 -0
- package/src/index.ts +48 -0
- 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 +641 -43
- package/src/trust-kpi.ts +518 -0
- package/src/trust.ts +774 -0
- package/src/types.ts +171 -0
- package/tests/diff.test.ts +124 -0
- package/tests/new-features.test.ts +71 -0
- package/tests/plugins.test.ts +219 -0
- package/tests/rules.test.ts +23 -1
- package/tests/saas-foundation.test.ts +358 -1
- package/tests/trust-kpi.test.ts +120 -0
- 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 =
|
|
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[
|
|
86
|
-
const
|
|
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 '${
|
|
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.
|
|
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 '${
|
|
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
|
|
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[
|
|
125
|
-
id:
|
|
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
|
|
133
|
-
const
|
|
134
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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[
|
|
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
|
-
|
|
535
|
+
organizationId: scoped.organizationId,
|
|
536
|
+
workspaceId: scoped.workspaceId,
|
|
155
537
|
userId: options.userId,
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
178
|
-
const
|
|
179
|
-
|
|
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
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
304
|
-
|
|
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="
|
|
307
|
-
|
|
308
|
-
|
|
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
|