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.
package/src/validate.js CHANGED
@@ -3,10 +3,14 @@ import { readFile, stat } from "node:fs/promises";
3
3
  import { performance } from "node:perf_hooks";
4
4
  import { getResourceDefinition, modelSupports } from "../model/index.js";
5
5
  import { scopedCollectionRecords } from "./collection-scope.js";
6
- import { collectionRevision } from "./collection-revision.js";
6
+ import {
7
+ collectionRevision,
8
+ collectionRevisionMatches
9
+ } from "./collection-revision.js";
7
10
  import { isSafeGitName } from "./git-name.js";
8
11
  import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
9
12
  import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
13
+ import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
10
14
  import { obligationIsEnabled } from "./program-lifecycle.js";
11
15
  import { partyPeople } from "./parties.js";
12
16
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
@@ -42,6 +46,15 @@ async function validateWorkspaceUnmeasured(input) {
42
46
  ]));
43
47
  const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC");
44
48
  const obligationsByControl = new Map();
49
+ const reviewRecords = loaded.resources.filter((record) => (
50
+ record.status === "active" && ["retention-schedule-item", "requirement-mapping"].includes(record.type)
51
+ ));
52
+ const reviewDependencyIds = reviewRecords.flatMap((record) => (
53
+ record.type === "retention-schedule-item"
54
+ ? retentionReviewResourceIds(record, loaded)
55
+ : [...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])]
56
+ ));
57
+ const currentReviewRevisions = await resourceReviewRevisions(loaded, reviewDependencyIds);
45
58
  for (const obligation of loaded.resources.filter((record) => record.type === "obligation" && record.status !== "retired")) {
46
59
  for (const controlId of obligation.controlIds || []) {
47
60
  if (!obligationsByControl.has(controlId)) obligationsByControl.set(controlId, []);
@@ -85,6 +98,8 @@ async function validateWorkspaceUnmeasured(input) {
85
98
  validateCollectionReview(record, loaded, byId, displayPath, diagnostics);
86
99
  }
87
100
  if (record.type === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
101
+ if (record.type === "retention-schedule-item") validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisions, displayPath, diagnostics);
102
+ if (record.type === "requirement-mapping") validateRequirementMapping(record, currentReviewRevisions, displayPath, diagnostics);
88
103
  if (record.type === "action-item") {
89
104
  validateCompletedObligationAction(record, byId, loaded.model, displayPath, diagnostics);
90
105
  }
@@ -153,16 +168,20 @@ async function validateWorkspaceUnmeasured(input) {
153
168
  }
154
169
 
155
170
  diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
156
- return {
171
+ const result = {
157
172
  ok: !diagnostics.some(({ severity }) => severity === "error"),
158
173
  diagnostics,
159
174
  counts: {
160
175
  resources: loaded.resources.length,
161
176
  errors: diagnostics.filter(({ severity }) => severity === "error").length,
162
177
  warnings: diagnostics.filter(({ severity }) => severity === "warning").length
163
- },
164
- loaded
178
+ }
165
179
  };
180
+ Object.defineProperty(result, "loaded", {
181
+ value: loaded,
182
+ enumerable: false
183
+ });
184
+ return result;
166
185
  }
167
186
 
168
187
  function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagnostics) {
@@ -246,7 +265,7 @@ function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagno
246
265
  `Program-scoped Document "${document.title}" cannot fill an Audit engagement or management-Document field.`
247
266
  ));
248
267
  }
249
- if (document.activationBasis !== "legacy-v4") continue;
268
+ if (!["legacy-v4", "historical"].includes(document.activationBasis)) continue;
250
269
  const historicalAuditIds = new Set(auditRefs
251
270
  .filter(({ audit }) => ["issued", "delivered", "complete"].includes(audit.status))
252
271
  .map(({ audit }) => audit.id));
@@ -254,7 +273,7 @@ function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagno
254
273
  diagnostics.push(error(
255
274
  "invalid-legacy-document-activation",
256
275
  path,
257
- `activationBasis legacy-v4 is reserved for an engagement Document tied to exactly one issued, delivered, or completed Audit.`
276
+ `The historical activation basis is reserved for an engagement Document tied to exactly one issued, delivered, or completed Audit.`
258
277
  ));
259
278
  }
260
279
  }
@@ -313,7 +332,18 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
313
332
  ? record.authoritativeComponentId || record.authoritativeSystemId
314
333
  : null
315
334
  });
316
- const current = record.collectionRevision === currentRevision;
335
+ const current = collectionRevisionMatches(
336
+ loaded,
337
+ record.resourceType,
338
+ record.collectionRevision,
339
+ {
340
+ program,
341
+ authoritativeSourceId: record.decision === "externally-managed"
342
+ ? record.authoritativeComponentId || record.authoritativeSystemId
343
+ : null,
344
+ currentRevision
345
+ }
346
+ );
317
347
  if (current && !recordCount && record.decision === "complete") {
318
348
  diagnostics.push(error(
319
349
  "invalid-collection-review-decision",
@@ -561,7 +591,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
561
591
  }
562
592
  const obligation = byId.get(action.obligationId);
563
593
  const expectedTypes = obligation?.type === "obligation"
564
- ? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
594
+ ? obligationCompletionTypes(model, obligation)
565
595
  : [];
566
596
  if (!expectedTypes.length) continue;
567
597
  const completionIds = modelSupports(model, "guided-workflow")
@@ -588,7 +618,7 @@ function validateCompletedObligationAction(record, byId, model, path, diagnostic
588
618
  ) return;
589
619
  const obligation = byId.get(record.obligationId);
590
620
  if (obligation?.type !== "obligation") return;
591
- const expectedTypes = model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || [];
621
+ const expectedTypes = obligationCompletionTypes(model, obligation);
592
622
  if (!expectedTypes.length) return;
593
623
  const linked = [...new Set(record.completionResourceIds || [])]
594
624
  .map((id) => byId.get(id))
@@ -635,7 +665,19 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
635
665
  function validateObligation(record, model, byId, path, diagnostics) {
636
666
  const recurrence = record.recurrence;
637
667
  if (!recurrence || Array.isArray(recurrence) || typeof recurrence !== "object") return;
638
- const activity = model.obligationActivities?.[record.activityType];
668
+ const activity = record.activityType === "custom"
669
+ ? { ...model.obligationActivities?.custom, ...record.customActivity }
670
+ : model.obligationActivities?.[record.activityType];
671
+ if (record.activityType === "custom") {
672
+ const invalidTypes = (record.customActivity?.completionResourceTypes || []).filter((type) => !model.resources[type]);
673
+ if (invalidTypes.length) {
674
+ diagnostics.push(error(
675
+ "invalid-obligation-activity",
676
+ path,
677
+ `customActivity.completionResourceTypes contains unknown resource types: ${invalidTypes.join(", ")}.`
678
+ ));
679
+ }
680
+ }
639
681
  if (
640
682
  activity
641
683
  && Array.isArray(activity.recurrenceModes)
@@ -674,7 +716,7 @@ function validateObligation(record, model, byId, path, diagnostics) {
674
716
  ));
675
717
  }
676
718
  } else if (recurrence.mode === "event") {
677
- if (!model.policyEvents?.[recurrence.eventType]) {
719
+ if (!model.policyEvents?.[recurrence.eventType] && !modelSupports(model, "custom-obligations")) {
678
720
  diagnostics.push(error(
679
721
  "invalid-obligation-recurrence",
680
722
  path,
@@ -737,6 +779,59 @@ function validateObligation(record, model, byId, path, diagnostics) {
737
779
  }
738
780
  }
739
781
 
782
+ function validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisions, path, diagnostics) {
783
+ if (record.status !== "active") return;
784
+ const schedule = byId.get(record.scheduleDocumentId);
785
+ if (schedule?.type !== "document" || schedule.documentKind !== "schedule" || schedule.workflowScope !== "program" || ["superseded", "retired"].includes(schedule.status)) {
786
+ diagnostics.push(error(
787
+ "invalid-retention-schedule-document",
788
+ path,
789
+ "scheduleDocumentId must reference a current program Document whose documentKind is schedule."
790
+ ));
791
+ }
792
+ const sources = retentionReviewResourceIds(record, loaded);
793
+ const revisions = record.reviewedSourceRevisions || {};
794
+ const missing = sources.filter((id) => (
795
+ !currentReviewRevisions.get(id) || revisions[id] !== currentReviewRevisions.get(id)
796
+ )).concat(Object.keys(revisions).filter((id) => !sources.includes(id)));
797
+ if (missing.length) {
798
+ diagnostics.push(error(
799
+ "stale-retention-review",
800
+ path,
801
+ `reviewedSourceRevisions must bind the schedule Document, Information Types, operational scope, and authority or source records before activation: ${missing.join(", ")}.`
802
+ ));
803
+ }
804
+ }
805
+
806
+ function validateRequirementMapping(record, currentReviewRevisions, path, diagnostics) {
807
+ const overlap = (record.sourceResourceIds || []).filter((id) => (record.targetResourceIds || []).includes(id));
808
+ if (overlap.length) {
809
+ diagnostics.push(error(
810
+ "invalid-requirement-mapping",
811
+ path,
812
+ `A Requirement Mapping cannot place the same record on both sides: ${[...new Set(overlap)].join(", ")}.`
813
+ ));
814
+ }
815
+ if (record.status !== "active") return;
816
+ const mappedIds = [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])];
817
+ const revisions = record.reviewedSourceRevisions || {};
818
+ const missing = mappedIds.filter((id) => (
819
+ !currentReviewRevisions.get(id) || revisions[id] !== currentReviewRevisions.get(id)
820
+ )).concat(Object.keys(revisions).filter((id) => !mappedIds.includes(id)));
821
+ if (missing.length) {
822
+ diagnostics.push(error(
823
+ "stale-requirement-mapping",
824
+ path,
825
+ `reviewedSourceRevisions must bind every mapped resource before activation: ${missing.join(", ")}.`
826
+ ));
827
+ }
828
+ }
829
+
830
+ function obligationCompletionTypes(model, obligation) {
831
+ if (obligation.activityType === "custom") return obligation.customActivity?.completionResourceTypes || [];
832
+ return model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || [];
833
+ }
834
+
740
835
  function validatePolicyEvent(record, model, byId, path, diagnostics) {
741
836
  const event = model.policyEvents?.[record.eventType];
742
837
  if (!event) return;