filegrc 0.14.0 → 0.15.1

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.
@@ -32,6 +32,94 @@ import { assessSourceCoverageReadiness } from "./source-coverage.js";
32
32
  import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js";
33
33
  import { loadWorkspace } from "./workspace.js";
34
34
 
35
+ export function calculateProgramProgress({
36
+ stages = [],
37
+ target = {},
38
+ evidenceReady = false,
39
+ operating = false,
40
+ asOf,
41
+ window = null
42
+ } = {}) {
43
+ const typeTwo = target.goal === "soc-2-type-2";
44
+ const trackedWindow = normalizeProgressWindow(window || target.candidateCoverage, window ? "audit" : "candidate");
45
+ const windowStarted = Boolean(
46
+ typeTwo
47
+ && trackedWindow
48
+ && asOf
49
+ && trackedWindow.start <= asOf
50
+ );
51
+ const setupProgress = calculateSetupActionProgress(stages, target, Boolean(trackedWindow));
52
+ if (windowStarted) {
53
+ const totalDays = calendarDayDistance(trackedWindow.start, trackedWindow.end) + 1;
54
+ const elapsedDays = Math.min(
55
+ totalDays,
56
+ Math.max(0, calendarDayDistance(trackedWindow.start, asOf) + 1)
57
+ );
58
+ const percent = totalDays ? Math.round((elapsedDays / totalDays) * 100) : 0;
59
+ const complete = elapsedDays >= totalDays;
60
+ const healthy = Boolean(operating);
61
+ return {
62
+ mode: "operating-window",
63
+ label: trackedWindow.source === "audit" ? "Audit window" : "Operating window",
64
+ status: healthy ? complete ? "Window complete" : "Operating" : "Needs attention",
65
+ complete: elapsedDays,
66
+ total: totalDays,
67
+ remaining: Math.max(0, totalDays - elapsedDays),
68
+ percent,
69
+ unit: "day",
70
+ start: trackedWindow.start,
71
+ end: trackedWindow.end,
72
+ source: trackedWindow.source,
73
+ detail: complete
74
+ ? `${totalDays} of ${totalDays} days elapsed.`
75
+ : `Day ${elapsedDays} of ${totalDays}; ${Math.max(0, totalDays - elapsedDays)} days remain.`,
76
+ operating
77
+ };
78
+ }
79
+
80
+ return {
81
+ mode: "setup-progress",
82
+ label: "Program setup",
83
+ status: !setupProgress.remaining
84
+ ? evidenceReady && typeTwo && trackedWindow?.start > asOf
85
+ ? "Scheduled"
86
+ : evidenceReady ? "Evidence ready" : "Ready"
87
+ : evidenceReady && typeTwo ? "Ready to start" : "Needs work",
88
+ ...setupProgress,
89
+ unit: "action",
90
+ detail: setupProgress.remaining
91
+ ? `${setupProgress.complete} of ${setupProgress.total} required actions complete.`
92
+ : `All ${setupProgress.total} required actions are complete.`,
93
+ operating
94
+ };
95
+ }
96
+
97
+ function calculateSetupActionProgress(stages, target, windowMilestoneComplete) {
98
+ const units = new Map();
99
+ for (const currentStage of stages.filter(({ id }) => id !== "operation")) {
100
+ for (const current of currentStage.items || []) {
101
+ if (["info", "later"].includes(current.status) || current.progressUnit === false) continue;
102
+ const key = `${currentStage.id}:${current.progressUnitId || current.id}`;
103
+ const existing = units.get(key);
104
+ if (!existing || existing.status === "complete" && current.status !== "complete") {
105
+ units.set(key, current);
106
+ }
107
+ }
108
+ }
109
+
110
+ const setupUnits = [...units.values()];
111
+ const complete = setupUnits.filter(({ status }) => status === "complete").length;
112
+ const total = setupUnits.length + (target.goal === "soc-2-type-2" ? 1 : 0);
113
+ const completed = complete + (windowMilestoneComplete ? 1 : 0);
114
+ const remaining = Math.max(0, total - completed);
115
+ return {
116
+ complete: completed,
117
+ total,
118
+ remaining,
119
+ percent: total ? Math.round((completed / total) * 100) : 0
120
+ };
121
+ }
122
+
35
123
  export async function assessProgramReadiness(input, options = {}) {
36
124
  const loaded = input?.resources && input?.model && input?.entries
37
125
  ? input
@@ -41,6 +129,15 @@ export async function assessProgramReadiness(input, options = {}) {
41
129
  const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
42
130
  const program = resolveProgram(loaded, options.programId);
43
131
  const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
132
+ const requestedAudit = options.auditId
133
+ ? records.find((record) => record.type === "audit" && record.id === options.auditId)
134
+ : null;
135
+ const auditWindow = requestedAudit?.auditKind === "soc-2-type-2"
136
+ ? requestedAudit.coverage
137
+ : selectedAuditWindow(records, program, asOf);
138
+ const progressWindow = requestedAudit?.auditKind === "soc-2-type-2"
139
+ ? normalizeProgressWindow(auditWindow, "audit")
140
+ : selectedProgressWindow(program?.candidateCoverage, auditWindow, asOf);
44
141
  const scope = programScope(program, records, byId, loaded.model, loaded);
45
142
  const collectionReviews = assessCollectionReviews(loaded, { programId: program.id });
46
143
  const markdown = new Map();
@@ -57,12 +154,19 @@ export async function assessProgramReadiness(input, options = {}) {
57
154
  .map(collectionReviewReadinessItem));
58
155
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
59
156
  controlStage.items.push(...sourceStage.items);
60
- controlStage.items.push(...await assessRetentionReadiness(loaded, program, {
157
+ const retentionItems = await assessRetentionReadiness(loaded, program, {
61
158
  informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
62
- }));
63
- controlStage.items.push(...collectionReviews
159
+ });
160
+ const retentionReviewItems = collectionReviews
64
161
  .filter(({ resourceType }) => resourceType === "retention-schedule-item")
65
- .map(collectionReviewReadinessItem));
162
+ .map(collectionReviewReadinessItem);
163
+ if (modelSupports(loaded.model, "retention-schedule-approval")) {
164
+ policyStage.items.push(...retentionItems.filter(({ id }) => !id.startsWith("retention-source-coverage-")));
165
+ controlStage.items.push(...retentionItems.filter(({ id }) => id.startsWith("retention-source-coverage-")));
166
+ policyStage.items.push(...retentionReviewItems);
167
+ } else {
168
+ controlStage.items.push(...retentionItems, ...retentionReviewItems);
169
+ }
66
170
  const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
67
171
  const policyActivations = await assessPolicyActivations(
68
172
  requiredPolicies(scope, byId),
@@ -116,26 +220,54 @@ export async function assessProgramReadiness(input, options = {}) {
116
220
  const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
117
221
  const stages = [
118
222
  ...evidenceGateStages,
119
- await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
223
+ await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model, progressWindow)
120
224
  ];
121
225
  finalizeStage(stages.at(-1));
122
- const candidateStarted = Boolean(
226
+ const periodStarted = Boolean(
123
227
  program?.assuranceGoal === "soc-2-type-2"
124
- && program.candidateCoverage?.kind === "range"
125
- && coverageStart(program.candidateCoverage) <= asOf
228
+ && progressWindow?.start <= asOf
126
229
  );
127
230
  const obligations = planObligations(records, { programId: program.id, asOf, through: asOf, model: loaded.model });
128
231
  const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
129
- const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
232
+ const operating = Boolean(
233
+ evidenceReady
234
+ && periodStarted
235
+ && stages.at(-1).counts.action === 0
236
+ );
130
237
  const canStartCandidatePeriod = Boolean(
131
238
  evidenceReady
132
239
  && program?.assuranceGoal === "soc-2-type-2"
133
240
  && !program.candidateCoverage
241
+ && !periodStarted
134
242
  );
135
243
  const items = stages.flatMap((current) => current.items);
136
244
  const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
137
245
  const complete = managedItems.filter((current) => current.status === "complete").length;
138
246
  const firstAction = items.find((current) => current.status === "action") || null;
247
+ const target = {
248
+ programId: program?.id || null,
249
+ goal: program?.assuranceGoal || "none",
250
+ label: assuranceGoalLabel(program?.assuranceGoal),
251
+ candidateCoverage: program?.candidateCoverage || null
252
+ };
253
+ const checkProgress = {
254
+ complete,
255
+ total: managedItems.length,
256
+ percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
257
+ };
258
+ const progress = calculateProgramProgress({
259
+ stages,
260
+ target,
261
+ evidenceReady,
262
+ operating,
263
+ asOf,
264
+ window: progressWindow
265
+ });
266
+ const setupProgress = calculateSetupActionProgress(
267
+ stages,
268
+ target,
269
+ Boolean(progressWindow)
270
+ );
139
271
 
140
272
  return {
141
273
  program,
@@ -143,12 +275,7 @@ export async function assessProgramReadiness(input, options = {}) {
143
275
  dataModelVersion: String(loaded.model.modelVersion),
144
276
  generatedAt: options.generatedAt || new Date().toISOString(),
145
277
  asOf,
146
- target: {
147
- programId: program?.id || null,
148
- goal: program?.assuranceGoal || "none",
149
- label: assuranceGoalLabel(program?.assuranceGoal),
150
- candidateCoverage: program?.candidateCoverage || null
151
- },
278
+ target,
152
279
  status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
153
280
  evidenceReady,
154
281
  operating,
@@ -161,11 +288,9 @@ export async function assessProgramReadiness(input, options = {}) {
161
288
  ...policyLibrary.proposals,
162
289
  ...legacyPolicyLibraryProposals(records)
163
290
  ],
164
- progress: {
165
- complete,
166
- total: managedItems.length,
167
- percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
168
- },
291
+ progress,
292
+ setupProgress,
293
+ checkProgress,
169
294
  counts: countStatuses(items),
170
295
  firstAction,
171
296
  scope: {
@@ -717,6 +842,7 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
717
842
  : "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
718
843
  appointedReviewer || (reviewerNeedsAssignment ? governedRecords[0] : { type: "person" }),
719
844
  {
845
+ progressUnit: false,
720
846
  commands: [
721
847
  "npx filegrc list appointment --workflow --json",
722
848
  "npx filegrc guide appointment --json",
@@ -900,6 +1026,11 @@ function requiredGovernedDocuments(scope, records, byId, model) {
900
1026
  && (
901
1027
  linkedDocumentIds.has(record.id)
902
1028
  || obligationDocumentIds.has(record.id)
1029
+ || (
1030
+ modelSupports(model, "retention-schedule-approval")
1031
+ && record.documentKind === "schedule"
1032
+ && record.workflowScope === "program"
1033
+ )
903
1034
  || (
904
1035
  record.programRole === "required"
905
1036
  && (record.controlIds || []).some((id) => selectedControlIds.has(id))
@@ -1456,7 +1587,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1456
1587
  return stage("sources", "Control Evidence Sources", `Complete the authoritative ${modelSupports(model, "component-sources") ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
1457
1588
  }
1458
1589
 
1459
- async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
1590
+ async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model, progressWindow = null) {
1460
1591
  const goal = workspace?.assuranceGoal || "none";
1461
1592
  if (!evidenceReady) {
1462
1593
  return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
@@ -1488,12 +1619,9 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
1488
1619
  }
1489
1620
 
1490
1621
  const obligations = planObligations(records, { programId: workspace.id, asOf, through: asOf, model });
1491
- const start = workspace.candidateCoverage?.kind === "range"
1492
- ? coverageStart(workspace.candidateCoverage)
1493
- : null;
1494
- const end = workspace.candidateCoverage?.kind === "range"
1495
- ? coverageEnd(workspace.candidateCoverage)
1496
- : null;
1622
+ const start = progressWindow?.start || null;
1623
+ const end = progressWindow?.end || null;
1624
+ const formalWindow = progressWindow?.source === "audit";
1497
1625
  const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
1498
1626
  const sourceCoverage = await assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
1499
1627
  const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
@@ -1505,16 +1633,20 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
1505
1633
  !start
1506
1634
  ? "Set the management candidate period start when the Evidence Ready gate passes. Do not backdate it."
1507
1635
  : start <= asOf
1508
- ? `Management began the candidate Type 2 evidence period on ${start}. This is not the auditor-agreed report period.`
1636
+ ? formalWindow
1637
+ ? `The auditor-agreed Type 2 period began on ${start}. Keep evidence collection and operating work current through the period.`
1638
+ : `Management began the candidate Type 2 evidence period on ${start}. This is not the auditor-agreed report period.`
1509
1639
  : `Evidence collection is scheduled to begin on ${start}.`,
1510
1640
  workspace
1511
1641
  ),
1512
1642
  item(
1513
1643
  "candidate-period-end",
1514
1644
  end ? "complete" : start ? "later" : "info",
1515
- "Plan the candidate period end",
1645
+ formalWindow ? "Confirm the auditor-agreed period end" : "Plan the candidate period end",
1516
1646
  end
1517
- ? `Management candidate period: ${start || "start not set"} through ${end}. The CPA firm may agree to different dates.`
1647
+ ? formalWindow
1648
+ ? `Auditor-agreed period: ${start || "start not set"} through ${end}.`
1649
+ : `Management candidate period: ${start || "start not set"} through ${end}. The CPA firm may agree to different dates.`
1518
1650
  : "Add the management target end when useful. Starting reliable evidence collection is the immediate milestone.",
1519
1651
  workspace
1520
1652
  ),
@@ -1772,6 +1904,61 @@ function countStatuses(items) {
1772
1904
  return counts;
1773
1905
  }
1774
1906
 
1907
+ export function selectedAuditWindow(records, program, asOf) {
1908
+ if (program?.assuranceGoal !== "soc-2-type-2") return null;
1909
+ const candidates = records.filter((record) => (
1910
+ record.type === "audit"
1911
+ && record.auditKind === "soc-2-type-2"
1912
+ && (!record.programId || record.programId === program.id)
1913
+ && !["canceled", "closed", "complete", "delivered"].includes(record.status)
1914
+ && normalizeProgressWindow(record.coverage)
1915
+ && coverageStart(record.coverage) <= asOf
1916
+ ));
1917
+ const statusOrder = new Map([
1918
+ ["planned", 0],
1919
+ ["in-progress", 1],
1920
+ ["fieldwork", 2],
1921
+ ["report-draft", 3],
1922
+ ["issued", 4]
1923
+ ]);
1924
+ candidates.sort((left, right) => (
1925
+ coverageStart(right.coverage).localeCompare(coverageStart(left.coverage))
1926
+ || (statusOrder.get(right.status) ?? -1) - (statusOrder.get(left.status) ?? -1)
1927
+ || coverageEnd(right.coverage).localeCompare(coverageEnd(left.coverage))
1928
+ || left.id.localeCompare(right.id)
1929
+ ));
1930
+ return candidates[0]?.coverage || null;
1931
+ }
1932
+
1933
+ export function selectedProgressWindow(candidateCoverage, auditCoverage, asOf) {
1934
+ const candidates = [
1935
+ normalizeProgressWindow(candidateCoverage, "candidate"),
1936
+ normalizeProgressWindow(auditCoverage, "audit")
1937
+ ].filter((window) => window && (!asOf || window.start <= asOf));
1938
+ candidates.sort((left, right) => (
1939
+ right.start.localeCompare(left.start)
1940
+ || Number(right.source === "audit") - Number(left.source === "audit")
1941
+ || right.end.localeCompare(left.end)
1942
+ ));
1943
+ if (candidates.length) return candidates[0];
1944
+ return normalizeProgressWindow(candidateCoverage, "candidate")
1945
+ || normalizeProgressWindow(auditCoverage, "audit");
1946
+ }
1947
+
1948
+ function normalizeProgressWindow(coverage, source = "candidate") {
1949
+ const start = coverage?.start || coverageStart(coverage);
1950
+ const end = coverage?.end || coverageEnd(coverage);
1951
+ if (!start || !end || end < start) return null;
1952
+ return { start, end, source: coverage?.source || source };
1953
+ }
1954
+
1955
+ function calendarDayDistance(start, end) {
1956
+ const startTime = Date.parse(`${start}T00:00:00Z`);
1957
+ const endTime = Date.parse(`${end}T00:00:00Z`);
1958
+ if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return 0;
1959
+ return Math.round((endTime - startTime) / 86_400_000);
1960
+ }
1961
+
1775
1962
  function shiftYear(value, offset) {
1776
1963
  const date = new Date(`${value}T00:00:00Z`);
1777
1964
  date.setUTCFullYear(date.getUTCFullYear() + offset);
@@ -0,0 +1,121 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
4
+ import { markdownEntries } from "./resource-markdown.js";
5
+ import { resolveDataPath } from "./paths.js";
6
+ import { currentPartyPeople, partiesIndependent } from "./parties.js";
7
+ import {
8
+ nearDuplicateInformationTypes,
9
+ resourceReviewRevisionsSync,
10
+ retentionReviewResourceIds,
11
+ retentionRuleIsCurrent,
12
+ retentionUses
13
+ } from "./retention.js";
14
+
15
+ export function retentionScheduleApprovalIssues(loaded, program, rows, options = {}) {
16
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
17
+ const issues = [];
18
+ const planned = rows.filter(({ status }) => status === "planned");
19
+ if (planned.length) {
20
+ issues.push(issue("planned-retention-schedule-row", `Complete or retire every planned retention row before approving the schedule revision: ${planned[0].title}.`));
21
+ }
22
+ const schedules = loaded.resources.filter((record) => (
23
+ record.type === "document"
24
+ && record.documentKind === "schedule"
25
+ && record.workflowScope === "program"
26
+ && !["superseded", "retired"].includes(record.status)
27
+ ));
28
+ if (!schedules.length) {
29
+ issues.push(issue("missing-retention-schedule-document", "Add and approve the Data Retention Schedule document before approving the schedule revision."));
30
+ }
31
+ if (schedules.length !== 1) {
32
+ if (schedules.length) {
33
+ issues.push(issue("multiple-retention-schedule-documents", "Keep exactly one current program Data Retention Schedule document before approving the schedule revision."));
34
+ }
35
+ }
36
+ const schedule = schedules.length === 1 ? schedules[0] : null;
37
+ const scheduleContent = schedule ? primaryMarkdown(loaded, schedule) : "";
38
+ const linkedControl = schedule && (schedule.controlIds || []).some((id) => (program.controlIds || []).includes(id));
39
+ if (
40
+ schedule
41
+ && (!["approved", "active"].includes(schedule.status)
42
+ || !schedule.approvedOn
43
+ || !schedule.approvedContentRevisions
44
+ || currentPartyPeople(schedule.ownerIds || [], byId).size === 0
45
+ || !partiesIndependent(schedule.ownerIds, schedule.approverIds, byId)
46
+ || !linkedControl
47
+ || !substantiveMarkdown(scheduleContent)
48
+ || openPlaceholderCount(scheduleContent) > 0
49
+ || !approvalBindingsMatch(loaded, schedule))
50
+ ) {
51
+ issues.push(issue("unapproved-retention-schedule-document", "Complete and independently approve the Data Retention Schedule document before approving the complete schedule."));
52
+ }
53
+ const activeRows = rows.filter(({ status }) => status === "active");
54
+ const mismatched = schedule
55
+ ? activeRows.filter(({ scheduleDocumentId }) => scheduleDocumentId !== schedule.id)
56
+ : [];
57
+ if (mismatched.length) {
58
+ issues.push(issue("retention-row-wrong-schedule-document", `Link every active retention row to the current Data Retention Schedule document before approval: ${mismatched[0].title}.`));
59
+ }
60
+ const revisions = resourceReviewRevisionsSync(
61
+ loaded,
62
+ activeRows.flatMap((row) => retentionReviewResourceIds(row, loaded))
63
+ );
64
+ const incomplete = activeRows.filter((row) => !retentionRuleIsCurrent(row, revisions, byId, loaded));
65
+ if (incomplete.length) {
66
+ issues.push(issue("incomplete-retention-schedule-row", `Complete the retention decisions before approving the schedule revision: ${incomplete[0].title}.`));
67
+ }
68
+ const usableRows = activeRows.filter((row) => !incomplete.includes(row));
69
+ const uncovered = retentionUses(loaded, program).find((use) => !usableRows.some((row) => (
70
+ (row.informationTypeIds || []).includes(use.informationTypeId)
71
+ && ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(program.id))
72
+ )));
73
+ if (uncovered) {
74
+ issues.push(issue(
75
+ "uncovered-retention-information-use",
76
+ `${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no active, current retention schedule row covers both the type and scope.`
77
+ ));
78
+ }
79
+ if (nearDuplicateInformationTypes(loaded.resources).length && !options.informationTypesReviewed) {
80
+ issues.push(issue("unreviewed-similar-information-types", "Review similar Information Types before approving the Data Retention Schedule."));
81
+ }
82
+ return issues;
83
+ }
84
+
85
+ function issue(code, message) {
86
+ return { code, message };
87
+ }
88
+
89
+ function primaryMarkdown(loaded, record) {
90
+ const definition = loaded.model.resources[record.type];
91
+ const selected = markdownEntries(loaded.model, record).find((entry) => (
92
+ definition.markdown?.[entry.name]?.primary || entry.name === loaded.model.recordContent?.slot
93
+ ));
94
+ if (!selected) return "";
95
+ try {
96
+ return readFileSync(resolveDataPath(loaded.root, selected.path), "utf8");
97
+ } catch {
98
+ return "";
99
+ }
100
+ }
101
+
102
+ function approvalBindingsMatch(loaded, record) {
103
+ const actual = {};
104
+ for (const entry of markdownEntries(loaded.model, record)) {
105
+ try {
106
+ actual[entry.path] = createHash("sha256")
107
+ .update(readFileSync(resolveDataPath(loaded.root, entry.path), "utf8"))
108
+ .digest("hex");
109
+ } catch (error) {
110
+ if (error.code !== "ENOENT") throw error;
111
+ }
112
+ }
113
+ const expected = record.approvedContentRevisions || {};
114
+ const actualKeys = Object.keys(actual).sort();
115
+ const expectedKeys = Object.keys(expected).sort();
116
+ return actualKeys.length === expectedKeys.length
117
+ && actualKeys.every((key, index) => (
118
+ key === expectedKeys[index]
119
+ && actual[key] === expected[key]
120
+ ));
121
+ }
package/src/retention.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { contentRevision } from "./files.js";
3
4
  import { resolveDataPath } from "./paths.js";
4
5
  import { programComponents } from "./program.js";
5
6
  import { markdownEntries } from "./resource-markdown.js";
7
+ import { modelSupports } from "../model/index.js";
6
8
 
7
9
  export async function assessRetentionReadiness(loaded, program, options = {}) {
8
10
  if (!loaded.model.resources["retention-schedule-item"]) return [];
@@ -30,22 +32,42 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
30
32
  ]
31
33
  }
32
34
  ));
33
- items.push(...uses.map((use) => {
34
- const matches = usableRules.filter((rule) => ruleCoversUse(rule, use, program));
35
+ const usesByInformationType = new Map();
36
+ for (const use of uses) {
37
+ const grouped = usesByInformationType.get(use.informationTypeId) || [];
38
+ grouped.push(use);
39
+ usesByInformationType.set(use.informationTypeId, grouped);
40
+ }
41
+ items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => {
42
+ const uncoveredUses = informationTypeUses.filter((use) => (
43
+ !usableRules.some((rule) => ruleCoversUse(rule, use, program))
44
+ ));
45
+ const matches = usableRules.filter((rule) => (
46
+ informationTypeUses.some((use) => ruleCoversUse(rule, use, program))
47
+ ));
48
+ const informationTypeTitle = byId.get(informationTypeId)?.title || informationTypeId;
49
+ const affectedTitles = uncoveredUses.map(({ resource }) => resource.title);
50
+ const affectedSummary = affectedTitles.length > 3
51
+ ? `${affectedTitles.slice(0, 3).join(", ")}, and ${affectedTitles.length - 3} more`
52
+ : affectedTitles.join(", ");
35
53
  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,
54
+ `retention-use-${informationTypeId}`,
55
+ uncoveredUses.length ? "action" : "complete",
56
+ `Decide retention for ${informationTypeTitle}`,
57
+ uncoveredUses.length
58
+ ? `${affectedTitles.length} in-scope ${affectedTitles.length === 1 ? "resource uses" : "resources use"} this Information Type without an active, current retention decision, including ${affectedSummary}. Management must choose the cutoff, period, disposition, and whether one program-wide rule or separate scoped rules apply.`
59
+ : `All ${informationTypeUses.length} in-scope ${informationTypeUses.length === 1 ? "use is" : "uses are"} covered by ${matches.map(({ title }) => title).join(", ")}.`,
60
+ { type: "retention-schedule-item" },
43
61
  {
44
- informationTypeId: use.informationTypeId,
62
+ informationTypeId,
63
+ affectedResourceIds: uncoveredUses.map(({ resource }) => resource.id),
64
+ retentionScopeResourceIds: uncoveredUses.length === informationTypeUses.length
65
+ ? [program.id]
66
+ : uncoveredUses.map(({ resource }) => resource.id),
45
67
  retentionScheduleItemIds: matches.map(({ id }) => id),
46
68
  commands: [
47
69
  "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}`)}`
70
+ `npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${informationTypeTitle}`)}`
49
71
  ]
50
72
  }
51
73
  );
@@ -113,7 +135,7 @@ export async function resourceReviewRevisions(loaded, ids) {
113
135
  const entry = entries.get(id);
114
136
  if (!entry || reviewing.has(id)) return null;
115
137
  reviewing.add(id);
116
- const parts = [entry.source];
138
+ const parts = [reviewSource(loaded, entry)];
117
139
  for (const markdown of markdownEntries(loaded.model, entry.record)) {
118
140
  try {
119
141
  parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
@@ -136,6 +158,37 @@ export async function resourceReviewRevisions(loaded, ids) {
136
158
  return new Map([...revisions].filter(([id]) => wanted.has(id)));
137
159
  }
138
160
 
161
+ export function resourceReviewRevisionsSync(loaded, ids) {
162
+ const wanted = new Set(ids);
163
+ const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
164
+ const revisions = new Map();
165
+ const reviewing = new Set();
166
+ const review = (id) => {
167
+ if (revisions.has(id)) return revisions.get(id);
168
+ const entry = entries.get(id);
169
+ if (!entry || reviewing.has(id)) return null;
170
+ reviewing.add(id);
171
+ const parts = [reviewSource(loaded, entry)];
172
+ for (const markdown of markdownEntries(loaded.model, entry.record)) {
173
+ try {
174
+ parts.push(readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"));
175
+ } catch (error) {
176
+ if (error.code !== "ENOENT") throw error;
177
+ }
178
+ }
179
+ for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
180
+ const revision = review(sourceId);
181
+ if (revision) parts.push(`${sourceId}:${revision}`);
182
+ }
183
+ reviewing.delete(id);
184
+ const revision = contentRevision(parts.join("\n"));
185
+ revisions.set(id, revision);
186
+ return revision;
187
+ };
188
+ for (const id of wanted) review(id);
189
+ return new Map([...revisions].filter(([id]) => wanted.has(id)));
190
+ }
191
+
139
192
  export function retentionUses(loaded, program) {
140
193
  const systemIds = new Set(program.systemIds || []);
141
194
  const componentIds = new Set(programComponents(loaded, program).map(({ id }) => id));
@@ -157,6 +210,28 @@ export function retentionUses(loaded, program) {
157
210
  return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
158
211
  }
159
212
 
213
+ function reviewSource(loaded, entry) {
214
+ if (
215
+ modelSupports(loaded.model, "retention-schedule-approval")
216
+ && entry.record.type === "document"
217
+ && entry.record.documentKind === "schedule"
218
+ && entry.record.workflowScope === "program"
219
+ ) {
220
+ const approved = structuredClone(entry.record);
221
+ if (approved.status === "active") approved.status = "approved";
222
+ for (const field of [
223
+ "activationBasis",
224
+ "activatedByIds",
225
+ "activatedOn",
226
+ "activatedContentRevisions",
227
+ "effectiveOn",
228
+ "proposedEffectiveOn"
229
+ ]) delete approved[field];
230
+ return JSON.stringify(approved);
231
+ }
232
+ return entry.source;
233
+ }
234
+
160
235
  export function nearDuplicateInformationTypes(records) {
161
236
  const types = records.filter((record) => record.type === "information-type" && !["retired", "superseded"].includes(record.status));
162
237
  const pairs = [];
package/src/state.js CHANGED
@@ -150,17 +150,7 @@ export async function createAppStateSection(input, section, options = {}) {
150
150
  collectionReviews: Object.fromEntries(
151
151
  assessCollectionReviews(loaded, { programId: activeProgram.id }).map((assessment) => [
152
152
  assessment.resourceType,
153
- {
154
- resourceType: assessment.resourceType,
155
- configuration: assessment.configuration,
156
- recordCount: assessment.recordCount,
157
- review: assessment.review,
158
- reviewRevision: assessment.reviewRevision,
159
- collectionRevision: assessment.collectionRevision,
160
- status: assessment.status,
161
- complete: assessment.complete,
162
- message: assessment.message
163
- }
153
+ serializeCollectionReviewAssessment(assessment)
164
154
  ])
165
155
  ),
166
156
  applicabilityConstraints: Object.fromEntries(loaded.resources.flatMap((record) => {
@@ -318,17 +308,7 @@ async function createAppStateUnlocked(input, options) {
318
308
  const collectionReviews = Object.fromEntries(
319
309
  assessCollectionReviews(loaded, { programId: activeProgram.id }).map((assessment) => [
320
310
  assessment.resourceType,
321
- {
322
- resourceType: assessment.resourceType,
323
- configuration: assessment.configuration,
324
- recordCount: assessment.recordCount,
325
- review: assessment.review,
326
- reviewRevision: assessment.reviewRevision,
327
- collectionRevision: assessment.collectionRevision,
328
- status: assessment.status,
329
- complete: assessment.complete,
330
- message: assessment.message
331
- }
311
+ serializeCollectionReviewAssessment(assessment)
332
312
  ])
333
313
  );
334
314
  return {
@@ -356,6 +336,23 @@ async function createAppStateUnlocked(input, options) {
356
336
  };
357
337
  }
358
338
 
339
+ function serializeCollectionReviewAssessment(assessment) {
340
+ return {
341
+ resourceType: assessment.resourceType,
342
+ configuration: assessment.configuration,
343
+ recordCount: assessment.recordCount,
344
+ review: assessment.review,
345
+ reviewRevision: assessment.reviewRevision,
346
+ collectionRevision: assessment.collectionRevision,
347
+ status: assessment.status,
348
+ complete: assessment.complete,
349
+ message: assessment.message,
350
+ ...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
351
+ ...(assessment.reviewerConflictIds ? { reviewerConflictIds: assessment.reviewerConflictIds } : {}),
352
+ ...(assessment.approvalIssues ? { approvalIssues: assessment.approvalIssues } : {})
353
+ };
354
+ }
355
+
359
356
  export async function createResourceDetail(input, type, id, options = {}) {
360
357
  if (input?.entries && input?.root) return createResourceDetailFromLoaded(input, type, id, options);
361
358
  return serializeWorkspaceMutation(input, async (root) => {