filegrc 0.16.14 → 0.16.16
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/package.json +1 -1
- package/src/agent.js +1 -0
- package/src/collection-changes.js +158 -0
- package/src/collection-review.js +9 -3
- package/src/collection-scope.js +8 -6
- package/src/files.js +2 -2
- package/src/program-readiness.js +4 -2
- package/src/revisions.js +6 -0
- package/src/state.js +5 -1
- package/src/validate.js +2 -2
- package/src/web.js +31 -5
- package/src/workflow.js +7 -2
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -102,6 +102,7 @@ export function buildAgentGuide(loaded, type, options = {}) {
|
|
|
102
102
|
allowedDecisions: collectionReview.configuration.decisions,
|
|
103
103
|
status: collectionReview.status,
|
|
104
104
|
recordCount: collectionReview.recordCount,
|
|
105
|
+
...(collectionReview.changesSinceReview ? { changesSinceReview: collectionReview.changesSinceReview } : {}),
|
|
105
106
|
command: `npx filegrc review-collection ${type} --scaffold`
|
|
106
107
|
}
|
|
107
108
|
: null
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { modelSupports } from "../model/index.js";
|
|
3
|
+
import { authoritativeSourceRevisionValue, collectionRevisionInputs, collectionScopeRevisionFacts, scopedCollectionRecords } from "./collection-scope.js";
|
|
4
|
+
import { collectionRevisionMatches } from "./collection-revision.js";
|
|
5
|
+
import { getFileAtRevision } from "./git.js";
|
|
6
|
+
import { reviewHistoricalWorkspace, reviewHistoryContext } from "./historical-workspace.js";
|
|
7
|
+
import { markdownEntries } from "./resource-markdown.js";
|
|
8
|
+
import { resolveProgram } from "./program.js";
|
|
9
|
+
import { resolveDataPath } from "./paths.js";
|
|
10
|
+
import { canonicalCalculatedRevision } from "./revisions.js";
|
|
11
|
+
|
|
12
|
+
export function collectionChangesSinceReview(loaded, resourceType, review, programId) {
|
|
13
|
+
if (!/^[a-f0-9]{40,64}$/i.test(review?.scopeRevision || "") || !loaded.root) return unavailable("The reviewed Git snapshot is unavailable. Compare the current records with the prior review.");
|
|
14
|
+
const before = reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision);
|
|
15
|
+
if (!before) return unavailable("The reviewed Git snapshot is unavailable. Compare the current records with the prior review.");
|
|
16
|
+
const workspaceWide = resourceType === "retention-schedule-item"
|
|
17
|
+
&& modelSupports(loaded.model, "retention-schedule-approval");
|
|
18
|
+
let priorProgram = null;
|
|
19
|
+
let currentProgram = null;
|
|
20
|
+
try {
|
|
21
|
+
if (!workspaceWide) {
|
|
22
|
+
priorProgram = resolveProgram(before, programId);
|
|
23
|
+
currentProgram = resolveProgram(loaded, programId);
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
return unavailable("The reviewed Program could not be reconstructed. Compare the current scope with the prior review.");
|
|
27
|
+
}
|
|
28
|
+
if (!collectionRevisionMatches(before, resourceType, review.collectionRevision, {
|
|
29
|
+
programId,
|
|
30
|
+
historicalCommit: review.scopeRevision,
|
|
31
|
+
authoritativeSourceId: review.authoritativeComponentId || review.authoritativeSystemId
|
|
32
|
+
})) {
|
|
33
|
+
return unavailable("The stored revision does not match the reviewed Git snapshot. Compare the current records with the prior review.");
|
|
34
|
+
}
|
|
35
|
+
const priorInputs = new Map(collectionRevisionInputs(before, resourceType, priorProgram)
|
|
36
|
+
.map((input) => [input.record.id, input]));
|
|
37
|
+
const currentInputs = new Map(collectionRevisionInputs(loaded, resourceType, currentProgram)
|
|
38
|
+
.map((input) => [input.record.id, input]));
|
|
39
|
+
const authoritativeSourceId = review.authoritativeComponentId || review.authoritativeSystemId;
|
|
40
|
+
for (const [source, inputs] of [[before, priorInputs], [loaded, currentInputs]]) {
|
|
41
|
+
const record = source.resources.find(({ id }) => id === authoritativeSourceId);
|
|
42
|
+
if (record) inputs.set(record.id, {
|
|
43
|
+
record,
|
|
44
|
+
value: authoritativeSourceRevisionValue(record),
|
|
45
|
+
includeContent: true
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const priorMembers = new Set(scopedCollectionRecords(before, resourceType, priorProgram).map(({ id }) => id));
|
|
49
|
+
const currentMembers = new Set(scopedCollectionRecords(loaded, resourceType, currentProgram).map(({ id }) => id));
|
|
50
|
+
const changes = [];
|
|
51
|
+
for (const id of new Set([...priorInputs.keys(), ...currentInputs.keys()])) {
|
|
52
|
+
const prior = priorInputs.get(id);
|
|
53
|
+
const current = currentInputs.get(id);
|
|
54
|
+
const record = current?.record || prior?.record;
|
|
55
|
+
const fields = prior && current ? changedFields(prior.value, current.value, loaded.model, record.type) : [];
|
|
56
|
+
const contentChanged = prior && current && (prior.includeContent || current.includeContent)
|
|
57
|
+
? changedContent(before, loaded, review.scopeRevision, prior.record, current.record)
|
|
58
|
+
: false;
|
|
59
|
+
const membershipChanged = priorMembers.has(id) !== currentMembers.has(id);
|
|
60
|
+
if (!prior || !current || fields.length || contentChanged || membershipChanged) {
|
|
61
|
+
changes.push({
|
|
62
|
+
id,
|
|
63
|
+
type: record.type,
|
|
64
|
+
title: record.title || id,
|
|
65
|
+
kind: !prior ? "added" : !current ? "removed" : "changed",
|
|
66
|
+
fields,
|
|
67
|
+
...(contentChanged ? { contentChanged: true } : {}),
|
|
68
|
+
...(membershipChanged ? { membershipChanged: true } : {})
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const scopeFields = changedFields(
|
|
73
|
+
collectionScopeRevisionFacts(before, resourceType, priorProgram),
|
|
74
|
+
collectionScopeRevisionFacts(loaded, resourceType, currentProgram),
|
|
75
|
+
loaded.model,
|
|
76
|
+
"program"
|
|
77
|
+
);
|
|
78
|
+
if (!changes.length && !scopeFields.length) {
|
|
79
|
+
return unavailable("The stored revision does not match the reviewed Git snapshot. Compare the current records with the prior review.");
|
|
80
|
+
}
|
|
81
|
+
changes.sort((left, right) => left.type.localeCompare(right.type) || left.title.localeCompare(right.title) || left.id.localeCompare(right.id));
|
|
82
|
+
const overview = [
|
|
83
|
+
...changes.slice(0, 3).map(describeChange),
|
|
84
|
+
...(changes.length > 3 ? [`${changes.length - 3} more changed records`] : []),
|
|
85
|
+
...(scopeFields.length ? [`Program scope (${scopeFields.join(", ")})`] : [])
|
|
86
|
+
].join("; ");
|
|
87
|
+
const headlineParts = [
|
|
88
|
+
...changes.slice(0, 2).map(describeShortChange),
|
|
89
|
+
...(changes.length > 2 ? [`${changes.length - 2} more changed records`] : []),
|
|
90
|
+
...(scopeFields.length ? [`Program scope: ${scopeFields.slice(0, 2).join(", ")}${scopeFields.length > 2 ? ", more" : ""}`] : [])
|
|
91
|
+
];
|
|
92
|
+
return {
|
|
93
|
+
status: "available",
|
|
94
|
+
summary: `Changed since the last review: ${overview}.`,
|
|
95
|
+
headline: `${headlineParts.join("; ")}. Open the review for details.`,
|
|
96
|
+
changes,
|
|
97
|
+
scopeFields
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function unavailable(summary) {
|
|
102
|
+
return { status: "unavailable", summary, headline: "Changes could not be reconstructed. Open the review for details.", changes: [], scopeFields: [] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function describeChange(change) {
|
|
106
|
+
const name = `${change.title} (${change.type})`;
|
|
107
|
+
if (change.kind !== "changed") return `${name} ${change.kind === "added" ? "entered review scope" : "left review scope"}`;
|
|
108
|
+
const details = [...change.fields, ...(change.contentChanged ? ["Markdown"] : []), ...(change.membershipChanged ? ["collection membership"] : [])];
|
|
109
|
+
return `${name}: ${details.join(", ")}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function describeShortChange(change) {
|
|
113
|
+
const name = change.title.length > 44 ? `${change.title.slice(0, 43)}…` : change.title;
|
|
114
|
+
if (change.kind !== "changed") return `${name} ${change.kind === "added" ? "entered scope" : "left scope"}`;
|
|
115
|
+
const details = [...change.fields, ...(change.contentChanged ? ["Markdown"] : []), ...(change.membershipChanged ? ["membership"] : [])];
|
|
116
|
+
return `${name}: ${details.slice(0, 2).join(", ")}${details.length > 2 ? ", more" : ""}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function changedFields(before, after, model, type) {
|
|
120
|
+
const definitions = { ...model.commonFields, ...model.resources[type]?.fields };
|
|
121
|
+
return [...new Set([...Object.keys(before || {}), ...Object.keys(after || {})])]
|
|
122
|
+
.filter((field) => !sameValue(before?.[field], after?.[field]))
|
|
123
|
+
.map((field) => definitions[field]?.label || field.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/\bIds\b/g, "IDs").replace(/^./, (letter) => letter.toUpperCase()));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sameValue(left, right) {
|
|
127
|
+
if (typeof left === "string" && typeof right === "string") {
|
|
128
|
+
return canonicalCalculatedRevision(left) === canonicalCalculatedRevision(right);
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
131
|
+
if (left.every((value) => typeof value === "string") && right.every((value) => typeof value === "string")) {
|
|
132
|
+
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
133
|
+
}
|
|
134
|
+
return left.length === right.length && left.every((value, index) => sameValue(value, right[index]));
|
|
135
|
+
}
|
|
136
|
+
if (left && right && typeof left === "object" && typeof right === "object") {
|
|
137
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
138
|
+
return [...keys].every((key) => sameValue(left[key], right[key]));
|
|
139
|
+
}
|
|
140
|
+
return left === right;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function changedContent(before, current, commit, priorRecord, currentRecord) {
|
|
144
|
+
const priorPaths = markdownEntries(before.model, priorRecord).map(({ path }) => path);
|
|
145
|
+
const currentPaths = markdownEntries(current.model, currentRecord).map(({ path }) => path);
|
|
146
|
+
for (const path of new Set([...priorPaths, ...currentPaths])) {
|
|
147
|
+
const priorSource = priorPaths.includes(path)
|
|
148
|
+
? before.historicalFiles?.get(`data/${path}`) ?? getFileAtRevision(before.root, commit, `data/${path}`)
|
|
149
|
+
: null;
|
|
150
|
+
let currentSource = null;
|
|
151
|
+
if (currentPaths.includes(path)) {
|
|
152
|
+
try { currentSource = readFileSync(resolveDataPath(current.root, path), "utf8"); }
|
|
153
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
154
|
+
}
|
|
155
|
+
if (priorSource !== currentSource) return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
package/src/collection-review.js
CHANGED
|
@@ -16,8 +16,9 @@ import { currentPartyPeople } from "./parties.js";
|
|
|
16
16
|
import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
|
|
17
17
|
import { markdownEntries } from "./resource-markdown.js";
|
|
18
18
|
import { resolveDataPath } from "./paths.js";
|
|
19
|
-
import { calculateRevision, calculatedRevisionDiagnostic, revisionsMatch } from "./revisions.js";
|
|
19
|
+
import { calculateRevision, calculatedRevisionDiagnostic, displayRevision, revisionsMatch } from "./revisions.js";
|
|
20
20
|
import { personWasActiveOn } from "./soc2.js";
|
|
21
|
+
import { collectionChangesSinceReview } from "./collection-changes.js";
|
|
21
22
|
|
|
22
23
|
export { collectionRevision };
|
|
23
24
|
|
|
@@ -123,6 +124,9 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
123
124
|
&& review.collectionRevision
|
|
124
125
|
&& (!revisionMatches || !reviewScopeMatches)
|
|
125
126
|
);
|
|
127
|
+
const changesSinceReview = stale
|
|
128
|
+
? collectionChangesSinceReview(loaded, resourceType, review, program.id)
|
|
129
|
+
: null;
|
|
126
130
|
return {
|
|
127
131
|
resourceType,
|
|
128
132
|
configuration,
|
|
@@ -134,6 +138,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
134
138
|
: null,
|
|
135
139
|
reviewEntries,
|
|
136
140
|
collectionRevision: currentRevision,
|
|
141
|
+
changesSinceReview,
|
|
137
142
|
revisionDiagnostic: calculatedRevisionDiagnostic("collection", review?.collectionRevision, currentRevision),
|
|
138
143
|
status: complete ? "current" : stale ? "stale" : "review-required",
|
|
139
144
|
complete,
|
|
@@ -143,7 +148,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
143
148
|
message: complete
|
|
144
149
|
? `${configuration.title} were reviewed on ${review.reviewedOn}.`
|
|
145
150
|
: approvalIssues.length
|
|
146
|
-
? approvalIssues[0].message
|
|
151
|
+
? `${approvalIssues[0].message}${changesSinceReview ? ` ${changesSinceReview.summary}` : ""}`
|
|
147
152
|
: incompleteRecordProposals.length
|
|
148
153
|
? `Complete ${incompleteRecordProposals.length} ${configuration.title.toLowerCase()} ${incompleteRecordProposals.length === 1 ? "record proposal" : "record proposals"} before the collection review.`
|
|
149
154
|
: revisionMatches && review?.status === "active" && !reviewersEligible
|
|
@@ -151,7 +156,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
151
156
|
? "Review the Data Retention Schedule again with a reviewer who does not own its governing document or an included schedule row."
|
|
152
157
|
: "Review the Control collection again with a reviewer who does not own an included Control or its enabled Obligation."
|
|
153
158
|
: stale
|
|
154
|
-
? `${configuration.title} changed after the last confirmation. Stored revision: ${review.collectionRevision}. Current revision: ${currentRevision}. Review the current records again.`
|
|
159
|
+
? `${configuration.title} changed after the last confirmation. ${changesSinceReview.summary} Stored revision: ${displayRevision(review.collectionRevision)}. Current revision: ${displayRevision(currentRevision)}. Review the current records again.`
|
|
155
160
|
: !records.length && !allowsEmptyCollection
|
|
156
161
|
? `Add at least one ${loaded.model.resources[resourceType].title.toLowerCase()} before confirming this collection.`
|
|
157
162
|
: `Review ${configuration.title.toLowerCase()} before this page can be ready.`
|
|
@@ -190,6 +195,7 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
|
|
|
190
195
|
: null;
|
|
191
196
|
return {
|
|
192
197
|
resourceType,
|
|
198
|
+
...(assessment.changesSinceReview ? { changesSinceReview: assessment.changesSinceReview } : {}),
|
|
193
199
|
decision: priorDecisionStillValid ? priorReview.decision : (assessment.records.length
|
|
194
200
|
? "complete"
|
|
195
201
|
: allowedDecisions.includes("zero-population") ? "zero-population" : null),
|
package/src/collection-scope.js
CHANGED
|
@@ -167,7 +167,9 @@ function scopedProgramPeople(loaded, program = {}) {
|
|
|
167
167
|
const systemIds = new Set(program.systemIds || []);
|
|
168
168
|
const controlIds = new Set(program.controlIds || []);
|
|
169
169
|
const policyIds = new Set(loaded.resources
|
|
170
|
-
.filter(({ type, status, programRole }) =>
|
|
170
|
+
.filter(({ type, status, programRole }) => (
|
|
171
|
+
type === "policy" && !["superseded", "retired"].includes(status) && programRole !== "reference"
|
|
172
|
+
))
|
|
171
173
|
.map(({ id }) => id));
|
|
172
174
|
const components = programComponents(loaded, program);
|
|
173
175
|
const componentIds = new Set(components.map(({ id }) => id));
|
|
@@ -177,13 +179,13 @@ function scopedProgramPeople(loaded, program = {}) {
|
|
|
177
179
|
]);
|
|
178
180
|
const sources = loaded.resources.filter((record) => (
|
|
179
181
|
["workspace", "program", "appointment", "team"].includes(record.type)
|
|
180
|
-
|| record.type === "system" && systemIds.has(record.id)
|
|
182
|
+
|| record.type === "system" && systemIds.has(record.id) && record.status !== "retired"
|
|
181
183
|
|| record.type === "component" && componentIds.has(record.id)
|
|
182
|
-
|| record.type === "vendor" && vendorIds.has(record.id)
|
|
183
|
-
|| record.type === "control" && controlIds.has(record.id)
|
|
184
|
+
|| record.type === "vendor" && vendorIds.has(record.id) && !["deprecated", "terminated"].includes(record.status)
|
|
185
|
+
|| record.type === "control" && controlIds.has(record.id) && record.status !== "retired"
|
|
184
186
|
|| record.type === "policy" && policyIds.has(record.id)
|
|
185
|
-
|| record.type === "document" && record.workflowScope !== "engagement"
|
|
186
|
-
|| record.type === "obligation" && (
|
|
187
|
+
|| record.type === "document" && record.workflowScope !== "engagement" && !["superseded", "retired"].includes(record.status)
|
|
188
|
+
|| record.type === "obligation" && record.status !== "retired" && (
|
|
187
189
|
(record.controlIds || []).some((id) => controlIds.has(id))
|
|
188
190
|
|| (record.policyIds || []).some((id) => policyIds.has(id))
|
|
189
191
|
)
|
package/src/files.js
CHANGED
|
@@ -12,7 +12,7 @@ import { measureTiming } from "./timing.js";
|
|
|
12
12
|
import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js";
|
|
13
13
|
import { loadWorkspace } from "./workspace.js";
|
|
14
14
|
import { validateWorkspace } from "./validate.js";
|
|
15
|
-
import { calculateRevision, revisionsMatch } from "./revisions.js";
|
|
15
|
+
import { calculateRevision, displayRevision, revisionsMatch } from "./revisions.js";
|
|
16
16
|
|
|
17
17
|
export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({
|
|
18
18
|
auditManagementReconciliation: Symbol("audit-management-reconciliation"),
|
|
@@ -1165,7 +1165,7 @@ async function prepareContentWrites(loaded, record, content, options = {}) {
|
|
|
1165
1165
|
function assertRevision(source, expected, label) {
|
|
1166
1166
|
const current = contentRevision(source);
|
|
1167
1167
|
if (expected && !revisionsMatch("content", expected, current)) {
|
|
1168
|
-
throw new Error(`${label} changed after you opened it. Stored revision: ${expected}. Current revision: ${current}. Reload the workspace and apply your change again.`);
|
|
1168
|
+
throw new Error(`${label} changed after you opened it. Stored revision: ${displayRevision(expected)}. Current revision: ${displayRevision(current)}. Reload the workspace and apply your change again.`);
|
|
1169
1169
|
}
|
|
1170
1170
|
}
|
|
1171
1171
|
|
package/src/program-readiness.js
CHANGED
|
@@ -794,7 +794,9 @@ export function collectionReviewReadinessItem(assessment) {
|
|
|
794
794
|
: `Review ${assessment.configuration.title.toLowerCase()}`,
|
|
795
795
|
proposalAction
|
|
796
796
|
? `Complete ${firstIncomplete.title} before reviewing ${assessment.configuration.title.toLowerCase()}.`
|
|
797
|
-
: assessment.
|
|
797
|
+
: assessment.approvalIssues?.length || pendingRetentionRow || needsFirstRecord
|
|
798
|
+
? assessment.message
|
|
799
|
+
: assessment.changesSinceReview?.headline || assessment.message,
|
|
798
800
|
proposalAction
|
|
799
801
|
? { id: firstIncomplete.resourceId, type: assessment.resourceType }
|
|
800
802
|
: needsFirstRecord ? { type: assessment.resourceType }
|
|
@@ -1239,7 +1241,7 @@ function requiredGovernedDocuments(scope, records, byId, model) {
|
|
|
1239
1241
|
const selectedControlIds = new Set(scope.controls.map(({ id }) => id));
|
|
1240
1242
|
const linkedDocumentIds = new Set(requiredPolicies(scope, byId).flatMap((policy) => policy.relatedDocumentIds || []));
|
|
1241
1243
|
const obligationDocumentIds = new Set(records
|
|
1242
|
-
.filter((record) => record.type === "obligation")
|
|
1244
|
+
.filter((record) => record.type === "obligation" && record.status !== "retired")
|
|
1243
1245
|
.flatMap((record) => [
|
|
1244
1246
|
...(record.scopeResourceIds || []),
|
|
1245
1247
|
...(record.templateResourceId ? [record.templateResourceId] : [])
|
package/src/revisions.js
CHANGED
|
@@ -42,6 +42,12 @@ export function revisionDigest(kind, value) {
|
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export function displayRevision(value) {
|
|
46
|
+
const text = String(value || "");
|
|
47
|
+
const match = /^(?:filegrc:[a-z-]+:v\d+:sha256:|scope:)?([a-f0-9]{64})$/.exec(text);
|
|
48
|
+
return match ? match[1].slice(0, 12) : text;
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
export function revisionsMatch(kind, stored, current) {
|
|
46
52
|
const storedDigest = revisionDigest(kind, stored);
|
|
47
53
|
const currentDigest = revisionDigest(kind, current);
|
package/src/state.js
CHANGED
|
@@ -344,7 +344,10 @@ async function createAppStateUnlocked(input, options) {
|
|
|
344
344
|
function assessApplicabilityReviewStatuses(loaded, program) {
|
|
345
345
|
const decisions = new Map((program.requirementApplicability || []).map((review) => [review.requirementId, review]));
|
|
346
346
|
return Object.fromEntries(loaded.resources
|
|
347
|
-
.filter(({ type }) =>
|
|
347
|
+
.filter(({ type, status }) => (
|
|
348
|
+
["requirement", "control", "commitment", "complementary-control"].includes(type)
|
|
349
|
+
&& !["retired", "superseded"].includes(status)
|
|
350
|
+
))
|
|
348
351
|
.map((record) => {
|
|
349
352
|
const review = record.type === "requirement" && modelSupports(loaded.model, "program-scope")
|
|
350
353
|
? decisions.get(record.id)
|
|
@@ -367,6 +370,7 @@ function serializeCollectionReviewAssessment(assessment) {
|
|
|
367
370
|
status: assessment.status,
|
|
368
371
|
complete: assessment.complete,
|
|
369
372
|
message: assessment.message,
|
|
373
|
+
...(assessment.changesSinceReview ? { changesSinceReview: assessment.changesSinceReview } : {}),
|
|
370
374
|
...(assessment.recordProposals ? { recordProposals: assessment.recordProposals } : {}),
|
|
371
375
|
...(assessment.incompleteRecordProposals ? { incompleteRecordProposals: assessment.incompleteRecordProposals } : {}),
|
|
372
376
|
...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
|
package/src/validate.js
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
} from "./reporting-route-integrity.js";
|
|
47
47
|
import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
|
|
48
48
|
import { occurrenceMemberExceptionIsValid } from "./obligation-members.js";
|
|
49
|
-
import { calculateRevision, revisionDigest, revisionsMatch } from "./revisions.js";
|
|
49
|
+
import { calculateRevision, displayRevision, revisionDigest, revisionsMatch } from "./revisions.js";
|
|
50
50
|
|
|
51
51
|
let fingerprintFileReadObserver = null;
|
|
52
52
|
|
|
@@ -2028,7 +2028,7 @@ function validateRequirementMapping(record, loaded, currentReviewRevisions, path
|
|
|
2028
2028
|
|
|
2029
2029
|
function revisionMismatchDetails(ids, stored, current) {
|
|
2030
2030
|
return ids.map((id) => (
|
|
2031
|
-
`${id} (stored: ${stored[id] || "missing"}; current: ${current.get(id) || "missing"})`
|
|
2031
|
+
`${id} (stored: ${displayRevision(stored[id]) || "missing"}; current: ${displayRevision(current.get(id)) || "missing"})`
|
|
2032
2032
|
)).join(", ");
|
|
2033
2033
|
}
|
|
2034
2034
|
|
package/src/web.js
CHANGED
|
@@ -1251,7 +1251,7 @@ function collectionReviewPanel(type, force = false) {
|
|
|
1251
1251
|
: "";
|
|
1252
1252
|
const reviewSummary = current && !reviewBlocker
|
|
1253
1253
|
? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + "." + reviewNote + '</span></p>'
|
|
1254
|
-
: '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : proposalBlocker ? "Complete proposals first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(reviewBlocker || assessment.message) + '</span></p>';
|
|
1254
|
+
: '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : proposalBlocker ? "Complete proposals first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(reviewBlocker || (assessment.status === "stale" && assessment.changesSinceReview ? "Check what changed since the last review, then confirm the current records." : assessment.message)) + '</span></p>';
|
|
1255
1255
|
const compactReviewSummary = current && !reviewBlocker
|
|
1256
1256
|
? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + ".</span></p>"
|
|
1257
1257
|
: reviewSummary;
|
|
@@ -1270,8 +1270,11 @@ function collectionReviewPanel(type, force = false) {
|
|
|
1270
1270
|
const summary = current
|
|
1271
1271
|
? '<div class="collection-review-current-row"><div class="collection-review-complete-summary"><span class="kicker">' + (scheduleReview ? "Approved schedule" : "Scope confirmation") + '</span>' + compactReviewSummary + '</div>' + details + action + '</div>'
|
|
1272
1272
|
: details;
|
|
1273
|
+
const changes = !current && assessment.status === "stale" && !reviewBlocker
|
|
1274
|
+
? collectionReviewChangesHtml(assessment.changesSinceReview)
|
|
1275
|
+
: "";
|
|
1273
1276
|
return '<section class="collection-review-panel panel ' + (current ? "current" : "required") + '">' + summary +
|
|
1274
|
-
(current ? "" : '<div class="collection-review-foot">' + reviewSummary + action + '</div>') + '</section>';
|
|
1277
|
+
changes + (current ? "" : '<div class="collection-review-foot">' + reviewSummary + action + '</div>') + '</section>';
|
|
1275
1278
|
}
|
|
1276
1279
|
|
|
1277
1280
|
function retentionScheduleApprovalBlocker() {
|
|
@@ -1452,9 +1455,10 @@ function openCollectionReviewDialog(type) {
|
|
|
1452
1455
|
const saveDescription = scheduleReview
|
|
1453
1456
|
? 'Approve the governing document and ' + assessment.recordCount + ' current ' + pluralize("schedule row", assessment.recordCount) + ' as one revision. A later document, row, or material scope change requires another approval.'
|
|
1454
1457
|
: 'Confirm ' + assessment.recordCount + ' current ' + pluralize("record", assessment.recordCount) + '. If the collection or material scope changes, FileGRC will ask for another review.';
|
|
1458
|
+
const changes = collectionReviewChangesHtml(assessment.changesSinceReview);
|
|
1455
1459
|
const confirmSubmit = '<button type="submit" class="button primary">Confirm and save</button>';
|
|
1456
1460
|
const submit = scheduleReview ? '<button type="submit" class="button primary">Approve schedule</button>' : confirmSubmit;
|
|
1457
|
-
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + dialogKicker + '</p><h2 id="collection-review-dialog-title">' + esc(dialogTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p
|
|
1461
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + dialogKicker + '</p><h2 id="collection-review-dialog-title">' + esc(dialogTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p>' + changes + '<section class="event-dialog-steps collection-review-checks"><strong>' + checklistLabel + '</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>' + reviewerHelp + '<div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label><label data-authoritative-system><span>Authoritative ' + (v4 ? "Component" : "System") + '</span><select name="authoritativeSourceId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (preservedAuthoritativeSourceId === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview"><strong>What this saves</strong><p>' + esc(saveDescription) + '</p></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button>' + submit + '</div></form>';
|
|
1458
1462
|
document.body.append(dialog);
|
|
1459
1463
|
dialog.showModal();
|
|
1460
1464
|
const form = dialog.querySelector("form");
|
|
@@ -1516,6 +1520,23 @@ function openCollectionReviewDialog(type) {
|
|
|
1516
1520
|
});
|
|
1517
1521
|
}
|
|
1518
1522
|
|
|
1523
|
+
function collectionReviewChangesHtml(changes) {
|
|
1524
|
+
if (!changes) return "";
|
|
1525
|
+
if (changes.status !== "available") {
|
|
1526
|
+
return '<section class="collection-review-change-list"><strong>What changed since the last review</strong><p>' + esc(changes.summary) + '</p></section>';
|
|
1527
|
+
}
|
|
1528
|
+
const rows = changes.changes.map((change) => {
|
|
1529
|
+
const details = change.kind !== "changed" ? change.kind === "added" ? "entered review scope" : "left review scope" : [
|
|
1530
|
+
...change.fields,
|
|
1531
|
+
...(change.contentChanged ? ["Markdown"] : []),
|
|
1532
|
+
...(change.membershipChanged ? ["collection membership"] : [])
|
|
1533
|
+
].join(", ");
|
|
1534
|
+
return '<li><strong>' + esc(change.title) + '</strong> <span>(' + esc(change.type) + ')</span>: ' + esc(details) + '</li>';
|
|
1535
|
+
});
|
|
1536
|
+
if (changes.scopeFields?.length) rows.push('<li><strong>Program scope</strong>: ' + esc(changes.scopeFields.join(", ")) + '</li>');
|
|
1537
|
+
return '<section class="collection-review-change-list"><strong>What changed since the last review</strong><p>Check these changes before recording a new conclusion.</p><ul>' + rows.join("") + '</ul></section>';
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1519
1540
|
function workflowItemHref(item) {
|
|
1520
1541
|
const destinationOptions = {
|
|
1521
1542
|
retentionScheduleApproval: modelSupports("retention-schedule-approval"),
|
|
@@ -1897,11 +1918,13 @@ function derivedStagePageState(stage, destination) {
|
|
|
1897
1918
|
: { complete: false, label: "Review scope" };
|
|
1898
1919
|
}
|
|
1899
1920
|
if (stage.id === "audit" && destination.type === "document" && destination.href.includes("documentScope=audit")) {
|
|
1900
|
-
if (!resourcesOfType("document").some(({ record }) =>
|
|
1921
|
+
if (!resourcesOfType("document").some(({ record }) => (
|
|
1922
|
+
auditSpecificDocument(record) && !["retired", "superseded"].includes(record.status)
|
|
1923
|
+
))) {
|
|
1901
1924
|
return { complete: false, countsTowardProgress: false, label: "Only if needed" };
|
|
1902
1925
|
}
|
|
1903
1926
|
}
|
|
1904
|
-
if (destination.type && resourcesOfType(destination.type).
|
|
1927
|
+
if (destination.type && !resourcesOfType(destination.type).some(({ record }) => !["retired", "superseded"].includes(record.status))) {
|
|
1905
1928
|
return { complete: false, countsTowardProgress: false, label: "Only if needed" };
|
|
1906
1929
|
}
|
|
1907
1930
|
return { complete: true, label: "Ready" };
|
|
@@ -2792,6 +2815,7 @@ function openNextAuditCycleDialog(prior) {
|
|
|
2792
2815
|
}
|
|
2793
2816
|
|
|
2794
2817
|
function controlApplicabilityPending(record) {
|
|
2818
|
+
if (["retired", "superseded"].includes(record.status)) return false;
|
|
2795
2819
|
if (state.applicabilityReviewStatuses?.[record.id]) {
|
|
2796
2820
|
return state.applicabilityReviewStatuses[record.id] !== "current";
|
|
2797
2821
|
}
|
|
@@ -2802,6 +2826,7 @@ function controlApplicabilityPending(record) {
|
|
|
2802
2826
|
}
|
|
2803
2827
|
|
|
2804
2828
|
function applicabilityPending(record) {
|
|
2829
|
+
if (["retired", "superseded"].includes(record.status)) return false;
|
|
2805
2830
|
if (state.applicabilityReviewStatuses?.[record.id]) {
|
|
2806
2831
|
return state.applicabilityReviewStatuses[record.id] !== "current";
|
|
2807
2832
|
}
|
|
@@ -6580,6 +6605,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6580
6605
|
.stage-page-card-actions{display:flex;align-items:center;gap:10px}.stage-page-card-actions .stage-page-open{white-space:nowrap}.stage-page-open::after{content:"";position:absolute;inset:0;z-index:1;border-radius:10px}.stage-page-open:focus-visible::after{outline:2px solid var(--focus);outline-offset:2px}.stage-page-open>span{position:relative;z-index:2}
|
|
6581
6606
|
.workflow-findings>a:hover{border-color:var(--accent-light);box-shadow:0 3px 9px rgba(21,40,33,.05)}.workflow-findings>a:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{align-items:flex-start}.stage-page-completion-state{flex:0 0 auto;max-width:150px;padding:5px 8px;border-radius:99px;background:#f7e9cf;color:#855717;font-size:9.6px;font-weight:750;line-height:1.25;text-align:right}.stage-page-completion-state.complete{background:#ddefe5;color:#176143}.obligation-card.workflow-target{outline:2px solid var(--focus);outline-offset:3px}.obligation-card-foot{flex-wrap:wrap}.obligation-card-foot .obligation-links{flex:1 1 120px}
|
|
6582
6607
|
.collection-review-panel.current details{margin-top:8px}.collection-review-detail-content{margin-top:10px}.collection-review-complete-summary{display:flex;align-items:baseline;gap:10px}.collection-review-complete-summary>.kicker{margin:0;white-space:nowrap}.collection-review-panel.current .collection-review-foot{justify-content:flex-end;margin-top:8px}
|
|
6608
|
+
.collection-review-change-list{margin:14px 0;padding:12px 14px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-change-list>strong{font-size:11px}.collection-review-change-list p{margin:7px 0 0;color:var(--muted);font-size:11px;line-height:1.5}.collection-review-change-list ul{max-height:180px;overflow:auto;margin:9px 0 0;padding-left:20px;font-size:11px;line-height:1.55}.collection-review-change-list li+li{margin-top:5px}.collection-review-change-list li span{color:var(--muted)}
|
|
6583
6609
|
.collection-review-current-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.collection-review-panel.current .collection-review-details{flex:0 0 auto;margin:0;padding:0;border:0;background:transparent}.collection-review-panel.current .collection-review-details>summary{color:var(--accent);white-space:nowrap}.collection-review-panel.current .collection-review-details[open]{flex-basis:100%;order:3;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-panel.current .collection-review-details[open]>summary{margin-bottom:10px}.collection-review-panel.current .collection-review-details[open] .collection-review-detail-content{margin-top:0}.collection-review-panel.current .button{margin-left:auto}
|
|
6584
6610
|
@media(max-width:520px){.collection-review-complete-summary{flex:1 1 180px;min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result{min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result span{overflow-wrap:anywhere}.collection-review-panel.current .button{margin-left:0}}
|
|
6585
6611
|
@media(max-width:520px){.stage-page-card-head{align-items:stretch;flex-direction:column;gap:10px}.stage-page-card-actions{justify-content:space-between}}
|
package/src/workflow.js
CHANGED
|
@@ -26,6 +26,7 @@ import { assessAuditPreparation } from "./audit-preparation.js";
|
|
|
26
26
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
27
27
|
import { resolveProgram } from "./program.js";
|
|
28
28
|
import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
|
|
29
|
+
import { displayRevision } from "./revisions.js";
|
|
29
30
|
import { signatoryAppointmentIssue, soc2ReportEvidenceIssue, subsequentEventsReviewIssue } from "./soc2.js";
|
|
30
31
|
import { planReconciliation } from "./reconciliation.js";
|
|
31
32
|
import { documentIsAuditSpecific } from "./program-lifecycle.js";
|
|
@@ -393,6 +394,7 @@ function recordFinalizationFindings(loaded, program) {
|
|
|
393
394
|
const findings = [];
|
|
394
395
|
for (const record of loaded.resources) {
|
|
395
396
|
if (["workspace", "renderer-settings"].includes(record.type)) continue;
|
|
397
|
+
if (["retired", "superseded"].includes(record.status)) continue;
|
|
396
398
|
const incomplete = recordIncompleteReason(record, loaded, program);
|
|
397
399
|
if (incomplete) {
|
|
398
400
|
findings.push(finalizationFinding(record, incomplete, loaded.model));
|
|
@@ -543,8 +545,8 @@ function finalizationFields(record, model, resources, program, root) {
|
|
|
543
545
|
field: "applicabilityReview",
|
|
544
546
|
requiredness: "required",
|
|
545
547
|
message: record.applicabilityReview?.scopeRevision
|
|
546
|
-
? `Review applicability against the current service scope. Stored revision: ${record.applicabilityReview.scopeRevision}. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.`
|
|
547
|
-
: `Review applicability against the current service scope. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.`
|
|
548
|
+
? `Review applicability against the current service scope. Stored revision: ${displayRevision(record.applicabilityReview.scopeRevision)}. Current revision: ${displayRevision(currentRevision)}. Then record the decision, rationale, reviewer, and date.`
|
|
549
|
+
: `Review applicability against the current service scope. Current revision: ${displayRevision(currentRevision)}. Then record the decision, rationale, reviewer, and date.`
|
|
548
550
|
});
|
|
549
551
|
}
|
|
550
552
|
if (record.type === "policy" && model.resources.policy?.fields?.programRole && !record.programRole) {
|
|
@@ -815,6 +817,9 @@ async function assessPeriodHealth(loaded, options) {
|
|
|
815
817
|
.map(({ id }) => id));
|
|
816
818
|
for (const record of loaded.resources) {
|
|
817
819
|
if (record.type === "policy" && ["required", "alternative"].includes(record.programRole)) {
|
|
820
|
+
const historical = ["retired", "superseded"].includes(record.status);
|
|
821
|
+
const changedOn = record.statusTransition?.changedOn;
|
|
822
|
+
if (historical && changedOn && changedOn <= coverage.start) continue;
|
|
818
823
|
if (record.status !== "active" || !record.effectiveOn || record.effectiveOn > coverage.start) {
|
|
819
824
|
findings.push(periodGap(record, "policy", record.effectiveOn, coverage));
|
|
820
825
|
}
|