filegrc 0.10.0 → 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
@@ -10,6 +10,7 @@ import {
10
10
  import { isSafeGitName } from "./git-name.js";
11
11
  import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
12
12
  import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
13
+ import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
13
14
  import { obligationIsEnabled } from "./program-lifecycle.js";
14
15
  import { partyPeople } from "./parties.js";
15
16
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
@@ -45,6 +46,15 @@ async function validateWorkspaceUnmeasured(input) {
45
46
  ]));
46
47
  const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC");
47
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);
48
58
  for (const obligation of loaded.resources.filter((record) => record.type === "obligation" && record.status !== "retired")) {
49
59
  for (const controlId of obligation.controlIds || []) {
50
60
  if (!obligationsByControl.has(controlId)) obligationsByControl.set(controlId, []);
@@ -88,6 +98,8 @@ async function validateWorkspaceUnmeasured(input) {
88
98
  validateCollectionReview(record, loaded, byId, displayPath, diagnostics);
89
99
  }
90
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);
91
103
  if (record.type === "action-item") {
92
104
  validateCompletedObligationAction(record, byId, loaded.model, displayPath, diagnostics);
93
105
  }
@@ -579,7 +591,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
579
591
  }
580
592
  const obligation = byId.get(action.obligationId);
581
593
  const expectedTypes = obligation?.type === "obligation"
582
- ? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
594
+ ? obligationCompletionTypes(model, obligation)
583
595
  : [];
584
596
  if (!expectedTypes.length) continue;
585
597
  const completionIds = modelSupports(model, "guided-workflow")
@@ -606,7 +618,7 @@ function validateCompletedObligationAction(record, byId, model, path, diagnostic
606
618
  ) return;
607
619
  const obligation = byId.get(record.obligationId);
608
620
  if (obligation?.type !== "obligation") return;
609
- const expectedTypes = model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || [];
621
+ const expectedTypes = obligationCompletionTypes(model, obligation);
610
622
  if (!expectedTypes.length) return;
611
623
  const linked = [...new Set(record.completionResourceIds || [])]
612
624
  .map((id) => byId.get(id))
@@ -653,7 +665,19 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
653
665
  function validateObligation(record, model, byId, path, diagnostics) {
654
666
  const recurrence = record.recurrence;
655
667
  if (!recurrence || Array.isArray(recurrence) || typeof recurrence !== "object") return;
656
- 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
+ }
657
681
  if (
658
682
  activity
659
683
  && Array.isArray(activity.recurrenceModes)
@@ -692,7 +716,7 @@ function validateObligation(record, model, byId, path, diagnostics) {
692
716
  ));
693
717
  }
694
718
  } else if (recurrence.mode === "event") {
695
- if (!model.policyEvents?.[recurrence.eventType]) {
719
+ if (!model.policyEvents?.[recurrence.eventType] && !modelSupports(model, "custom-obligations")) {
696
720
  diagnostics.push(error(
697
721
  "invalid-obligation-recurrence",
698
722
  path,
@@ -755,6 +779,59 @@ function validateObligation(record, model, byId, path, diagnostics) {
755
779
  }
756
780
  }
757
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
+
758
835
  function validatePolicyEvent(record, model, byId, path, diagnostics) {
759
836
  const event = model.policyEvents?.[record.eventType];
760
837
  if (!event) return;