filegrc 0.7.0 → 0.8.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/validate.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile, stat } from "node:fs/promises";
3
3
  import { performance } from "node:perf_hooks";
4
- import { getResourceDefinition } from "../model/index.js";
4
+ import { getResourceDefinition, modelSupports } from "../model/index.js";
5
5
  import { scopedCollectionRecords } from "./collection-scope.js";
6
6
  import { collectionRevision } from "./collection-revision.js";
7
7
  import { isSafeGitName } from "./git-name.js";
@@ -12,6 +12,7 @@ import { partyPeople } from "./parties.js";
12
12
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
13
13
  import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
14
14
  import { recordTiming } from "./timing.js";
15
+ import { personWasActiveOn } from "./soc2.js";
15
16
  import { indexResources, loadWorkspace } from "./workspace.js";
16
17
 
17
18
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -147,6 +148,9 @@ async function validateWorkspaceUnmeasured(input) {
147
148
  pathById,
148
149
  diagnostics
149
150
  );
151
+ if (modelSupports(loaded.model, "document-workflow-scope")) {
152
+ validateDocumentWorkflowScopes(loaded.resources, loaded.model, byId, pathById, diagnostics);
153
+ }
150
154
 
151
155
  diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
152
156
  return {
@@ -161,6 +165,116 @@ async function validateWorkspaceUnmeasured(input) {
161
165
  };
162
166
  }
163
167
 
168
+ function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagnostics) {
169
+ const managementFields = [
170
+ "engagementTermsDocumentId",
171
+ ...(model.auditReadiness?.managementDocuments || []).map(({ field }) => field)
172
+ ];
173
+ const auditReferences = new Map();
174
+ const governedAuditReferences = new Map();
175
+ const addAuditReference = (map, documentId, audit, field) => {
176
+ if (!documentId) return;
177
+ if (!map.has(documentId)) map.set(documentId, []);
178
+ map.get(documentId).push({ audit, field });
179
+ };
180
+ for (const audit of resources.filter(({ type }) => type === "audit")) {
181
+ for (const field of managementFields) {
182
+ const documentId = audit[field];
183
+ if (!documentId) continue;
184
+ addAuditReference(auditReferences, documentId, audit, field);
185
+ addAuditReference(governedAuditReferences, documentId, audit, field);
186
+ const document = byId.get(documentId);
187
+ if (document?.type === "document" && document.workflowScope !== "engagement") {
188
+ diagnostics.push(error(
189
+ "invalid-document-workflow-scope",
190
+ pathById.get(audit.id) || `data/${audit.id}`,
191
+ `${field} must reference an engagement-scoped Document; "${document.title}" is scoped to the program workflow.`
192
+ ));
193
+ }
194
+ }
195
+ for (const documentId of audit.supplementalDocumentIds || []) {
196
+ addAuditReference(auditReferences, documentId, audit, "supplementalDocumentIds");
197
+ }
198
+ }
199
+
200
+ const programDocumentReferences = new Map();
201
+ const addProgramReference = (documentId, record, field) => {
202
+ if (!documentId) return;
203
+ if (!programDocumentReferences.has(documentId)) programDocumentReferences.set(documentId, []);
204
+ programDocumentReferences.get(documentId).push({ record, field });
205
+ };
206
+ for (const policy of resources.filter(({ type }) => type === "policy")) {
207
+ for (const documentId of policy.relatedDocumentIds || []) addProgramReference(documentId, policy, "relatedDocumentIds");
208
+ }
209
+ for (const obligation of resources.filter(({ type }) => type === "obligation")) {
210
+ for (const documentId of obligation.scopeResourceIds || []) {
211
+ if (byId.get(documentId)?.type === "document") addProgramReference(documentId, obligation, "scopeResourceIds");
212
+ }
213
+ if (byId.get(obligation.templateResourceId)?.type === "document") {
214
+ addProgramReference(obligation.templateResourceId, obligation, "templateResourceId");
215
+ }
216
+ }
217
+
218
+ for (const document of resources.filter(({ type }) => type === "document")) {
219
+ const path = pathById.get(document.id) || `data/${document.id}`;
220
+ const auditRefs = auditReferences.get(document.id) || [];
221
+ const auditIds = new Set(auditRefs.map(({ audit }) => audit.id));
222
+ const programRefs = programDocumentReferences.get(document.id) || [];
223
+ if (document.workflowScope === "engagement" && programRefs.length) {
224
+ const references = programRefs.map(({ record, field }) => `${record.id}.${field}`).join(", ");
225
+ diagnostics.push(error(
226
+ "engagement-document-in-program-workflow",
227
+ path,
228
+ `Engagement-scoped Document "${document.title}" cannot govern reusable program work through ${references}. Split the engagement deliverable from the program Document.`
229
+ ));
230
+ }
231
+ if (
232
+ document.workflowScope === "engagement"
233
+ && ["approved", "active"].includes(document.status)
234
+ && auditIds.size !== 1
235
+ ) {
236
+ diagnostics.push(error(
237
+ "invalid-engagement-document-audit-count",
238
+ path,
239
+ `Approved or active engagement Document "${document.title}" must belong to exactly one Audit; found ${auditIds.size}.`
240
+ ));
241
+ }
242
+ if (document.workflowScope === "program" && (governedAuditReferences.get(document.id) || []).length) {
243
+ diagnostics.push(error(
244
+ "program-document-in-engagement-workflow",
245
+ path,
246
+ `Program-scoped Document "${document.title}" cannot fill an Audit engagement or management-Document field.`
247
+ ));
248
+ }
249
+ if (document.activationBasis !== "legacy-v4") continue;
250
+ const historicalAuditIds = new Set(auditRefs
251
+ .filter(({ audit }) => ["issued", "delivered", "complete"].includes(audit.status))
252
+ .map(({ audit }) => audit.id));
253
+ if (document.workflowScope !== "engagement" || historicalAuditIds.size !== 1 || auditIds.size !== 1) {
254
+ diagnostics.push(error(
255
+ "invalid-legacy-document-activation",
256
+ path,
257
+ `activationBasis legacy-v4 is reserved for an engagement Document tied to exactly one issued, delivered, or completed Audit.`
258
+ ));
259
+ }
260
+ }
261
+
262
+ for (const document of resources.filter(({ type, activationBasis }) => (
263
+ type === "document" && activationBasis === "recorded"
264
+ ))) {
265
+ const invalidActorIds = (document.activatedByIds || []).filter((id) => (
266
+ !personWasActiveOn(byId.get(id), document.activatedOn)
267
+ ));
268
+ if (invalidActorIds.length) {
269
+ diagnostics.push(error(
270
+ "invalid-document-activation-actor",
271
+ pathById.get(document.id) || `data/${document.id}`,
272
+ `Document activation actors must have been active on ${document.activatedOn}: ${invalidActorIds.join(", ")}.`
273
+ ));
274
+ }
275
+ }
276
+ }
277
+
164
278
  function validateCollectionReview(record, loaded, byId, path, diagnostics) {
165
279
  if (record.status !== "active") return;
166
280
  const { model } = loaded;
@@ -175,7 +289,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
175
289
  ));
176
290
  return;
177
291
  }
178
- const program = String(model.modelVersion) === "4"
292
+ const program = modelSupports(model, "program-scope")
179
293
  ? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
180
294
  : null;
181
295
  const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
@@ -208,7 +322,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
208
322
  diagnostics.push(error(
209
323
  "inactive-authoritative-system",
210
324
  path,
211
- `${configuration.title} must name an active authoritative ${String(model.modelVersion) === "4" ? "Component" : "System"} for an externally managed conclusion.`
325
+ `${configuration.title} must name an active authoritative ${modelSupports(model, "component-sources") ? "Component" : "System"} for an externally managed conclusion.`
212
326
  ));
213
327
  }
214
328
  }
@@ -370,9 +484,19 @@ function validateControlComponents(record, byId, path, diagnostics) {
370
484
  }
371
485
 
372
486
  function validateAuditSubservices(record, byId, path, diagnostics) {
487
+ const selectedSystemIds = new Set(record.systemIds || []);
488
+ const seenComponentIds = new Set();
373
489
  for (const [index, treatment] of (record.subserviceTreatments || []).entries()) {
374
490
  for (const componentId of treatment.componentIds || []) {
375
491
  const component = byId.get(componentId);
492
+ if (seenComponentIds.has(componentId)) {
493
+ diagnostics.push(error(
494
+ "duplicate-subservice-component",
495
+ path,
496
+ `subserviceTreatments[${index}] repeats Component "${componentId}" in more than one treatment.`
497
+ ));
498
+ }
499
+ seenComponentIds.add(componentId);
376
500
  if (component?.type === "component" && component.vendorId !== treatment.vendorId) {
377
501
  diagnostics.push(error(
378
502
  "subservice-vendor-mismatch",
@@ -380,6 +504,16 @@ function validateAuditSubservices(record, byId, path, diagnostics) {
380
504
  `subserviceTreatments[${index}] Component "${componentId}" is not supplied by Vendor "${treatment.vendorId}".`
381
505
  ));
382
506
  }
507
+ if (
508
+ component?.type === "component"
509
+ && !(component.systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId))
510
+ ) {
511
+ diagnostics.push(error(
512
+ "subservice-component-outside-audit-scope",
513
+ path,
514
+ `subserviceTreatments[${index}] Component "${componentId}" has no use in a System selected by this Audit.`
515
+ ));
516
+ }
383
517
  }
384
518
  }
385
519
  }
@@ -416,7 +550,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
416
550
  ? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
417
551
  : [];
418
552
  if (!expectedTypes.length) continue;
419
- const completionIds = ["3", "4"].includes(String(model.modelVersion))
553
+ const completionIds = modelSupports(model, "guided-workflow")
420
554
  ? action.completionResourceIds || []
421
555
  : [...(action.completionResourceIds || []), ...(action.evidenceIds || [])];
422
556
  const linked = [...new Set(completionIds)]
@@ -434,7 +568,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
434
568
 
435
569
  function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
436
570
  if (
437
- !["3", "4"].includes(String(model.modelVersion))
571
+ !modelSupports(model, "guided-workflow")
438
572
  || record.status !== "done"
439
573
  || !record.obligationId
440
574
  ) return;
@@ -652,6 +786,9 @@ function validateCompletionDates(record, path, diagnostics) {
652
786
  ]);
653
787
  validateOrderedDates(record, path, diagnostics, ["startedAt", "endedAt"]);
654
788
  validateOrderedDates(record, path, diagnostics, ["fieldworkStart", "fieldworkEnd", "reportDate"]);
789
+ if (record.type === "document") {
790
+ validateOrderedDates(record, path, diagnostics, ["approvedOn", "activatedOn", "effectiveOn"]);
791
+ }
655
792
  if (record.acceptance) {
656
793
  validateOrderedDates(record.acceptance, path, diagnostics, ["acceptedOn", "expiresOn"], "acceptance.");
657
794
  }
@@ -746,8 +883,15 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn
746
883
  }
747
884
 
748
885
  async function validateApprovalBinding(record, model, root, path, diagnostics) {
749
- const bindingField = approvalBindingField(record, model);
750
- if (!bindingField || !approvalBound(record) || !record[bindingField]) return;
886
+ const bindingFields = contentBindingFields(record, model);
887
+ for (const binding of bindingFields) {
888
+ await validateContentBinding(record, model, root, path, diagnostics, binding);
889
+ }
890
+ }
891
+
892
+ async function validateContentBinding(record, model, root, path, diagnostics, binding) {
893
+ const { field: bindingField, bound, label } = binding;
894
+ if (!bound(record) || !record[bindingField]) return;
751
895
  const actual = {};
752
896
  for (const item of markdownEntries(model, record)) {
753
897
  try {
@@ -767,24 +911,34 @@ async function validateApprovalBinding(record, model, root, path, diagnostics) {
767
911
  diagnostics.push(error(
768
912
  "approval-content-changed",
769
913
  path,
770
- `Approved content no longer matches ${invalid.map((item) => `data/${item}`).join(", ")}. Move the record to draft or in-review, review the change, then approve it again.`
914
+ `${label} content no longer matches ${invalid.map((item) => `data/${item}`).join(", ")}. Move the record to draft or in-review, review the change, then approve and activate it again.`
771
915
  ));
772
916
  }
773
917
  }
774
918
 
775
919
  function approvalBound(record) {
776
920
  if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
777
- if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
921
+ if (record.type === "document") return ["approved", "active", "superseded", "retired"].includes(record.status);
778
922
  if (record.type === "training") return ["active", "retired"].includes(record.status);
779
923
  return false;
780
924
  }
781
925
 
782
- function approvalBindingField(record, model) {
783
- if (["policy", "document"].includes(record.type)) return "approvedContentRevisions";
926
+ function contentBindingFields(record, model) {
927
+ const fields = [];
928
+ if (["policy", "document"].includes(record.type)) {
929
+ fields.push({ field: "approvedContentRevisions", bound: approvalBound, label: "Approved" });
930
+ }
931
+ if (record.type === "document" && model.resources.document?.fields?.activatedContentRevisions) {
932
+ fields.push({
933
+ field: "activatedContentRevisions",
934
+ bound: (candidate) => ["active", "superseded", "retired"].includes(candidate?.status),
935
+ label: "Activated"
936
+ });
937
+ }
784
938
  if (record.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
785
- return "effectiveContentRevisions";
939
+ fields.push({ field: "effectiveContentRevisions", bound: approvalBound, label: "Approved" });
786
940
  }
787
- return null;
941
+ return fields;
788
942
  }
789
943
 
790
944
  function validateCoverage(record, path, diagnostics) {
@@ -838,7 +992,7 @@ function validateCoverage(record, path, diagnostics) {
838
992
 
839
993
  function validateClassification(record, loaded, path, diagnostics) {
840
994
  if (!record.classificationId) return;
841
- if (String(loaded.model.modelVersion) === "4") {
995
+ if (modelSupports(loaded.model, "program-scope")) {
842
996
  if (!loaded.resources.some(({ id, type }) => id === record.classificationId && type === "classification")) {
843
997
  diagnostics.push(error(
844
998
  "unknown-classification",