filegrc 0.4.0 → 0.5.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/src/setup.js CHANGED
@@ -18,7 +18,10 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
18
18
  const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, entry.revision]));
19
19
 
20
20
  await applyResourceBatch(loaded.root, {
21
- create: plan.existingSystem ? [] : [plan.system],
21
+ create: [
22
+ ...(plan.existingSystem ? [] : [plan.system]),
23
+ ...(plan.commitment ? [plan.commitment] : [])
24
+ ],
22
25
  update: updates,
23
26
  expectedRevisions: Object.fromEntries(updates.map((record) => [record.id, revisionById.get(record.id)])),
24
27
  validateWholeWorkspace: true
@@ -29,6 +32,7 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
29
32
  system: plan.system,
30
33
  workspace: plan.workspace,
31
34
  renderer: plan.renderer,
35
+ commitment: plan.commitment,
32
36
  linkedControlIds: [],
33
37
  onboardingComplete: !setup.draft
34
38
  };
@@ -47,11 +51,13 @@ export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
47
51
  system: plan.existingSystem ? "update" : "create",
48
52
  workspace: "update",
49
53
  renderer: plan.renderer ? "update" : "unchanged",
50
- controls: 0
54
+ controls: 0,
55
+ commitment: plan.commitment ? "create" : "unchanged"
51
56
  },
52
57
  system: setupSystemSummary(plan.system),
53
58
  target: setupTargetSummary(plan.workspace),
54
59
  renderer: plan.renderer ? setupRendererSummary(plan.renderer) : null,
60
+ commitment: plan.commitment || null,
55
61
  onboardingComplete: !setup.draft
56
62
  };
57
63
  }
@@ -64,11 +70,13 @@ export function summarizeSetupResult(result) {
64
70
  changes: {
65
71
  system: "saved",
66
72
  workspace: "updated",
67
- controls: result.linkedControlIds?.length || 0
73
+ controls: result.linkedControlIds?.length || 0,
74
+ commitment: result.commitment ? "saved" : "unchanged"
68
75
  },
69
76
  system: setupSystemSummary(result.system),
70
77
  target: setupTargetSummary(result.workspace),
71
78
  renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
79
+ commitment: result.commitment || null,
72
80
  onboardingComplete: result.onboardingComplete
73
81
  };
74
82
  }
@@ -168,7 +176,31 @@ function buildSetupRecords(loaded, setup) {
168
176
  };
169
177
  const existingRenderer = loaded.resources.find(({ type }) => type === "renderer-settings");
170
178
  const renderer = existingRenderer ? { ...existingRenderer, showOnboarding: setup.draft } : null;
171
- return { existingSystem, system, workspace, renderer };
179
+ const existingCommitment = loaded.resources.find((record) => (
180
+ record.type === "commitment"
181
+ && !["superseded", "retired"].includes(record.status)
182
+ && (record.systemIds || []).includes(systemId)
183
+ ));
184
+ const commitment = String(loaded.model.modelVersion) === "3" && !existingCommitment
185
+ ? {
186
+ id: createResourceId(
187
+ "commitment",
188
+ `${setup.serviceName} service commitment`,
189
+ loaded.resources.map(({ id }) => id)
190
+ ),
191
+ type: "commitment",
192
+ title: `${setup.serviceName} service commitment`,
193
+ status: "planned",
194
+ commitmentKind: "service",
195
+ statement: "Replace this starter with the actual customer promise or approved service requirement before activation.",
196
+ systemIds: [systemId],
197
+ ownerIds: [setup.ownerId],
198
+ customerFacing: true,
199
+ ...(workspace.requirementIds?.length ? { requirementIds: [...workspace.requirementIds] } : {}),
200
+ ...(workspace.controlIds?.length ? { controlIds: [...workspace.controlIds] } : {})
201
+ }
202
+ : null;
203
+ return { existingSystem, system, workspace, renderer, commitment };
172
204
  }
173
205
 
174
206
  function assuranceGoalFromSetup(goal) {
@@ -0,0 +1,61 @@
1
+ export function sourceCoverageComplete(record, loaded) {
2
+ if (!record?.validFrom || !record.collectionCadence || !record.retention || !record.reconciliationMethod) {
3
+ return false;
4
+ }
5
+ if (record.coverageKind === "external-system" && (!record.systemId || !(record.retrieverIds || []).length)) {
6
+ return false;
7
+ }
8
+ if (["not-applicable", "zero-population"].includes(record.coverageKind) && !record.applicabilityReview) {
9
+ return false;
10
+ }
11
+ if (loaded.workspace?.candidateCoverage && !(record.readinessTestEvidenceIds || []).length) {
12
+ return false;
13
+ }
14
+ if (loaded.workspace?.candidateCoverage) {
15
+ const tests = (record.readinessTestEvidenceIds || []).map((id) => (
16
+ loaded.resources.find((resource) => resource.id === id && resource.type === "evidence")
17
+ ));
18
+ if (tests.some((test) => (
19
+ !test
20
+ || test.readinessTest !== true
21
+ || test.retrievalResult !== "passed"
22
+ || test.accessConfirmed !== true
23
+ || !(test.coveredSourceFamilyIds || []).includes(record.sourceFamilyId)
24
+ || (record.coverageKind === "external-system" && !(
25
+ test.sourceSystemId === record.systemId
26
+ || (test.systemIds || []).includes(record.systemId)
27
+ ))
28
+ ))) return false;
29
+ }
30
+ return true;
31
+ }
32
+
33
+ export function assessSourceCoverageReadiness(loaded, selectedControlIds = []) {
34
+ if (!loaded.model.resources["source-coverage"]) return [];
35
+ const selected = new Set(selectedControlIds);
36
+ const selectedControlCodes = new Set(loaded.resources
37
+ .filter((record) => (
38
+ record.type === "control"
39
+ && selected.has(record.id)
40
+ && !["not-applicable", "retired"].includes(record.status)
41
+ ))
42
+ .map(({ code }) => code)
43
+ .filter(Boolean));
44
+ return (loaded.model.evidenceSourceFamilies || [])
45
+ .filter((family) => family.controlCodes.some((code) => selectedControlCodes.has(code)))
46
+ .map((family) => {
47
+ const records = loaded.resources.filter((record) => (
48
+ record.type === "source-coverage"
49
+ && record.sourceFamilyId === family.id
50
+ && record.status !== "retired"
51
+ ));
52
+ const record = records.find(({ status }) => status === "active")
53
+ || records.find(({ status }) => status === "planned")
54
+ || null;
55
+ return {
56
+ family,
57
+ record,
58
+ complete: Boolean(record?.status === "active" && sourceCoverageComplete(record, loaded))
59
+ };
60
+ });
61
+ }
package/src/state.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { assessAuditPreparation } from "./audit-preparation.js";
4
+ import { assessCollectionReviews } from "./collection-review.js";
4
5
  import { getBrowserRepositoryState, getGitSummary, getWorkspaceHistories } from "./git.js";
5
6
  import { renderMarkdown } from "./markdown.js";
6
7
  import { planObligations } from "./obligations.js";
@@ -10,6 +11,7 @@ import { markdownEntries } from "./resource-markdown.js";
10
11
  import { currentCalendarDate } from "./time.js";
11
12
  import { serializeWorkspaceMutation } from "./mutation.js";
12
13
  import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
14
+ import { assessWorkflow } from "./workflow.js";
13
15
 
14
16
  const renderedMarkdownCache = new Map();
15
17
  const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
@@ -73,6 +75,38 @@ async function createAppStateUnlocked(input, options) {
73
75
  return [audit?.id || "none", preparation];
74
76
  })
75
77
  ));
78
+ const obligations = planObligations(entries, {
79
+ asOf,
80
+ now: options.now ?? generatedAt,
81
+ model: loaded.model
82
+ });
83
+ const workflow = await assessWorkflow(loaded, {
84
+ asOf,
85
+ evaluatedAt: generatedAt,
86
+ programReadiness,
87
+ auditPreparations: Object.fromEntries(
88
+ Object.entries(auditPreparations).filter(([id]) => id !== "none")
89
+ ),
90
+ obligations,
91
+ git,
92
+ validation
93
+ });
94
+ const collectionReviews = Object.fromEntries(
95
+ assessCollectionReviews(loaded).map((assessment) => [
96
+ assessment.resourceType,
97
+ {
98
+ resourceType: assessment.resourceType,
99
+ configuration: assessment.configuration,
100
+ recordCount: assessment.recordCount,
101
+ review: assessment.review,
102
+ reviewRevision: assessment.reviewRevision,
103
+ collectionRevision: assessment.collectionRevision,
104
+ status: assessment.status,
105
+ complete: assessment.complete,
106
+ message: assessment.message
107
+ }
108
+ ])
109
+ );
76
110
  return {
77
111
  generatedAt,
78
112
  asOf,
@@ -86,9 +120,11 @@ async function createAppStateUnlocked(input, options) {
86
120
  counts: validation.counts,
87
121
  diagnostics: validation.diagnostics
88
122
  },
89
- obligations: planObligations(entries, { asOf, now: options.now ?? generatedAt, model: loaded.model }),
123
+ obligations,
124
+ collectionReviews,
90
125
  programReadiness,
91
126
  auditPreparations,
127
+ workflow,
92
128
  git
93
129
  };
94
130
  }
package/src/validate.js CHANGED
@@ -73,7 +73,13 @@ async function validateWorkspaceUnmeasured(input) {
73
73
  validateRecord(record, definition, loaded.model, displayPath, diagnostics);
74
74
  validateDateRanges(record, displayPath, diagnostics);
75
75
  if (record.type === "appointment") validateAppointment(record, byId, displayPath, diagnostics);
76
+ if (record.type === "collection-review") {
77
+ validateCollectionReview(record, loaded.model, loaded.resources, byId, displayPath, diagnostics);
78
+ }
76
79
  if (record.type === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
80
+ if (record.type === "action-item") {
81
+ validateCompletedObligationAction(record, byId, loaded.model, displayPath, diagnostics);
82
+ }
77
83
  if (record.type === "obligation-event") validatePolicyEvent(record, loaded.model, byId, displayPath, diagnostics);
78
84
  if (record.type === "evidence") validateEvidencePaths(record, displayPath, diagnostics);
79
85
  validateCoverage(record, displayPath, diagnostics);
@@ -148,6 +154,46 @@ async function validateWorkspaceUnmeasured(input) {
148
154
  };
149
155
  }
150
156
 
157
+ function validateCollectionReview(record, model, resources, byId, path, diagnostics) {
158
+ if (record.status !== "active") return;
159
+ const configuration = model.collectionReviews?.[record.resourceType];
160
+ if (!configuration) return;
161
+ const allowedDecisions = configuration.decisions || ["complete"];
162
+ if (!allowedDecisions.includes(record.decision)) {
163
+ diagnostics.push(error(
164
+ "invalid-collection-review-decision",
165
+ path,
166
+ `${configuration.title} review must use one of: ${allowedDecisions.join(", ")}.`
167
+ ));
168
+ return;
169
+ }
170
+ const recordCount = resources.filter(({ type }) => type === record.resourceType).length;
171
+ if (!recordCount && record.decision === "complete") {
172
+ diagnostics.push(error(
173
+ "invalid-collection-review-decision",
174
+ path,
175
+ `${configuration.title} has no records and cannot use the complete conclusion.`
176
+ ));
177
+ }
178
+ if (recordCount && record.decision === "zero-population") {
179
+ diagnostics.push(error(
180
+ "invalid-collection-review-decision",
181
+ path,
182
+ `${configuration.title} has ${recordCount} records and cannot use the zero-population conclusion.`
183
+ ));
184
+ }
185
+ if (
186
+ record.decision === "externally-managed"
187
+ && byId.get(record.authoritativeSystemId)?.status !== "active"
188
+ ) {
189
+ diagnostics.push(error(
190
+ "inactive-authoritative-system",
191
+ path,
192
+ `${configuration.title} must name an active authoritative System for an externally managed conclusion.`
193
+ ));
194
+ }
195
+ }
196
+
151
197
  export async function fingerprintWorkspace(input = process.cwd()) {
152
198
  const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
153
199
  const hash = createHash("sha256");
@@ -288,7 +334,10 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
288
334
  ? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
289
335
  : [];
290
336
  if (!expectedTypes.length) continue;
291
- const linked = [...new Set([...(action.completionResourceIds || []), ...(action.evidenceIds || [])])]
337
+ const completionIds = String(model.modelVersion) === "3"
338
+ ? action.completionResourceIds || []
339
+ : [...(action.completionResourceIds || []), ...(action.evidenceIds || [])];
340
+ const linked = [...new Set(completionIds)]
292
341
  .map((id) => byId.get(id))
293
342
  .filter(Boolean);
294
343
  if (!linked.some((item) => expectedTypes.includes(item.type))) {
@@ -301,6 +350,27 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
301
350
  }
302
351
  }
303
352
 
353
+ function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
354
+ if (
355
+ String(model.modelVersion) !== "3"
356
+ || record.status !== "done"
357
+ || !record.obligationId
358
+ ) return;
359
+ const obligation = byId.get(record.obligationId);
360
+ if (obligation?.type !== "obligation") return;
361
+ const expectedTypes = model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || [];
362
+ if (!expectedTypes.length) return;
363
+ const linked = [...new Set(record.completionResourceIds || [])]
364
+ .map((id) => byId.get(id))
365
+ .filter(Boolean);
366
+ if (linked.some((item) => expectedTypes.includes(item.type))) return;
367
+ diagnostics.push(error(
368
+ "missing-obligation-completion",
369
+ path,
370
+ `A done Action Item linked to "${obligation.id}" needs completionResourceIds containing ${expectedTypes.join(" or ")}.`
371
+ ));
372
+ }
373
+
304
374
  function validateEvidencePaths(record, path, diagnostics) {
305
375
  const expectedPrefix = `evidence/${record.id}/`;
306
376
  for (const filePath of record.filePaths || []) {
@@ -381,6 +451,23 @@ function validateObligation(record, model, byId, path, diagnostics) {
381
451
  "Event recurrence must use a policy event defined by the model."
382
452
  ));
383
453
  }
454
+ if (record.eventRiskLevels?.length) {
455
+ if (recurrence.eventType !== "person-ended") {
456
+ diagnostics.push(error(
457
+ "invalid-obligation-event-filter",
458
+ path,
459
+ "eventRiskLevels may be used only with the person-ended Policy Event."
460
+ ));
461
+ }
462
+ const invalid = record.eventRiskLevels.filter((value) => !["normal", "high"].includes(value));
463
+ if (invalid.length) {
464
+ diagnostics.push(error(
465
+ "invalid-obligation-event-filter",
466
+ path,
467
+ `Departure risk filters must be normal or high, not ${invalid.join(", ")}.`
468
+ ));
469
+ }
470
+ }
384
471
  } else {
385
472
  diagnostics.push(error(
386
473
  "invalid-obligation-recurrence",
@@ -577,7 +664,8 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn
577
664
  }
578
665
 
579
666
  async function validateApprovalBinding(record, model, root, path, diagnostics) {
580
- if (!approvalBound(record) || !record.approvedContentRevisions) return;
667
+ const bindingField = approvalBindingField(record, model);
668
+ if (!bindingField || !approvalBound(record) || !record[bindingField]) return;
581
669
  const actual = {};
582
670
  for (const item of markdownEntries(model, record)) {
583
671
  try {
@@ -587,7 +675,7 @@ async function validateApprovalBinding(record, model, root, path, diagnostics) {
587
675
  if (error.code !== "ENOENT") throw error;
588
676
  }
589
677
  }
590
- const expected = record.approvedContentRevisions;
678
+ const expected = record[bindingField];
591
679
  const paths = [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort();
592
680
  const invalid = paths.filter((item) => (
593
681
  !/^[a-f0-9]{64}$/.test(String(expected[item] || ""))
@@ -605,9 +693,18 @@ async function validateApprovalBinding(record, model, root, path, diagnostics) {
605
693
  function approvalBound(record) {
606
694
  if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
607
695
  if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
696
+ if (record.type === "training") return ["active", "retired"].includes(record.status);
608
697
  return false;
609
698
  }
610
699
 
700
+ function approvalBindingField(record, model) {
701
+ if (["policy", "document"].includes(record.type)) return "approvedContentRevisions";
702
+ if (record.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
703
+ return "effectiveContentRevisions";
704
+ }
705
+ return null;
706
+ }
707
+
611
708
  function validateCoverage(record, path, diagnostics) {
612
709
  const coverage = record.type === "workspace" ? record.candidateCoverage : record.coverage;
613
710
  if (!coverage) return;