filegrc 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/model/v4.json +82 -72
- package/package.json +1 -1
- package/src/batch-review.js +142 -17
- package/src/cli.js +66 -2
- package/src/collection-review.js +15 -26
- package/src/collection-revision.js +83 -0
- package/src/collection-scope.js +160 -2
- package/src/content-readiness.js +11 -0
- package/src/files.js +5 -0
- package/src/git.js +54 -5
- package/src/index.js +1 -0
- package/src/policy-activation.js +84 -0
- package/src/program-lifecycle.js +4 -0
- package/src/program-path.js +11 -10
- package/src/program-readiness.js +237 -37
- package/src/reconciliation.js +3 -1
- package/src/server.js +12 -3
- package/src/validate.js +35 -20
- package/src/web.js +150 -23
- package/src/workflow.js +27 -1
package/src/batch-review.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
1
3
|
import { applyResourceBatch } from "./files.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
+
import { getWorkspaceRevisionSnapshot } from "./git.js";
|
|
5
|
+
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
6
|
+
import { resolveDataPath } from "./paths.js";
|
|
4
7
|
import { resolveProgram } from "./program.js";
|
|
8
|
+
import { markdownEntries } from "./resource-markdown.js";
|
|
9
|
+
import { assessWorkflow, buildWorkflowDelta } from "./workflow.js";
|
|
10
|
+
import { loadWorkspace } from "./workspace.js";
|
|
5
11
|
|
|
6
12
|
const REVIEWABLE_TYPES = new Set([
|
|
7
13
|
"requirement",
|
|
@@ -11,7 +17,8 @@ const REVIEWABLE_TYPES = new Set([
|
|
|
11
17
|
]);
|
|
12
18
|
|
|
13
19
|
export async function scaffoldApplicabilityReview(input = process.cwd(), options = {}) {
|
|
14
|
-
const
|
|
20
|
+
const context = await applicabilityReviewContext(input);
|
|
21
|
+
const { loaded } = context;
|
|
15
22
|
if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
|
|
16
23
|
throw new Error("Batch applicability review requires a model v3 or v4 workspace.");
|
|
17
24
|
}
|
|
@@ -32,6 +39,7 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
|
|
|
32
39
|
&& !["retired", "superseded"].includes(record.status)
|
|
33
40
|
));
|
|
34
41
|
return {
|
|
42
|
+
basis: context.basis,
|
|
35
43
|
reviewedByIds: [],
|
|
36
44
|
reviewedOn: null,
|
|
37
45
|
decisions: records
|
|
@@ -45,14 +53,25 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
|
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
export async function planApplicabilityReview(input = process.cwd(), options = {}) {
|
|
48
|
-
const
|
|
56
|
+
const context = await applicabilityReviewContext(input);
|
|
57
|
+
return planApplicabilityReviewWithContext(context, options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function planApplicabilityReviewWithContext(context, options) {
|
|
61
|
+
const { basis, loaded } = context;
|
|
49
62
|
if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
|
|
50
63
|
throw new Error("Batch applicability review requires a model v3 or v4 workspace.");
|
|
51
64
|
}
|
|
52
65
|
if (!Array.isArray(options.decisions) || !options.decisions.length) {
|
|
53
66
|
throw new Error("Applicability review needs at least one decision.");
|
|
54
67
|
}
|
|
68
|
+
if (options.basis !== undefined) assertApplicabilityReviewBasis(options.basis, basis);
|
|
55
69
|
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
70
|
+
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, entry.revision]));
|
|
71
|
+
const expectedRevisions = options.expectedRevisions ?? {};
|
|
72
|
+
if (Array.isArray(expectedRevisions) || typeof expectedRevisions !== "object" || expectedRevisions === null) {
|
|
73
|
+
throw new Error("Batch expected revisions must be keyed by resource ID.");
|
|
74
|
+
}
|
|
56
75
|
const program = resolveProgram(loaded, options.programId);
|
|
57
76
|
const v4RequirementDecisions = [];
|
|
58
77
|
const update = options.decisions.flatMap((decision) => {
|
|
@@ -62,12 +81,6 @@ export async function planApplicabilityReview(input = process.cwd(), options = {
|
|
|
62
81
|
}
|
|
63
82
|
const reviewedByIds = [...new Set((decision.reviewedByIds || options.reviewedByIds || []).map(String))];
|
|
64
83
|
const reviewedOn = String(decision.reviewedOn || options.reviewedOn || "").trim();
|
|
65
|
-
const scopeRevision = String(
|
|
66
|
-
decision.scopeRevision
|
|
67
|
-
|| options.scopeRevision
|
|
68
|
-
|| getGitSummary(loaded.root).commit
|
|
69
|
-
|| "uncommitted"
|
|
70
|
-
).trim();
|
|
71
84
|
const rationale = String(decision.rationale || "").trim();
|
|
72
85
|
const result = String(decision.decision || "").trim();
|
|
73
86
|
if (!["applicable", "not-applicable", "externally-managed", "zero-population"].includes(result)) {
|
|
@@ -83,7 +96,7 @@ export async function planApplicabilityReview(input = process.cwd(), options = {
|
|
|
83
96
|
rationale,
|
|
84
97
|
reviewedByIds,
|
|
85
98
|
reviewedOn,
|
|
86
|
-
scopeRevision
|
|
99
|
+
scopeRevision: basis.scopeRevision
|
|
87
100
|
}
|
|
88
101
|
};
|
|
89
102
|
if (record.type === "requirement") {
|
|
@@ -91,7 +104,14 @@ export async function planApplicabilityReview(input = process.cwd(), options = {
|
|
|
91
104
|
throw new Error(`Requirement "${record.id}" must be applicable or not-applicable.`);
|
|
92
105
|
}
|
|
93
106
|
if (String(loaded.model.modelVersion) === "4") {
|
|
94
|
-
v4RequirementDecisions.push({
|
|
107
|
+
v4RequirementDecisions.push({
|
|
108
|
+
requirementId: record.id,
|
|
109
|
+
decision: result,
|
|
110
|
+
rationale,
|
|
111
|
+
reviewedByIds,
|
|
112
|
+
reviewedOn,
|
|
113
|
+
scopeRevision: basis.scopeRevision
|
|
114
|
+
});
|
|
95
115
|
return [];
|
|
96
116
|
}
|
|
97
117
|
next.applicability = result;
|
|
@@ -113,20 +133,125 @@ export async function planApplicabilityReview(input = process.cwd(), options = {
|
|
|
113
133
|
}
|
|
114
134
|
return {
|
|
115
135
|
operation: "applicability-review",
|
|
136
|
+
basis,
|
|
116
137
|
reviewedIds: options.decisions.map(({ id }) => id),
|
|
117
138
|
changes: {
|
|
118
139
|
update,
|
|
119
|
-
expectedRevisions:
|
|
140
|
+
expectedRevisions: Object.fromEntries(update.map((record) => [
|
|
141
|
+
record.id,
|
|
142
|
+
expectedRevisions[record.id] || revisionById.get(record.id)
|
|
143
|
+
])),
|
|
120
144
|
validateWholeWorkspace: true
|
|
121
145
|
}
|
|
122
146
|
};
|
|
123
147
|
}
|
|
124
148
|
|
|
125
|
-
export
|
|
149
|
+
export function applyApplicabilityReview(input = process.cwd(), options = {}) {
|
|
150
|
+
return applyApplicabilityReviewWithContext(input, options);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function applyApplicabilityReviewWithContext(input = process.cwd(), options = {}, contextOptions = {}) {
|
|
126
154
|
if (options.confirmed !== true) {
|
|
127
155
|
throw new Error("Preview the applicability decisions and confirm the write.");
|
|
128
156
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
158
|
+
const context = await applicabilityReviewContext(root, contextOptions);
|
|
159
|
+
if (options.basis === undefined) {
|
|
160
|
+
throw new Error("Applicability review apply requires the basis returned by scaffold or --preview --json. Generate the decisions again before confirming the write.");
|
|
161
|
+
}
|
|
162
|
+
const plan = planApplicabilityReviewWithContext(context, options);
|
|
163
|
+
const before = contextOptions.includeWorkflowDelta
|
|
164
|
+
? await assessWorkflow(context.loaded, { git: workflowGitState(context.repository) })
|
|
165
|
+
: null;
|
|
166
|
+
const result = await applyResourceBatch(root, plan.changes);
|
|
167
|
+
if (!before) return { ...plan, result };
|
|
168
|
+
const after = await assessWorkflow(result.validation.loaded, {
|
|
169
|
+
git: workflowGitState(context.repository),
|
|
170
|
+
validation: result.validation
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
...plan,
|
|
174
|
+
result,
|
|
175
|
+
workflowDelta: buildWorkflowDelta(before, after)
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function applicabilityReviewContext(input, options = {}) {
|
|
181
|
+
const loaded = await loadWorkspace(input);
|
|
182
|
+
const repository = options.repositorySnapshot ?? await getWorkspaceRevisionSnapshot(loaded.root);
|
|
183
|
+
return {
|
|
184
|
+
basis: await applicabilityReviewBasis(loaded, repository),
|
|
185
|
+
loaded,
|
|
186
|
+
repository
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function workflowGitState(repository) {
|
|
191
|
+
return {
|
|
192
|
+
...repository,
|
|
193
|
+
commit: repository.currentCommit ?? repository.commit ?? null
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function applicabilityReviewBasis(loaded, repository) {
|
|
198
|
+
const scopeFingerprint = await applicabilityScopeFingerprint(loaded);
|
|
199
|
+
const commit = repository.currentCommit ?? repository.commit ?? null;
|
|
200
|
+
const clean = repository.clean === true || repository.wholeWorktreeClean === true;
|
|
201
|
+
return {
|
|
202
|
+
scopeRevision: commit && clean ? commit : `uncommitted:${scopeFingerprint}`,
|
|
203
|
+
scopeFingerprint
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function assertApplicabilityReviewBasis(expected, current) {
|
|
208
|
+
if (
|
|
209
|
+
!expected
|
|
210
|
+
|| Array.isArray(expected)
|
|
211
|
+
|| typeof expected !== "object"
|
|
212
|
+
|| typeof expected.scopeRevision !== "string"
|
|
213
|
+
|| typeof expected.scopeFingerprint !== "string"
|
|
214
|
+
) {
|
|
215
|
+
throw new Error("Applicability review basis must include its scope revision and scope fingerprint. Generate a new scaffold or preview.");
|
|
216
|
+
}
|
|
217
|
+
if (
|
|
218
|
+
expected.scopeRevision !== current.scopeRevision
|
|
219
|
+
|| expected.scopeFingerprint !== current.scopeFingerprint
|
|
220
|
+
) {
|
|
221
|
+
throw new Error("The workspace scope changed after this applicability review was prepared. Generate a new preview before confirming the write.");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function applicabilityScopeFingerprint(loaded) {
|
|
226
|
+
const hash = createHash("sha256");
|
|
227
|
+
hash.update(`model\0${loaded.model.modelVersion}\0`);
|
|
228
|
+
for (const entry of [...loaded.entries].sort((left, right) => compareText(left.relativePath, right.relativePath))) {
|
|
229
|
+
hash.update(`record\0${entry.relativePath}\0${stableJson(entry.record)}\0`);
|
|
230
|
+
for (const markdown of markdownEntries(loaded.model, entry.record).sort((left, right) => (
|
|
231
|
+
compareText(left.path, right.path)
|
|
232
|
+
))) {
|
|
233
|
+
try {
|
|
234
|
+
const source = await readFile(resolveDataPath(loaded.root, markdown.path), "utf8");
|
|
235
|
+
hash.update(`markdown\0${markdown.path}\0${source.length}\0${source}\0`);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error.code !== "ENOENT") throw error;
|
|
238
|
+
hash.update(`markdown-missing\0${markdown.path}\0`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return hash.digest("hex");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function compareText(left, right) {
|
|
246
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function stableJson(value) {
|
|
250
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
251
|
+
if (value && typeof value === "object") {
|
|
252
|
+
return `{${Object.keys(value).sort().map((key) => (
|
|
253
|
+
`${JSON.stringify(key)}:${stableJson(value[key])}`
|
|
254
|
+
)).join(",")}}`;
|
|
255
|
+
}
|
|
256
|
+
return JSON.stringify(value);
|
|
132
257
|
}
|
package/src/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldRes
|
|
|
6
6
|
import { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
|
|
7
7
|
import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
applyApplicabilityReviewWithContext,
|
|
10
10
|
planApplicabilityReview,
|
|
11
11
|
scaffoldApplicabilityReview
|
|
12
12
|
} from "./batch-review.js";
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
setupExternalReviewerGovernance
|
|
42
42
|
} from "./external-reviewer.js";
|
|
43
43
|
import { relativeToWorkspace, resolveDataPath } from "./paths.js";
|
|
44
|
+
import { activatePolicies, planPolicyActivation, scaffoldPolicyActivation } from "./policy-activation.js";
|
|
44
45
|
import { buildAgentProgramPath } from "./program-path.js";
|
|
45
46
|
import { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
|
|
46
47
|
import { resolveProgram } from "./program.js";
|
|
@@ -432,6 +433,12 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
432
433
|
console.log(`\n${stage.title}`);
|
|
433
434
|
for (const item of stage.items) console.log(`${item.status.toUpperCase()}\t${item.title}\t${item.message}`);
|
|
434
435
|
}
|
|
436
|
+
if (result.policyActivations.length) {
|
|
437
|
+
console.log("\nPolicy activation assessments");
|
|
438
|
+
for (const policy of result.policyActivations) {
|
|
439
|
+
console.log(`${policy.label.toUpperCase()}\t${policy.title}\t${policy.gapCount} implementation gaps`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
435
442
|
if (result.canStartCandidatePeriod && !result.operating) {
|
|
436
443
|
console.log(`\nEvidence Ready: management can start the candidate Type 2 period on or after ${result.suggestedCandidatePeriodStart || result.asOf}.`);
|
|
437
444
|
}
|
|
@@ -561,7 +568,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
561
568
|
const options = { ...payload, confirmed: flags.yes === true };
|
|
562
569
|
const result = flags.preview
|
|
563
570
|
? await planApplicabilityReview(root, options)
|
|
564
|
-
: await
|
|
571
|
+
: await applyApplicabilityReviewWithContext(root, options, { includeWorkflowDelta: true });
|
|
565
572
|
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
566
573
|
else if (flags.preview) console.log(`Applicability preview: ${result.reviewedIds.length} decisions.`);
|
|
567
574
|
else console.log(`Recorded ${result.reviewedIds.length} reviewed applicability decisions.`);
|
|
@@ -588,6 +595,27 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
588
595
|
else console.log(`Confirmed ${result.assessment.configuration.title}.`);
|
|
589
596
|
return result;
|
|
590
597
|
}
|
|
598
|
+
if (command === "activate-policies") {
|
|
599
|
+
if (flags.scaffold) {
|
|
600
|
+
const result = await scaffoldPolicyActivation(root, { programId: flags.program });
|
|
601
|
+
console.log(JSON.stringify(result, null, 2));
|
|
602
|
+
return result;
|
|
603
|
+
}
|
|
604
|
+
const payload = await readSetupPayload(positionals[0]);
|
|
605
|
+
const options = {
|
|
606
|
+
...payload,
|
|
607
|
+
policyIds: flags.policy ? String(flags.policy).split(",").filter(Boolean) : payload.policyIds,
|
|
608
|
+
effectiveOn: flags["effective-on"] || payload.effectiveOn,
|
|
609
|
+
confirmed: flags.yes === true
|
|
610
|
+
};
|
|
611
|
+
const result = flags.preview
|
|
612
|
+
? await planPolicyActivation(root, options)
|
|
613
|
+
: await withWorkflowDelta(root, () => activatePolicies(root, options));
|
|
614
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
615
|
+
else if (flags.preview) console.log(`Policy activation preview: ${result.policyIds.length} Policies effective ${result.effectiveOn}.`);
|
|
616
|
+
else console.log(`Activated ${result.policyIds.length} Policies effective ${result.effectiveOn}.`);
|
|
617
|
+
return result;
|
|
618
|
+
}
|
|
591
619
|
if (command === "trigger") {
|
|
592
620
|
const result = await withWorkflowDelta(root, () => createObligationEvent(root, {
|
|
593
621
|
eventType: positionals[0],
|
|
@@ -1025,6 +1053,7 @@ Usage:
|
|
|
1025
1053
|
filegrc next-audit-cycle <prior-audit-id> [cycle.json|-] --start YYYY-MM-DD --end YYYY-MM-DD [--preview|--yes] [--json]
|
|
1026
1054
|
filegrc review-applicability [--scaffold --type requirement|control|commitment|complementary-control] [decisions.json|-] [--preview|--yes] [--json]
|
|
1027
1055
|
filegrc review-collection <resource-type> [--scaffold | review.json|-] [--preview|--yes] [--json]
|
|
1056
|
+
filegrc activate-policies [--scaffold | activation.json|-] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1028
1057
|
filegrc trigger <event-type> (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) [--risk-level normal|high] [--subject resource-id[,resource-id]] [--title text] [--json]
|
|
1029
1058
|
filegrc evidence-packet [--audit audit-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
|
|
1030
1059
|
filegrc get [resource-type] <id> [--mutation]
|
|
@@ -1174,6 +1203,26 @@ Options:
|
|
|
1174
1203
|
--help Show this help`);
|
|
1175
1204
|
return;
|
|
1176
1205
|
}
|
|
1206
|
+
if (command === "activate-policies") {
|
|
1207
|
+
console.log(`Usage:
|
|
1208
|
+
filegrc activate-policies --scaffold [--program id]
|
|
1209
|
+
filegrc activate-policies <activation.json|-> [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1210
|
+
|
|
1211
|
+
Review and atomically activate selected approved Policies at the end of Step 3.
|
|
1212
|
+
The scaffold includes every required, approved, inactive Policy and its current
|
|
1213
|
+
revision. A past effective date is rejected.
|
|
1214
|
+
|
|
1215
|
+
Options:
|
|
1216
|
+
--scaffold Print a cutover payload without writing
|
|
1217
|
+
--program <id> Program to assess when more than one active Program exists
|
|
1218
|
+
--effective-on <date> Shared effective date for the selected Policies
|
|
1219
|
+
--preview Validate and show the atomic updates without writing
|
|
1220
|
+
--yes Confirm and apply the reviewed cutover
|
|
1221
|
+
--json Print the result as JSON
|
|
1222
|
+
--root <path> Workspace path
|
|
1223
|
+
--help Show this help`);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1177
1226
|
if (command === "evidence-map") {
|
|
1178
1227
|
console.log(`Usage:
|
|
1179
1228
|
filegrc evidence-map [options]
|
|
@@ -1220,6 +1269,7 @@ function agentOverview(model) {
|
|
|
1220
1269
|
prepareAudit: "filegrc prepare-audit <audit-id>",
|
|
1221
1270
|
reconcile: "filegrc reconcile --preview --json",
|
|
1222
1271
|
externalReviewerSetup: "filegrc external-reviewer-setup [--scaffold | <reviewer.json|-> --preview] --json",
|
|
1272
|
+
policyActivation: "filegrc activate-policies [--scaffold | <activation.json|-> --preview] --json",
|
|
1223
1273
|
nextAuditCycle: "filegrc next-audit-cycle <prior-audit-id> --start <date> --end <date> --preview --json",
|
|
1224
1274
|
reviewApplicability: "filegrc review-applicability <decisions.json|-> --preview --json",
|
|
1225
1275
|
reviewCollection: "filegrc review-collection <resource-type> [--scaffold | <review.json|-> --preview] --json",
|
|
@@ -1371,6 +1421,8 @@ function buildProgramPathResult(model, readiness, auditReadiness) {
|
|
|
1371
1421
|
currentStep: { id: currentStep.id, number: currentStep.number, title: currentStep.title },
|
|
1372
1422
|
evidenceReady: readiness.evidenceReady,
|
|
1373
1423
|
operating: readiness.operating,
|
|
1424
|
+
policyActivations: readiness.policyActivations,
|
|
1425
|
+
policyLibraryProposals: readiness.policyLibraryProposals,
|
|
1374
1426
|
stages
|
|
1375
1427
|
};
|
|
1376
1428
|
}
|
|
@@ -1418,6 +1470,8 @@ function summarizeProgramPath(result) {
|
|
|
1418
1470
|
currentStep: result.currentStep,
|
|
1419
1471
|
evidenceReady: result.evidenceReady,
|
|
1420
1472
|
operating: result.operating,
|
|
1473
|
+
policyActivations: result.policyActivations,
|
|
1474
|
+
policyLibraryProposals: result.policyLibraryProposals,
|
|
1421
1475
|
stages: result.stages.map((stage) => ({
|
|
1422
1476
|
id: stage.id,
|
|
1423
1477
|
number: stage.number,
|
|
@@ -1439,6 +1493,8 @@ function nextProgramPath(result) {
|
|
|
1439
1493
|
currentStep: result.currentStep,
|
|
1440
1494
|
evidenceReady: result.evidenceReady,
|
|
1441
1495
|
operating: result.operating,
|
|
1496
|
+
policyActivations: result.policyActivations,
|
|
1497
|
+
policyLibraryProposals: result.policyLibraryProposals,
|
|
1442
1498
|
step: stage ? {
|
|
1443
1499
|
id: stage.id,
|
|
1444
1500
|
number: stage.number,
|
|
@@ -1562,6 +1618,14 @@ function summarizeProgramReadiness(result) {
|
|
|
1562
1618
|
scopeCounts: Object.fromEntries(
|
|
1563
1619
|
Object.entries(result.scope).map(([name, ids]) => [name.replace(/Ids$/, ""), ids.length])
|
|
1564
1620
|
),
|
|
1621
|
+
policyActivations: result.policyActivations.map(({ policyId, title, state, label, gapCount }) => ({
|
|
1622
|
+
policyId,
|
|
1623
|
+
title,
|
|
1624
|
+
state,
|
|
1625
|
+
label,
|
|
1626
|
+
gapCount
|
|
1627
|
+
})),
|
|
1628
|
+
policyLibraryProposals: result.policyLibraryProposals,
|
|
1565
1629
|
unresolvedOwnership: {
|
|
1566
1630
|
count: unresolvedOwnership.length,
|
|
1567
1631
|
byReason: ownershipReasons,
|
package/src/collection-review.js
CHANGED
|
@@ -1,33 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { collectionRevision } from "./collection-revision.js";
|
|
2
3
|
import { scopedCollectionRecords } from "./collection-scope.js";
|
|
3
4
|
import { applyResourceBatch } from "./files.js";
|
|
4
5
|
import { getGitSummary } from "./git.js";
|
|
5
6
|
import { loadWorkspace } from "./workspace.js";
|
|
6
|
-
import { resolveProgram
|
|
7
|
+
import { resolveProgram } from "./program.js";
|
|
7
8
|
|
|
8
|
-
export
|
|
9
|
-
const program = resolveProgram(loaded, options.programId);
|
|
10
|
-
const scopedIds = new Set(scopedCollectionRecords(loaded, resourceType, program).map(({ id }) => id));
|
|
11
|
-
const records = loaded.entries
|
|
12
|
-
.filter(({ record }) => record.type === resourceType && scopedIds.has(record.id))
|
|
13
|
-
.map(({ record, source }) => ({
|
|
14
|
-
id: record.id,
|
|
15
|
-
revision: createHash("sha256").update(source).digest("hex")
|
|
16
|
-
}))
|
|
17
|
-
.sort((left, right) => left.id.localeCompare(right.id));
|
|
18
|
-
const workspaceScope = {
|
|
19
|
-
programId: program?.id ?? null,
|
|
20
|
-
assuranceGoal: program?.assuranceGoal ?? null,
|
|
21
|
-
candidateCoverage: program?.candidateCoverage ?? null,
|
|
22
|
-
systemIds: [...(program?.systemIds || [])].sort(),
|
|
23
|
-
frameworkIds: [...(program?.frameworkIds || [])].sort(),
|
|
24
|
-
requirementIds: [...selectedRequirementIds(program || {}, loaded.model)].sort(),
|
|
25
|
-
controlIds: [...(program?.controlIds || [])].sort()
|
|
26
|
-
};
|
|
27
|
-
return createHash("sha256")
|
|
28
|
-
.update(JSON.stringify({ resourceType, records, workspaceScope }))
|
|
29
|
-
.digest("hex");
|
|
30
|
-
}
|
|
9
|
+
export { collectionRevision };
|
|
31
10
|
|
|
32
11
|
export function assessCollectionReviews(input, options = {}) {
|
|
33
12
|
const loaded = input?.resources && input?.model && input?.entries
|
|
@@ -50,7 +29,13 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
50
29
|
&& (String(loaded.model.modelVersion) !== "4" || (record.scopeResourceIds || []).includes(program.id))
|
|
51
30
|
));
|
|
52
31
|
const review = reviewEntry?.record || null;
|
|
53
|
-
const
|
|
32
|
+
const authoritativeSourceId = review?.decision === "externally-managed"
|
|
33
|
+
? review.authoritativeComponentId || review.authoritativeSystemId
|
|
34
|
+
: null;
|
|
35
|
+
const currentRevision = collectionRevision(loaded, resourceType, {
|
|
36
|
+
programId: program.id,
|
|
37
|
+
authoritativeSourceId
|
|
38
|
+
});
|
|
54
39
|
const allowedDecisions = configuration.decisions || ["complete"];
|
|
55
40
|
const allowsEmptyCollection = allowedDecisions.some((decision) => (
|
|
56
41
|
decision === "zero-population" || decision === "externally-managed"
|
|
@@ -147,6 +132,10 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
147
132
|
));
|
|
148
133
|
if (!system) throw new Error(`${configuration.title} review needs an active authoritative ${v4 ? "Component" : "System"}.`);
|
|
149
134
|
}
|
|
135
|
+
const currentRevision = collectionRevision(loaded, resourceType, {
|
|
136
|
+
programId: program.id,
|
|
137
|
+
authoritativeSourceId: decision === "externally-managed" ? authoritativeSourceId : null
|
|
138
|
+
});
|
|
150
139
|
const existing = assessment.review;
|
|
151
140
|
const record = {
|
|
152
141
|
...(existing || {
|
|
@@ -161,7 +150,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
161
150
|
rationale,
|
|
162
151
|
reviewedByIds,
|
|
163
152
|
reviewedOn,
|
|
164
|
-
collectionRevision:
|
|
153
|
+
collectionRevision: currentRevision,
|
|
165
154
|
scopeRevision,
|
|
166
155
|
...(decision === "externally-managed"
|
|
167
156
|
? { [v4 ? "authoritativeComponentId" : "authoritativeSystemId"]: authoritativeSourceId }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
collectionRevisionInputs,
|
|
5
|
+
collectionScopeRevisionFacts
|
|
6
|
+
} from "./collection-scope.js";
|
|
7
|
+
import { resolveDataPath } from "./paths.js";
|
|
8
|
+
import { resolveProgram } from "./program.js";
|
|
9
|
+
import { markdownEntries } from "./resource-markdown.js";
|
|
10
|
+
|
|
11
|
+
export function collectionRevision(loaded, resourceType, options = {}) {
|
|
12
|
+
const program = Object.hasOwn(options, "program")
|
|
13
|
+
? options.program
|
|
14
|
+
: resolveProgram(loaded, options.programId);
|
|
15
|
+
const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program)
|
|
16
|
+
.map((input) => [input.record.id, input]));
|
|
17
|
+
const authoritativeSource = loaded.resources.find(({ id }) => id === options.authoritativeSourceId);
|
|
18
|
+
if (authoritativeSource) {
|
|
19
|
+
inputs.set(authoritativeSource.id, { record: authoritativeSource, value: authoritativeSource });
|
|
20
|
+
}
|
|
21
|
+
const records = [...inputs.values()]
|
|
22
|
+
.map(({ record, value }) => ({
|
|
23
|
+
id: record.id,
|
|
24
|
+
revision: createHash("sha256")
|
|
25
|
+
.update(JSON.stringify(canonicalRecordValue(loaded.model, record.type, value)))
|
|
26
|
+
.digest("hex"),
|
|
27
|
+
contentRevisions: markdownEntries(loaded.model, record).flatMap(({ path }) => {
|
|
28
|
+
try {
|
|
29
|
+
const content = readFileSync(resolveDataPath(loaded.root, path), "utf8");
|
|
30
|
+
return [{ path, revision: createHash("sha256").update(content).digest("hex") }];
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error.code === "ENOENT") return [];
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
}))
|
|
37
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
38
|
+
const workspaceScope = collectionScopeRevisionFacts(loaded, resourceType, program);
|
|
39
|
+
return createHash("sha256")
|
|
40
|
+
.update(JSON.stringify({ resourceType, records, workspaceScope }))
|
|
41
|
+
.digest("hex");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function canonicalRecordValue(model, resourceType, value) {
|
|
45
|
+
const fields = {
|
|
46
|
+
...model.commonFields,
|
|
47
|
+
...model.resources[resourceType]?.fields
|
|
48
|
+
};
|
|
49
|
+
return canonicalObject(model, value, fields);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function canonicalObject(model, value, fields = {}) {
|
|
53
|
+
return Object.fromEntries(Object.keys(value).sort().map((name) => [
|
|
54
|
+
name,
|
|
55
|
+
canonicalFieldValue(model, value[name], fields[name])
|
|
56
|
+
]));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function canonicalFieldValue(model, value, field) {
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
const objectType = field?.itemObjectType;
|
|
62
|
+
const itemFields = objectType ? model.objectTypes?.[objectType]?.properties : undefined;
|
|
63
|
+
const items = value.map((item) => (
|
|
64
|
+
item && typeof item === "object" && !Array.isArray(item)
|
|
65
|
+
? canonicalObject(model, item, itemFields)
|
|
66
|
+
: item
|
|
67
|
+
));
|
|
68
|
+
return field?.type === "array"
|
|
69
|
+
? items.sort(compareCanonicalValues)
|
|
70
|
+
: items;
|
|
71
|
+
}
|
|
72
|
+
if (value && typeof value === "object") {
|
|
73
|
+
const objectType = field?.objectType;
|
|
74
|
+
return canonicalObject(model, value, objectType ? model.objectTypes?.[objectType]?.properties : undefined);
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function compareCanonicalValues(left, right) {
|
|
80
|
+
const leftValue = JSON.stringify(left);
|
|
81
|
+
const rightValue = JSON.stringify(right);
|
|
82
|
+
return leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0;
|
|
83
|
+
}
|