filegrc 0.16.15 → 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/files.js +2 -2
- package/src/program-readiness.js +3 -1
- package/src/revisions.js +6 -0
- package/src/state.js +1 -0
- package/src/validate.js +2 -2
- package/src/web.js +25 -3
- package/src/workflow.js +3 -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/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 }
|
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
|
@@ -370,6 +370,7 @@ function serializeCollectionReviewAssessment(assessment) {
|
|
|
370
370
|
status: assessment.status,
|
|
371
371
|
complete: assessment.complete,
|
|
372
372
|
message: assessment.message,
|
|
373
|
+
...(assessment.changesSinceReview ? { changesSinceReview: assessment.changesSinceReview } : {}),
|
|
373
374
|
...(assessment.recordProposals ? { recordProposals: assessment.recordProposals } : {}),
|
|
374
375
|
...(assessment.incompleteRecordProposals ? { incompleteRecordProposals: assessment.incompleteRecordProposals } : {}),
|
|
375
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"),
|
|
@@ -6584,6 +6605,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6584
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}
|
|
6585
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}
|
|
6586
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)}
|
|
6587
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}
|
|
6588
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}}
|
|
6589
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";
|
|
@@ -544,8 +545,8 @@ function finalizationFields(record, model, resources, program, root) {
|
|
|
544
545
|
field: "applicabilityReview",
|
|
545
546
|
requiredness: "required",
|
|
546
547
|
message: record.applicabilityReview?.scopeRevision
|
|
547
|
-
? `Review applicability against the current service scope. Stored revision: ${record.applicabilityReview.scopeRevision}. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.`
|
|
548
|
-
: `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.`
|
|
549
550
|
});
|
|
550
551
|
}
|
|
551
552
|
if (record.type === "policy" && model.resources.policy?.fields?.programRole && !record.programRole) {
|