filegrc 0.9.2 → 0.11.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.
@@ -0,0 +1,261 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { contentRevision } from "./files.js";
3
+ import { resolveDataPath } from "./paths.js";
4
+ import { programComponents } from "./program.js";
5
+ import { markdownEntries } from "./resource-markdown.js";
6
+
7
+ export async function assessRetentionReadiness(loaded, program, options = {}) {
8
+ if (!loaded.model.resources["retention-schedule-item"]) return [];
9
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
10
+ const rules = loaded.resources.filter((record) => (
11
+ record.type === "retention-schedule-item" && record.status === "active"
12
+ ));
13
+ const revisions = await resourceReviewRevisions(loaded, rules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
14
+ const usableRules = rules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded));
15
+ const uses = retentionUses(loaded, program);
16
+ const items = rules.filter((rule) => !usableRules.includes(rule)).map((rule) => readinessItem(
17
+ `retention-rule-${rule.id}`,
18
+ "action",
19
+ `Review ${rule.title}`,
20
+ "This active retention schedule item is incomplete or is not bound to every current source revision. Review it before relying on its period or disposition behavior.",
21
+ rule,
22
+ {
23
+ sourceResourceIds: retentionReviewResourceIds(rule, loaded),
24
+ commands: [
25
+ `npx filegrc get ${rule.id} --mutation`,
26
+ `npx filegrc review-bindings ${rule.id} --json`,
27
+ ...(rule.sourceResourceIds || [])
28
+ .filter((id) => ["policy", "document", "framework", "requirement", "commitment"].includes(byId.get(id)?.type))
29
+ .map((id) => `npx filegrc program-amendment ${id} --json`)
30
+ ]
31
+ }
32
+ ));
33
+ items.push(...uses.map((use) => {
34
+ const matches = usableRules.filter((rule) => ruleCoversUse(rule, use, program));
35
+ return readinessItem(
36
+ `retention-use-${use.resource.id}-${use.informationTypeId}`,
37
+ matches.length ? "complete" : "action",
38
+ `Decide retention for ${byId.get(use.informationTypeId)?.title || use.informationTypeId}`,
39
+ matches.length
40
+ ? `${use.resource.title} is covered by ${matches.map(({ title }) => title).join(", ")}.`
41
+ : `${use.resource.title} uses this Information Type, but no active, current retention schedule item covers both the type and scope. Management must choose the cutoff, period, and disposition.`,
42
+ use.resource,
43
+ {
44
+ informationTypeId: use.informationTypeId,
45
+ retentionScheduleItemIds: matches.map(({ id }) => id),
46
+ commands: [
47
+ "npx filegrc guide retention-schedule-item --json",
48
+ `npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${byId.get(use.informationTypeId)?.title || use.informationTypeId}`)}`
49
+ ]
50
+ }
51
+ );
52
+ }));
53
+
54
+ for (const coverage of loaded.resources.filter((record) => record.type === "source-coverage" && record.status === "active")) {
55
+ const linked = (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter(Boolean);
56
+ const matching = linked.filter((rule) => (
57
+ rule.type === "retention-schedule-item"
58
+ && rule.status === "active"
59
+ && usableRules.includes(rule)
60
+ && (rule.scopeResourceIds || []).includes(coverage.id)
61
+ ));
62
+ items.push(readinessItem(
63
+ `retention-source-coverage-${coverage.id}`,
64
+ matching.length ? "complete" : "action",
65
+ `Confirm retained evidence for ${coverage.title}`,
66
+ matching.length
67
+ ? `The source-coverage record references a current schedule item scoped to this population.`
68
+ : `The source-coverage record must reference an active, current schedule item whose scope includes ${coverage.id}. A draft schedule or unrelated rule does not satisfy this check.`,
69
+ coverage,
70
+ {
71
+ retentionScheduleItemIds: matching.map(({ id }) => id),
72
+ commands: [
73
+ `npx filegrc get ${coverage.id} --mutation`,
74
+ "npx filegrc list retention-schedule-item --workflow --json"
75
+ ]
76
+ }
77
+ ));
78
+ }
79
+
80
+ const duplicates = nearDuplicateInformationTypes(loaded.resources);
81
+ if (duplicates.length) {
82
+ items.push(readinessItem(
83
+ "retention-information-type-duplicates",
84
+ options.informationTypesReviewed ? "complete" : "action",
85
+ "Review similar Information Types",
86
+ options.informationTypesReviewed
87
+ ? `${duplicates.length} similar pair${duplicates.length === 1 ? " was" : "s were"} included in the current Information Type inventory review. FileGRC did not merge records or rewrite relationships.`
88
+ : `${duplicates.length} similar pair${duplicates.length === 1 ? " needs" : "s need"} management review. FileGRC will not merge records or rewrite relationships automatically.`,
89
+ { type: "information-type" },
90
+ {
91
+ candidates: duplicates,
92
+ commands: [
93
+ "npx filegrc list information-type --workflow --json",
94
+ "npx filegrc review-collection information-type --scaffold"
95
+ ]
96
+ }
97
+ ));
98
+ }
99
+ return items;
100
+ }
101
+
102
+ export async function resourceReviewRevision(loaded, resourceId) {
103
+ return (await resourceReviewRevisions(loaded, [resourceId])).get(resourceId) || null;
104
+ }
105
+
106
+ export async function resourceReviewRevisions(loaded, ids) {
107
+ const wanted = new Set(ids);
108
+ const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
109
+ const revisions = new Map();
110
+ const reviewing = new Set();
111
+ const review = async (id) => {
112
+ if (revisions.has(id)) return revisions.get(id);
113
+ const entry = entries.get(id);
114
+ if (!entry || reviewing.has(id)) return null;
115
+ reviewing.add(id);
116
+ const parts = [entry.source];
117
+ for (const markdown of markdownEntries(loaded.model, entry.record)) {
118
+ try {
119
+ parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
120
+ } catch (error) {
121
+ if (error.code !== "ENOENT") throw error;
122
+ }
123
+ }
124
+ for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
125
+ const revision = await review(sourceId);
126
+ if (revision) parts.push(`${sourceId}:${revision}`);
127
+ }
128
+ reviewing.delete(id);
129
+ const revision = contentRevision(parts.join("\n"));
130
+ revisions.set(id, revision);
131
+ return revision;
132
+ };
133
+ for (const id of wanted) {
134
+ await review(id);
135
+ }
136
+ return new Map([...revisions].filter(([id]) => wanted.has(id)));
137
+ }
138
+
139
+ export function retentionUses(loaded, program) {
140
+ const systemIds = new Set(program.systemIds || []);
141
+ const componentIds = new Set(programComponents(loaded, program).map(({ id }) => id));
142
+ const vendorIds = new Set(program.vendorIds || loaded.resources
143
+ .filter((record) => record.type === "vendor" && record.status !== "retired")
144
+ .map(({ id }) => id));
145
+ const uses = [];
146
+ for (const record of loaded.resources) {
147
+ if (record.type === "system" && systemIds.has(record.id)) {
148
+ for (const informationTypeId of record.informationTypeIds || []) uses.push({ resource: record, informationTypeId });
149
+ }
150
+ if (record.type === "component" && componentIds.has(record.id)) {
151
+ for (const use of record.informationUses || []) uses.push({ resource: record, informationTypeId: use.informationTypeId });
152
+ }
153
+ if (record.type === "vendor" && vendorIds.has(record.id)) {
154
+ for (const informationTypeId of record.informationTypeIds || []) uses.push({ resource: record, informationTypeId });
155
+ }
156
+ }
157
+ return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
158
+ }
159
+
160
+ export function nearDuplicateInformationTypes(records) {
161
+ const types = records.filter((record) => record.type === "information-type" && !["retired", "superseded"].includes(record.status));
162
+ const pairs = [];
163
+ for (let leftIndex = 0; leftIndex < types.length; leftIndex += 1) {
164
+ for (let rightIndex = leftIndex + 1; rightIndex < types.length; rightIndex += 1) {
165
+ const left = types[leftIndex];
166
+ const right = types[rightIndex];
167
+ const score = similarity(normalize(left.title), normalize(right.title));
168
+ if (score >= 0.8) pairs.push({ leftId: left.id, rightId: right.id, score });
169
+ }
170
+ }
171
+ return pairs;
172
+ }
173
+
174
+ function ruleCoversUse(rule, use, program) {
175
+ if (!(rule.informationTypeIds || []).includes(use.informationTypeId)) return false;
176
+ const scope = new Set(rule.scopeResourceIds || []);
177
+ return scope.has(use.resource.id) || scope.has(program.id);
178
+ }
179
+
180
+ export function retentionReviewResourceIds(rule, loaded) {
181
+ const programUseIds = [];
182
+ if (loaded) {
183
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
184
+ const informationTypeIds = new Set(rule.informationTypeIds || []);
185
+ for (const scopeId of rule.scopeResourceIds || []) {
186
+ const program = byId.get(scopeId);
187
+ if (program?.type !== "program") continue;
188
+ for (const use of retentionUses(loaded, program)) {
189
+ if (informationTypeIds.has(use.informationTypeId)) programUseIds.push(use.resource.id);
190
+ }
191
+ }
192
+ }
193
+ return [...new Set([
194
+ rule.scheduleDocumentId,
195
+ ...(rule.sourceResourceIds || []),
196
+ ...(rule.informationTypeIds || []),
197
+ ...(rule.scopeResourceIds || []),
198
+ ...programUseIds
199
+ ].filter(Boolean))];
200
+ }
201
+
202
+ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded) {
203
+ if (!String(rule.description || "").trim()) return false;
204
+ if (!(rule.informationTypeIds || []).length || !(rule.scopeResourceIds || []).length || !rule.scheduleDocumentId) return false;
205
+ const schedule = byId.get(rule.scheduleDocumentId);
206
+ if (schedule?.type !== "document" || schedule.documentKind !== "schedule" || schedule.workflowScope !== "program" || ["superseded", "retired"].includes(schedule.status)) return false;
207
+ if (!(rule.ownerIds || []).length || !(rule.approvedByIds || []).length || !rule.approvedOn) return false;
208
+ if (!rule.reviewedSourceRevisions || typeof rule.reviewedSourceRevisions !== "object") return false;
209
+ if (!retentionCutoffIsComplete(rule.cutoff) || !retentionPeriodIsComplete(rule.retentionPeriod)) return false;
210
+ if (!["delete", "destroy", "erase", "anonymize", "transfer", "retain-permanently"].includes(rule.dispositionAction)) return false;
211
+ if (!String(rule.dispositionInstructions || "").trim()) return false;
212
+ const dependencyIds = retentionReviewResourceIds(rule, loaded);
213
+ if (Object.keys(rule.reviewedSourceRevisions).length !== dependencyIds.length) return false;
214
+ return dependencyIds.every((id) => (
215
+ revisions.get(id) && rule.reviewedSourceRevisions?.[id] === revisions.get(id)
216
+ ));
217
+ }
218
+
219
+ function shellArgument(value) {
220
+ const text = String(value);
221
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(text)
222
+ ? text
223
+ : `'${text.replaceAll("'", "'\\''")}'`;
224
+ }
225
+
226
+ function retentionCutoffIsComplete(cutoff) {
227
+ if (!cutoff || !["creation", "receipt", "calendar-year-end", "fiscal-year-end", "event"].includes(cutoff.basis)) return false;
228
+ return cutoff.basis !== "event" || Boolean(String(cutoff.event || "").trim());
229
+ }
230
+
231
+ function retentionPeriodIsComplete(period) {
232
+ if (!period || !["fixed", "until-event", "permanent"].includes(period.basis)) return false;
233
+ if (period.basis === "fixed") {
234
+ return Number.isInteger(period.amount) && period.amount >= 1 && ["day", "month", "year"].includes(period.unit);
235
+ }
236
+ return period.basis !== "until-event" || Boolean(String(period.event || "").trim());
237
+ }
238
+
239
+ function normalize(value) {
240
+ return new Set(String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().split(/\s+/).filter(Boolean).map((word) => (
241
+ word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word
242
+ )));
243
+ }
244
+
245
+ function similarity(left, right) {
246
+ if (!left.size || !right.size) return 0;
247
+ const intersection = [...left].filter((word) => right.has(word)).length;
248
+ return Number((intersection / new Set([...left, ...right]).size).toFixed(2));
249
+ }
250
+
251
+ function readinessItem(id, status, title, message, resource = {}, details = {}) {
252
+ return {
253
+ id,
254
+ status,
255
+ title,
256
+ message,
257
+ ...(resource.type ? { resourceType: resource.type } : {}),
258
+ ...(resource.id ? { resourceId: resource.id } : {}),
259
+ ...details
260
+ };
261
+ }
package/src/server.js CHANGED
@@ -46,7 +46,8 @@ import {
46
46
  import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
47
47
  import { activatePolicies } from "./policy-activation.js";
48
48
  import { applyReconciliation, planReconciliation } from "./reconciliation.js";
49
- import { createAppState, createResourceDetail } from "./state.js";
49
+ import { resourceReviewRevisions } from "./retention.js";
50
+ import { createAppBootstrap, createAppState, createAppStateSection, createResourceDetail } from "./state.js";
50
51
  import { setupWorkspace } from "./setup.js";
51
52
  import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
52
53
  import {
@@ -59,6 +60,8 @@ import { loadWorkspace } from "./workspace.js";
59
60
  import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
60
61
 
61
62
  export function createFilegrcServer(input = process.cwd(), options = {}) {
63
+ const stateSessions = new Map();
64
+ let nextStateSession = 0;
62
65
  return createHttpServer(async (request, response) => {
63
66
  const requestStarted = performance.now();
64
67
  if (timingEnabled()) {
@@ -94,6 +97,31 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
94
97
  includeDetails: false
95
98
  }));
96
99
  }
100
+ if (request.method === "GET" && url.pathname === "/api/state/bootstrap") {
101
+ const loaded = await serializeWorkspaceMutation(input, (root) => loadWorkspace(root));
102
+ const token = `${Date.now().toString(36)}-${(++nextStateSession).toString(36)}`;
103
+ const session = {
104
+ loaded,
105
+ generatedAt: new Date().toISOString(),
106
+ promises: new Map()
107
+ };
108
+ stateSessions.set(token, session);
109
+ while (stateSessions.size > 8) stateSessions.delete(stateSessions.keys().next().value);
110
+ const state = await createAppBootstrap(loaded, { generatedAt: session.generatedAt });
111
+ state.stateToken = token;
112
+ return json(response, 200, state);
113
+ }
114
+ if (request.method === "GET" && url.pathname.startsWith("/api/state/")) {
115
+ const section = url.pathname.slice("/api/state/".length);
116
+ if (!["repository", "program", "obligations", "audits", "workflow"].includes(section)) {
117
+ return json(response, 404, { error: "Unknown app-state section." });
118
+ }
119
+ const token = url.searchParams.get("token");
120
+ const session = stateSessions.get(token);
121
+ if (!session) return json(response, 409, { error: "The workspace state expired. Reload it and try again." });
122
+ const state = await loadStateSessionSection(session, section, options);
123
+ return json(response, 200, { stateToken: token, section, state });
124
+ }
97
125
  if (request.method === "GET" && url.pathname === "/api/history") {
98
126
  const path = url.searchParams.get("path");
99
127
  if (!path || path.includes("..") || !path.startsWith("data/")) return json(response, 400, { error: "A safe data path is required." });
@@ -319,7 +347,8 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
319
347
  const payload = await readJson(request);
320
348
  const completeSetup = async () => {
321
349
  return browserMutation(input, options, {
322
- message: (setupResult) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${setupResult.workspace.organizationName}`
350
+ message: (setupResult) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${setupResult.workspace.organizationName}`,
351
+ fastResponse: prefersFastMutation(request)
323
352
  }, () => setupWorkspace(input, payload));
324
353
  };
325
354
  return json(response, 200, await completeSetup());
@@ -389,6 +418,17 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
389
418
  readOnly: repository.mode === "trunk" && !repository.writesAllowed
390
419
  });
391
420
  }
421
+ if (request.method === "GET" && url.pathname === "/api/review-revisions") {
422
+ const ids = [...new Set(url.searchParams.getAll("id"))];
423
+ if (!ids.length || ids.some((id) => !safeSegment(id))) {
424
+ return json(response, 400, { error: "Pass one or more safe resource IDs." });
425
+ }
426
+ const loaded = await loadWorkspace(input);
427
+ const revisions = await resourceReviewRevisions(loaded, ids);
428
+ const missing = ids.filter((id) => !revisions.has(id));
429
+ if (missing.length) return json(response, 404, { error: `Resources not found: ${missing.join(", ")}.` });
430
+ return json(response, 200, { revisions: Object.fromEntries(revisions) });
431
+ }
392
432
  if (request.method === "PUT" && url.pathname === "/api/content") {
393
433
  const payload = await readJson(request);
394
434
  const result = await browserMutation(input, options, {
@@ -593,6 +633,40 @@ export function reconcileMutationSynchronization(synchronization, repository) {
593
633
  };
594
634
  }
595
635
 
636
+ function loadStateSessionSection(session, section, serverOptions) {
637
+ if (session.promises.has(section)) return session.promises.get(section);
638
+ const dependencies = section === "workflow"
639
+ ? Promise.all([
640
+ loadStateSessionSection(session, "repository", serverOptions),
641
+ loadStateSessionSection(session, "program", serverOptions),
642
+ loadStateSessionSection(session, "obligations", serverOptions),
643
+ loadStateSessionSection(session, "audits", serverOptions)
644
+ ])
645
+ : section === "audits"
646
+ ? Promise.all([loadStateSessionSection(session, "program", serverOptions)])
647
+ : Promise.resolve([]);
648
+ const promise = dependencies.then((results) => {
649
+ const repository = section === "workflow" ? results[0] : null;
650
+ const program = section === "workflow" ? results[1] : section === "audits" ? results[0] : null;
651
+ const obligations = section === "workflow" ? results[2] : null;
652
+ const audits = section === "workflow" ? results[3] : null;
653
+ return createAppStateSection(session.loaded, section, {
654
+ allowNonAuthoritativeWrites: serverOptions.allowNonAuthoritativeWrites,
655
+ generatedAt: session.generatedAt,
656
+ programReadiness: program?.programReadiness,
657
+ auditPreparations: audits?.auditPreparations,
658
+ obligations: obligations?.obligations,
659
+ git: repository?.git,
660
+ validation: repository?.validation
661
+ });
662
+ }).catch((error) => {
663
+ session.promises.delete(section);
664
+ throw error;
665
+ });
666
+ session.promises.set(section, promise);
667
+ return promise;
668
+ }
669
+
596
670
  function prefersFastMutation(request) {
597
671
  return String(request.headers.prefer || "")
598
672
  .split(",")
package/src/setup.js CHANGED
@@ -81,7 +81,7 @@ export function summarizeSetupResult(result) {
81
81
  },
82
82
  system: setupSystemSummary(result.system),
83
83
  target: setupTargetSummary(result.program || result.workspace, {
84
- modelVersion: result.workspace?.dataModelVersion || (result.program ? "6" : "3")
84
+ modelVersion: result.workspace?.dataModelVersion || (result.program ? "7" : "3")
85
85
  }),
86
86
  renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
87
87
  commitment: result.commitment || null,
@@ -1,7 +1,21 @@
1
- export function sourceCoverageComplete(record, loaded, program = loaded.workspace) {
2
- if (!record?.validFrom || !record.collectionCadence || !record.retention || !record.reconciliationMethod) {
1
+ import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
2
+
3
+ export async function sourceCoverageComplete(record, loaded, program = loaded.workspace) {
4
+ const structuredRetention = loaded.model.resources["retention-schedule-item"];
5
+ if (!record?.validFrom || !record.collectionCadence || !record.reconciliationMethod) {
3
6
  return false;
4
7
  }
8
+ if (structuredRetention) {
9
+ const linkedRules = (record.retentionScheduleItemIds || []).map((id) => (
10
+ loaded.resources.find((resource) => resource.id === id && resource.type === "retention-schedule-item")
11
+ ));
12
+ const revisions = await resourceReviewRevisions(loaded, linkedRules.filter(Boolean).flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
13
+ if (!linkedRules.some((rule) => (
14
+ rule?.status === "active"
15
+ && (rule.scopeResourceIds || []).includes(record.id)
16
+ && retentionRuleIsCurrent(rule, revisions, new Map(loaded.resources.map((resource) => [resource.id, resource])), loaded)
17
+ ))) return false;
18
+ } else if (!record.retention) return false;
5
19
  const external = record.coverageKind === "external-system" || record.coverageKind === "external-component";
6
20
  const sourceId = record.componentId || record.systemId;
7
21
  if (external && (!sourceId || !(record.retrieverIds || []).length)) {
@@ -34,7 +48,7 @@ export function sourceCoverageComplete(record, loaded, program = loaded.workspac
34
48
  return true;
35
49
  }
36
50
 
37
- export function assessSourceCoverageReadiness(loaded, selectedControlIds = [], program = loaded.workspace) {
51
+ export async function assessSourceCoverageReadiness(loaded, selectedControlIds = [], program = loaded.workspace) {
38
52
  if (!loaded.model.resources["source-coverage"]) return [];
39
53
  const selected = new Set(selectedControlIds);
40
54
  const selectedControlCodes = new Set(loaded.resources
@@ -45,9 +59,9 @@ export function assessSourceCoverageReadiness(loaded, selectedControlIds = [], p
45
59
  ))
46
60
  .map(({ code }) => code)
47
61
  .filter(Boolean));
48
- return (loaded.model.evidenceSourceFamilies || [])
62
+ return Promise.all((loaded.model.evidenceSourceFamilies || [])
49
63
  .filter((family) => family.controlCodes.some((code) => selectedControlCodes.has(code)))
50
- .map((family) => {
64
+ .map(async (family) => {
51
65
  const records = loaded.resources.filter((record) => (
52
66
  record.type === "source-coverage"
53
67
  && record.sourceFamilyId === family.id
@@ -59,7 +73,7 @@ export function assessSourceCoverageReadiness(loaded, selectedControlIds = [], p
59
73
  return {
60
74
  family,
61
75
  record,
62
- complete: Boolean(record?.status === "active" && sourceCoverageComplete(record, loaded, program))
76
+ complete: Boolean(record?.status === "active" && await sourceCoverageComplete(record, loaded, program))
63
77
  };
64
- });
78
+ }));
65
79
  }
package/src/state.js CHANGED
@@ -37,6 +37,174 @@ export async function createAppState(input = process.cwd(), options = {}) {
37
37
  return promise;
38
38
  }
39
39
 
40
+ export async function createAppBootstrap(input = process.cwd(), options = {}) {
41
+ const loaded = input?.entries && input?.root ? input : await loadWorkspace(input);
42
+ const workspace = loaded.workspace ?? {
43
+ dataModelVersion: loaded.model.modelVersion,
44
+ id: "workspace",
45
+ type: "workspace",
46
+ title: "filegrc workspace",
47
+ organizationName: "Workspace configuration unavailable",
48
+ timezone: "UTC"
49
+ };
50
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
51
+ return {
52
+ generatedAt,
53
+ asOf: options.asOf ?? currentCalendarDate(workspace.timezone),
54
+ readOnly: true,
55
+ repository: {
56
+ loading: true,
57
+ mode: null,
58
+ status: "loading",
59
+ label: "Checking Git",
60
+ writesAllowed: false
61
+ },
62
+ workspace,
63
+ model: loaded.model,
64
+ resources: loaded.entries.map((entry) => ({
65
+ record: structuredClone(entry.record),
66
+ relativePath: `data/${entry.relativePath}`,
67
+ revision: contentRevision(entry.source),
68
+ content: {},
69
+ history: undefined,
70
+ detailsLoaded: false
71
+ })),
72
+ validation: {
73
+ loading: true,
74
+ ok: null,
75
+ counts: { errors: 0, warnings: 0 },
76
+ diagnostics: []
77
+ },
78
+ obligations: { loading: true, items: [], triggers: [], counts: {} },
79
+ collectionReviews: {},
80
+ applicabilityConstraints: {},
81
+ programReadiness: null,
82
+ auditPreparations: {},
83
+ workflow: { loading: true, findings: [], workItems: [], assessments: {} },
84
+ git: {
85
+ loading: true,
86
+ available: false,
87
+ clean: null,
88
+ changes: [],
89
+ branch: null,
90
+ shortCommit: "checking"
91
+ },
92
+ sections: {
93
+ repository: "loading",
94
+ program: "idle",
95
+ obligations: "idle",
96
+ audits: "idle",
97
+ workflow: "idle"
98
+ }
99
+ };
100
+ }
101
+
102
+ export async function createAppStateSection(input, section, options = {}) {
103
+ const loaded = input?.entries && input?.root ? input : await loadWorkspace(input);
104
+ const workspace = loaded.workspace;
105
+ const asOf = options.asOf ?? currentCalendarDate(workspace?.timezone || "UTC");
106
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
107
+ if (section === "repository") {
108
+ const [validation, snapshot] = await Promise.all([
109
+ validateWorkspace(loaded),
110
+ measureTiming("state-repository-snapshot", () => getRepositorySnapshot(loaded.root))
111
+ ]);
112
+ const git = { ...snapshot };
113
+ delete git.root;
114
+ const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded, {
115
+ readOnly: options.readOnly,
116
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
117
+ repositorySnapshot: git
118
+ }));
119
+ return {
120
+ generatedAt,
121
+ readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
122
+ repository,
123
+ git,
124
+ validation: {
125
+ ok: validation.ok,
126
+ counts: validation.counts,
127
+ diagnostics: validation.diagnostics
128
+ }
129
+ };
130
+ }
131
+ if (section === "program") {
132
+ const programReadiness = options.programReadiness ?? await assessProgramReadiness(loaded, { asOf, generatedAt });
133
+ const activeProgram = resolveProgram(loaded);
134
+ return {
135
+ generatedAt,
136
+ asOf,
137
+ programReadiness,
138
+ collectionReviews: Object.fromEntries(
139
+ assessCollectionReviews(loaded).map((assessment) => [
140
+ assessment.resourceType,
141
+ {
142
+ resourceType: assessment.resourceType,
143
+ configuration: assessment.configuration,
144
+ recordCount: assessment.recordCount,
145
+ review: assessment.review,
146
+ reviewRevision: assessment.reviewRevision,
147
+ collectionRevision: assessment.collectionRevision,
148
+ status: assessment.status,
149
+ complete: assessment.complete,
150
+ message: assessment.message
151
+ }
152
+ ])
153
+ ),
154
+ applicabilityConstraints: Object.fromEntries(loaded.resources.flatMap((record) => {
155
+ const constraint = soc2RequirementApplicabilityConstraint(record, activeProgram, loaded.model.modelVersion);
156
+ return constraint ? [[record.id, constraint]] : [];
157
+ }))
158
+ };
159
+ }
160
+ if (section === "obligations") {
161
+ return {
162
+ generatedAt,
163
+ asOf,
164
+ obligations: planObligations(loaded.resources, {
165
+ asOf,
166
+ now: options.now ?? generatedAt,
167
+ model: loaded.model
168
+ })
169
+ };
170
+ }
171
+ if (section === "audits") {
172
+ const programReadiness = options.programReadiness;
173
+ const audits = loaded.resources.filter((record) => record.type === "audit");
174
+ return {
175
+ generatedAt,
176
+ asOf,
177
+ auditPreparations: Object.fromEntries(await Promise.all(
178
+ (audits.length ? audits : [null]).map(async (audit) => [
179
+ audit?.id || "none",
180
+ await assessAuditPreparation(loaded, {
181
+ auditId: audit?.id,
182
+ asOf,
183
+ generatedAt,
184
+ ...(audit || !programReadiness ? {} : { programReadiness })
185
+ })
186
+ ])
187
+ ))
188
+ };
189
+ }
190
+ if (section === "workflow") {
191
+ return {
192
+ generatedAt,
193
+ asOf,
194
+ workflow: await assessWorkflow(loaded, {
195
+ asOf,
196
+ evaluatedAt: generatedAt,
197
+ programReadiness: options.programReadiness,
198
+ auditPreparations: options.auditPreparations,
199
+ obligations: options.obligations,
200
+ git: options.git,
201
+ validation: options.validation
202
+ })
203
+ };
204
+ }
205
+ throw new Error(`Unknown app-state section "${section}".`);
206
+ }
207
+
40
208
  async function createAppStateUnlocked(input, options) {
41
209
  let validation;
42
210
  if (options.validationProof) {
@@ -65,7 +233,7 @@ async function createAppStateUnlocked(input, options) {
65
233
 
66
234
  const git = { ...await measureTiming("state-repository-snapshot", () => getRepositorySnapshot(loaded.root)) };
67
235
  delete git.root;
68
- const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded.root, {
236
+ const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded, {
69
237
  readOnly: options.readOnly,
70
238
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
71
239
  repositorySnapshot: git
@@ -94,8 +262,9 @@ async function createAppStateUnlocked(input, options) {
94
262
  (audits.length ? audits : [null]).map(async (audit) => {
95
263
  const preparation = await assessAuditPreparation(loaded, {
96
264
  auditId: audit?.id,
265
+ asOf,
97
266
  generatedAt,
98
- programReadiness
267
+ ...(audit ? {} : { programReadiness })
99
268
  });
100
269
  return [audit?.id || "none", preparation];
101
270
  })