filegrc 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/model/index.js +9 -5
- package/model/v3.json +9391 -0
- package/package.json +3 -3
- package/src/agent.js +53 -0
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +47 -6
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +418 -61
- package/src/collection-review.js +185 -0
- package/src/evidence-packet.js +34 -2
- package/src/external-reviewer.js +165 -0
- package/src/files.js +46 -10
- package/src/index.js +36 -2
- package/src/model-docs.js +15 -0
- package/src/model-migration.js +516 -21
- package/src/obligations.js +396 -13
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +49 -12
- package/src/program-readiness.js +331 -27
- package/src/reconciliation.js +277 -0
- package/src/server.js +242 -14
- package/src/setup.js +36 -4
- package/src/source-coverage.js +61 -0
- package/src/startup.js +1 -1
- package/src/state.js +37 -1
- package/src/validate.js +100 -3
- package/src/web.js +979 -213
- package/src/workflow.js +1595 -0
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "filegrc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Zero-dependency Git-native GRC engine",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/
|
|
8
|
+
"url": "git+https://github.com/Alignbase/filegrc.git"
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"src"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"test": "node --test"
|
|
24
|
+
"test": "node --test --test-concurrency=1"
|
|
25
25
|
},
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": ">=20"
|
package/src/agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createResourceId } from "./id.js";
|
|
2
2
|
import { markdownEntries } from "./resource-markdown.js";
|
|
3
3
|
import { RESOURCE_INSTRUCTIONS, resourceProgramContext } from "./program-path.js";
|
|
4
|
+
import { assessCollectionReview } from "./collection-review.js";
|
|
4
5
|
|
|
5
6
|
const STARTING_STATUS_ORDER = [
|
|
6
7
|
"draft",
|
|
@@ -28,6 +29,7 @@ export function listResourceTypes(model) {
|
|
|
28
29
|
export function buildAgentGuide(loaded, type, options = {}) {
|
|
29
30
|
const definition = loaded.model.resources[type];
|
|
30
31
|
if (!definition) throw new Error(`Unknown resource type "${type}".`);
|
|
32
|
+
const collectionReview = assessCollectionReview(loaded, type);
|
|
31
33
|
const fields = { ...loaded.model.commonFields, ...definition.fields };
|
|
32
34
|
const required = new Set([
|
|
33
35
|
...Object.entries(loaded.model.commonFields)
|
|
@@ -81,8 +83,23 @@ export function buildAgentGuide(loaded, type, options = {}) {
|
|
|
81
83
|
programStep: resourceProgramContext(type),
|
|
82
84
|
policyBasis: definition.guidance.policyBasis,
|
|
83
85
|
cadence: definition.guidance.cadence,
|
|
86
|
+
emptyState: definition.guidance.emptyState ?? null,
|
|
84
87
|
policySourceIds: definition.guidance.sourceResourceIds ?? [],
|
|
85
88
|
obligationActivityTypes: definition.guidance.obligationActivityTypes ?? [],
|
|
89
|
+
reviewRequirements: {
|
|
90
|
+
recordReviewPoints: definition.guidance.reviewPoints ?? [],
|
|
91
|
+
collectionReview: collectionReview
|
|
92
|
+
? {
|
|
93
|
+
title: collectionReview.configuration.title,
|
|
94
|
+
description: collectionReview.configuration.description,
|
|
95
|
+
reviewPoints: collectionReview.configuration.reviewPoints,
|
|
96
|
+
allowedDecisions: collectionReview.configuration.decisions,
|
|
97
|
+
status: collectionReview.status,
|
|
98
|
+
recordCount: collectionReview.recordCount,
|
|
99
|
+
command: `npx filegrc review-collection ${type} --scaffold`
|
|
100
|
+
}
|
|
101
|
+
: null
|
|
102
|
+
},
|
|
86
103
|
location,
|
|
87
104
|
singleton: Boolean(definition.singleton),
|
|
88
105
|
requiredAtCreation,
|
|
@@ -155,6 +172,7 @@ export function scaffoldResourceMutation(loaded, type, title, options = {}) {
|
|
|
155
172
|
record[name] = scaffoldValue(name, field, loaded.model);
|
|
156
173
|
}
|
|
157
174
|
}
|
|
175
|
+
applyModelScaffoldDefaults(record, loaded);
|
|
158
176
|
|
|
159
177
|
const slots = markdownEntries(loaded.model, record).filter((slot) => (
|
|
160
178
|
slot.required
|
|
@@ -171,6 +189,41 @@ export function scaffoldResourceMutation(loaded, type, title, options = {}) {
|
|
|
171
189
|
};
|
|
172
190
|
}
|
|
173
191
|
|
|
192
|
+
function applyModelScaffoldDefaults(record, loaded) {
|
|
193
|
+
if (record.type === "appointment") {
|
|
194
|
+
const normalizedTitle = record.title.toLowerCase();
|
|
195
|
+
const match = Object.entries(loaded.model.appointmentTemplates || {}).find(([kind, template]) => (
|
|
196
|
+
template.title.toLowerCase() === normalizedTitle
|
|
197
|
+
|| kind === normalizedTitle.replace(/[^a-z0-9]+/g, "-")
|
|
198
|
+
));
|
|
199
|
+
record.appointmentKind = match?.[0] || normalizedTitle.replace(/[^a-z0-9]+/g, "-");
|
|
200
|
+
if (!record.scopeResourceIds?.length && loaded.workspace?.id) {
|
|
201
|
+
record.scopeResourceIds = [loaded.workspace.id];
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (record.type === "audit") {
|
|
206
|
+
const kind = {
|
|
207
|
+
"soc-2-type-1": "soc-2-type-1",
|
|
208
|
+
"soc-2-type-2": "soc-2-type-2"
|
|
209
|
+
}[loaded.workspace?.assuranceGoal];
|
|
210
|
+
if (kind) record.auditKind = kind;
|
|
211
|
+
for (const field of ["frameworkIds", "systemIds", "requirementIds", "controlIds"]) {
|
|
212
|
+
if (loaded.workspace?.[field]?.length) record[field] = [...loaded.workspace[field]];
|
|
213
|
+
}
|
|
214
|
+
const programOwner = loaded.resources.find((candidate) => (
|
|
215
|
+
candidate.type === "appointment"
|
|
216
|
+
&& candidate.appointmentKind === "program-lead"
|
|
217
|
+
&& candidate.status === "active"
|
|
218
|
+
)) || loaded.resources.find((candidate) => (
|
|
219
|
+
candidate.type === "appointment"
|
|
220
|
+
&& candidate.appointmentKind === "policy-owner"
|
|
221
|
+
&& candidate.status === "active"
|
|
222
|
+
));
|
|
223
|
+
if (programOwner) record.ownerIds = [programOwner.id];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
174
227
|
export function findResourceReferences(loaded, id) {
|
|
175
228
|
const target = loaded.resources.find((record) => record.id === id);
|
|
176
229
|
if (!target) throw new Error(`Resource "${id}" was not found.`);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function assessRequiredAppointments(records, model) {
|
|
2
|
+
const templates = model.appointmentTemplates || {};
|
|
3
|
+
return Object.entries(templates).map(([kind, template]) => {
|
|
4
|
+
const appointments = records.filter((record) => (
|
|
5
|
+
record.type === "appointment"
|
|
6
|
+
&& record.appointmentKind === kind
|
|
7
|
+
&& record.status !== "ended"
|
|
8
|
+
));
|
|
9
|
+
const active = appointments.find(({ status }) => status === "active");
|
|
10
|
+
const planned = appointments.find(({ status }) => status === "planned");
|
|
11
|
+
return {
|
|
12
|
+
kind,
|
|
13
|
+
template,
|
|
14
|
+
requiredness: template.requiredness,
|
|
15
|
+
record: active || planned || null,
|
|
16
|
+
state: active ? "complete" : "ready"
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
}
|
package/src/audit-preparation.js
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./coverage.js";
|
|
11
11
|
import { createResource, createResources, deleteResource, updateResource } from "./files.js";
|
|
12
12
|
import { createResourceId } from "./id.js";
|
|
13
|
-
import { partiesIndependent } from "./parties.js";
|
|
13
|
+
import { currentPartyPeople, partiesIndependent } from "./parties.js";
|
|
14
14
|
import { resolveDataPath } from "./paths.js";
|
|
15
15
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
16
16
|
import { markdownEntries } from "./resource-markdown.js";
|
|
@@ -433,7 +433,10 @@ function engagementStage(audit, byId, programReadiness) {
|
|
|
433
433
|
}
|
|
434
434
|
const auditor = audit.auditorVendorId ? byId.get(audit.auditorVendorId) : null;
|
|
435
435
|
const named = Boolean(auditor);
|
|
436
|
-
|
|
436
|
+
const currentOwners = [...currentPartyPeople(audit.ownerIds, byId)]
|
|
437
|
+
.map((id) => byId.get(id))
|
|
438
|
+
.filter(Boolean);
|
|
439
|
+
return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and the current management owner who authorizes and coordinates the engagement.", [
|
|
437
440
|
item(
|
|
438
441
|
"engagement-record",
|
|
439
442
|
"complete",
|
|
@@ -449,6 +452,21 @@ function engagementStage(audit, byId, programReadiness) {
|
|
|
449
452
|
? `${auditor.title} is recorded for the engagement.`
|
|
450
453
|
: "Select the CPA firm and record it here. The independent management policy reviewer is a different role.",
|
|
451
454
|
audit
|
|
455
|
+
),
|
|
456
|
+
item(
|
|
457
|
+
"engagement-owner",
|
|
458
|
+
currentOwners.length ? "complete" : "action",
|
|
459
|
+
"Confirm the management engagement owner",
|
|
460
|
+
currentOwners.length
|
|
461
|
+
? `${currentOwners.map(({ title }) => title).join(" and ")} currently owns management coordination for the engagement.`
|
|
462
|
+
: "Assign the audit to a current Person, Team, or Appointment. The audit owner may coordinate management and evidence work without a separate audit-specific title.",
|
|
463
|
+
audit,
|
|
464
|
+
{
|
|
465
|
+
commands: [
|
|
466
|
+
`npx filegrc get ${audit.id} --mutation`,
|
|
467
|
+
"npx filegrc audit-readiness AUDIT_ID --json"
|
|
468
|
+
]
|
|
469
|
+
}
|
|
452
470
|
)
|
|
453
471
|
]);
|
|
454
472
|
}
|
|
@@ -556,12 +574,21 @@ function evidenceStage(audit, records, byId, model) {
|
|
|
556
574
|
&& controlIdsForRecord(record, byId).size
|
|
557
575
|
&& recordRelevantToAuditDate(record, audit, model)
|
|
558
576
|
));
|
|
577
|
+
const reconciledZeroPopulationControlIds = new Set(records
|
|
578
|
+
.filter((record) => (
|
|
579
|
+
record.type === "audit-population"
|
|
580
|
+
&& record.auditId === audit.id
|
|
581
|
+
&& record.status === "reconciled"
|
|
582
|
+
&& record.conclusion === "complete"
|
|
583
|
+
&& byId.get(record.sourceEvidenceId)?.populationCount === 0
|
|
584
|
+
))
|
|
585
|
+
.flatMap((record) => record.controlIds || []));
|
|
559
586
|
const managedControls = controls.filter((control) => managedFamilies.some((family) => (
|
|
560
587
|
(family.controlCodes || []).includes(control.code)
|
|
561
588
|
)));
|
|
562
589
|
const controlsWithFilegrcRecords = managedControls.filter((control) => filegrcRecords.some((record) => (
|
|
563
590
|
controlIdsForRecord(record, byId).has(control.id)
|
|
564
|
-
)));
|
|
591
|
+
)) || reconciledZeroPopulationControlIds.has(control.id));
|
|
565
592
|
const externalControls = controls.filter((control) => externalFamilies.some((family) => (
|
|
566
593
|
(family.controlCodes || []).includes(control.code)
|
|
567
594
|
)) || !evidenceFamiliesFor(control).length);
|
|
@@ -574,7 +601,7 @@ function evidenceStage(audit, records, byId, model) {
|
|
|
574
601
|
managedControls.length && controlsWithFilegrcRecords.length === managedControls.length ? "complete" : managedControls.length ? "action" : "info",
|
|
575
602
|
"Review filegrc Evidence",
|
|
576
603
|
managedControls.length
|
|
577
|
-
? `${controlsWithFilegrcRecords.length} of ${managedControls.length} selected controls that use filegrc workflows have a dated operating record for the formal period. Complete each Step 4 record, link it to the control, and add results in its structured fields or Markdown.`
|
|
604
|
+
? `${controlsWithFilegrcRecords.length} of ${managedControls.length} selected controls that use filegrc workflows have a dated operating record or reconciled zero-event population for the formal period. Complete each Step 4 record, link it to the control, and add results in its structured fields or Markdown.`
|
|
578
605
|
: "No selected controls use a dedicated filegrc operating record.",
|
|
579
606
|
filegrcRecords[0] || { type: managedFamilies[0]?.operationRecordTypes?.[0] || "control" }
|
|
580
607
|
),
|
|
@@ -606,13 +633,16 @@ function evidenceStage(audit, records, byId, model) {
|
|
|
606
633
|
));
|
|
607
634
|
const coveredControls = relevantControls.filter((control) => sourceRecords.some((record) => (
|
|
608
635
|
controlIdsForRecord(record, byId).has(control.id)
|
|
609
|
-
)));
|
|
636
|
+
)) || reconciledZeroPopulationControlIds.has(control.id));
|
|
637
|
+
const zeroPopulationControls = relevantControls.filter((control) => (
|
|
638
|
+
reconciledZeroPopulationControlIds.has(control.id)
|
|
639
|
+
));
|
|
610
640
|
items.push(item(
|
|
611
641
|
`filegrc-${source.id}`,
|
|
612
642
|
coveredControls.length === relevantControls.length ? "complete" : "action",
|
|
613
643
|
source.title,
|
|
614
644
|
coveredControls.length === relevantControls.length
|
|
615
|
-
? `${sourceRecords.length} dated filegrc ${sourceRecords.length === 1 ? "record" : "records"} cover ${relevantControls.length} mapped controls.
|
|
645
|
+
? `${sourceRecords.length} dated filegrc ${sourceRecords.length === 1 ? "record" : "records"} and ${zeroPopulationControls.length} reconciled zero-population ${zeroPopulationControls.length === 1 ? "conclusion cover" : "conclusions cover"} ${relevantControls.length} mapped controls. Supporting artifacts are linked from the operating records or population export.`
|
|
616
646
|
: `${coveredControls.length} of ${relevantControls.length} mapped controls have a dated ${source.operationRecordTypes.map(displayValue).join(" or ")} record for the formal period. Complete the Step 4 work and attach or reference any supporting external artifact on that record.`,
|
|
617
647
|
sourceRecords[0] || { type: source.operationRecordTypes[0] }
|
|
618
648
|
));
|
|
@@ -846,6 +876,17 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
|
|
|
846
876
|
if (record.obligationId) {
|
|
847
877
|
for (const id of byId.get(record.obligationId)?.controlIds || []) ids.add(id);
|
|
848
878
|
}
|
|
879
|
+
for (const candidate of byId.values()) {
|
|
880
|
+
if (
|
|
881
|
+
candidate.type === "obligation"
|
|
882
|
+
&& (candidate.completionResourceIds || []).includes(record.id)
|
|
883
|
+
) {
|
|
884
|
+
for (const id of candidate.controlIds || []) ids.add(id);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
for (const subjectId of record.subjectResourceIds || []) {
|
|
888
|
+
for (const id of controlIdsForRecord(byId.get(subjectId), byId, seen)) ids.add(id);
|
|
889
|
+
}
|
|
849
890
|
for (const sourceId of record.sourceResourceIds || []) {
|
|
850
891
|
for (const id of controlIdsForRecord(byId.get(sourceId), byId, seen)) ids.add(id);
|
|
851
892
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createResource } from "./files.js";
|
|
2
|
+
import { createResourceId } from "./id.js";
|
|
3
|
+
import { loadWorkspace } from "./workspace.js";
|
|
4
|
+
|
|
5
|
+
export async function planNextAuditCycle(input = process.cwd(), options = {}) {
|
|
6
|
+
const loaded = await loadWorkspace(input);
|
|
7
|
+
if (String(loaded.model.modelVersion) !== "3") {
|
|
8
|
+
throw new Error("Audit-cycle carry-forward requires a model v3 workspace.");
|
|
9
|
+
}
|
|
10
|
+
const prior = loaded.resources.find((record) => (
|
|
11
|
+
record.type === "audit" && record.id === options.priorAuditId
|
|
12
|
+
));
|
|
13
|
+
if (!prior) throw new Error("A prior Audit record is required.");
|
|
14
|
+
const startsOn = required(options.startsOn, "Next period start");
|
|
15
|
+
const endsOn = required(options.endsOn, "Next period end");
|
|
16
|
+
if (startsOn > endsOn) throw new Error("The next period end must be on or after its start.");
|
|
17
|
+
const priorEnd = prior.coverage?.endsOn || prior.coverage?.on;
|
|
18
|
+
if (prior.auditKind === "soc-2-type-1" && priorEnd && startsOn <= priorEnd) {
|
|
19
|
+
throw new Error(`A Type 2 operating period must start after the Type 1 as-of date ${priorEnd}.`);
|
|
20
|
+
}
|
|
21
|
+
const title = String(options.title || nextTitle(prior, startsOn, endsOn)).trim();
|
|
22
|
+
const audit = {
|
|
23
|
+
id: options.id || createResourceId("audit", title, loaded.resources.map(({ id }) => id)),
|
|
24
|
+
type: "audit",
|
|
25
|
+
title,
|
|
26
|
+
status: "planned",
|
|
27
|
+
auditKind: "soc-2-type-2",
|
|
28
|
+
priorAuditId: prior.id,
|
|
29
|
+
coverage: { kind: "range", startsOn, endsOn },
|
|
30
|
+
frameworkIds: [...(prior.frameworkIds || [])],
|
|
31
|
+
systemIds: [...(prior.systemIds || [])],
|
|
32
|
+
requirementIds: [...(prior.requirementIds || [])],
|
|
33
|
+
controlIds: [...(prior.controlIds || [])],
|
|
34
|
+
complementaryControlIds: [...(prior.complementaryControlIds || [])],
|
|
35
|
+
subserviceVendorIds: [...(prior.subserviceVendorIds || [])],
|
|
36
|
+
...(prior.subserviceMethod ? { subserviceMethod: prior.subserviceMethod } : {}),
|
|
37
|
+
...(prior.complementaryControlsConclusion
|
|
38
|
+
? { complementaryControlsConclusion: prior.complementaryControlsConclusion }
|
|
39
|
+
: {}),
|
|
40
|
+
...(prior.auditorVendorId ? { auditorVendorId: prior.auditorVendorId } : {}),
|
|
41
|
+
contactIds: [...(prior.contactIds || [])],
|
|
42
|
+
ownerIds: [...(prior.ownerIds || [])],
|
|
43
|
+
signatoryAppointmentIds: [...(prior.signatoryAppointmentIds || [])],
|
|
44
|
+
scope: String(options.scope || prior.scope || "").trim(),
|
|
45
|
+
...(String(options.scopeRevision || "").trim()
|
|
46
|
+
? { scopeRevision: String(options.scopeRevision).trim() }
|
|
47
|
+
: {})
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
operation: prior.auditKind === "soc-2-type-1" ? "type-1-to-type-2" : "next-audit-cycle",
|
|
51
|
+
priorAuditId: prior.id,
|
|
52
|
+
audit,
|
|
53
|
+
carriedForward: [
|
|
54
|
+
"frameworkIds",
|
|
55
|
+
"systemIds",
|
|
56
|
+
"requirementIds",
|
|
57
|
+
"controlIds",
|
|
58
|
+
"complementaryControlIds",
|
|
59
|
+
"subserviceVendorIds",
|
|
60
|
+
"subserviceMethod",
|
|
61
|
+
"auditorVendorId",
|
|
62
|
+
"contactIds",
|
|
63
|
+
"ownerIds",
|
|
64
|
+
"signatoryAppointmentIds",
|
|
65
|
+
"scope"
|
|
66
|
+
],
|
|
67
|
+
reviewRequired: [
|
|
68
|
+
"coverage",
|
|
69
|
+
"scopeRevision",
|
|
70
|
+
"criteria and control changes since the prior audit",
|
|
71
|
+
"source and policy continuity",
|
|
72
|
+
"subservice assurance coverage",
|
|
73
|
+
"new or changed commitments"
|
|
74
|
+
]
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function createNextAuditCycle(input = process.cwd(), options = {}) {
|
|
79
|
+
if (options.confirmed !== true) {
|
|
80
|
+
throw new Error("Preview the next audit cycle and confirm the write.");
|
|
81
|
+
}
|
|
82
|
+
const plan = await planNextAuditCycle(input, options);
|
|
83
|
+
const result = await createResource(input, plan.audit);
|
|
84
|
+
return { ...plan, result };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function required(value, label) {
|
|
88
|
+
const normalized = String(value || "").trim();
|
|
89
|
+
if (!normalized) throw new Error(`${label} is required.`);
|
|
90
|
+
return normalized;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function nextTitle(prior, startsOn, endsOn) {
|
|
94
|
+
const year = endsOn.slice(0, 4) || startsOn.slice(0, 4);
|
|
95
|
+
return `${year} SOC 2 Type 2 audit after ${prior.title}`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { applyResourceBatch } from "./files.js";
|
|
2
|
+
import { getGitSummary } from "./git.js";
|
|
3
|
+
import { loadWorkspace } from "./workspace.js";
|
|
4
|
+
|
|
5
|
+
const REVIEWABLE_TYPES = new Set([
|
|
6
|
+
"requirement",
|
|
7
|
+
"control",
|
|
8
|
+
"commitment",
|
|
9
|
+
"complementary-control"
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export async function scaffoldApplicabilityReview(input = process.cwd(), options = {}) {
|
|
13
|
+
const loaded = await loadWorkspace(input);
|
|
14
|
+
if (String(loaded.model.modelVersion) !== "3") {
|
|
15
|
+
throw new Error("Batch applicability review requires a model v3 workspace.");
|
|
16
|
+
}
|
|
17
|
+
const requestedType = options.type ? String(options.type) : null;
|
|
18
|
+
if (requestedType && !REVIEWABLE_TYPES.has(requestedType)) {
|
|
19
|
+
throw new Error(`Applicability review type must be one of ${[...REVIEWABLE_TYPES].join(", ")}.`);
|
|
20
|
+
}
|
|
21
|
+
const records = loaded.resources.filter((record) => (
|
|
22
|
+
REVIEWABLE_TYPES.has(record.type)
|
|
23
|
+
&& (!requestedType || record.type === requestedType)
|
|
24
|
+
&& !record.applicabilityReview
|
|
25
|
+
&& !["retired", "superseded"].includes(record.status)
|
|
26
|
+
));
|
|
27
|
+
return {
|
|
28
|
+
reviewedByIds: [],
|
|
29
|
+
reviewedOn: null,
|
|
30
|
+
decisions: records
|
|
31
|
+
.sort((left, right) => `${left.type}:${left.title}:${left.id}`.localeCompare(`${right.type}:${right.title}:${right.id}`))
|
|
32
|
+
.map((record) => ({
|
|
33
|
+
id: record.id,
|
|
34
|
+
decision: null,
|
|
35
|
+
rationale: null
|
|
36
|
+
}))
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function planApplicabilityReview(input = process.cwd(), options = {}) {
|
|
41
|
+
const loaded = await loadWorkspace(input);
|
|
42
|
+
if (String(loaded.model.modelVersion) !== "3") {
|
|
43
|
+
throw new Error("Batch applicability review requires a model v3 workspace.");
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(options.decisions) || !options.decisions.length) {
|
|
46
|
+
throw new Error("Applicability review needs at least one decision.");
|
|
47
|
+
}
|
|
48
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
49
|
+
const update = options.decisions.map((decision) => {
|
|
50
|
+
const record = byId.get(decision.id);
|
|
51
|
+
if (!record || !REVIEWABLE_TYPES.has(record.type)) {
|
|
52
|
+
throw new Error(`Resource "${decision.id}" is not an applicability-review record.`);
|
|
53
|
+
}
|
|
54
|
+
const reviewedByIds = [...new Set((decision.reviewedByIds || options.reviewedByIds || []).map(String))];
|
|
55
|
+
const reviewedOn = String(decision.reviewedOn || options.reviewedOn || "").trim();
|
|
56
|
+
const scopeRevision = String(
|
|
57
|
+
decision.scopeRevision
|
|
58
|
+
|| options.scopeRevision
|
|
59
|
+
|| getGitSummary(loaded.root).commit
|
|
60
|
+
|| "uncommitted"
|
|
61
|
+
).trim();
|
|
62
|
+
const rationale = String(decision.rationale || "").trim();
|
|
63
|
+
const result = String(decision.decision || "").trim();
|
|
64
|
+
if (!["applicable", "not-applicable", "externally-managed", "zero-population"].includes(result)) {
|
|
65
|
+
throw new Error(`Decision for "${record.id}" must be applicable, not-applicable, externally-managed, or zero-population.`);
|
|
66
|
+
}
|
|
67
|
+
if (!reviewedByIds.length || !reviewedOn || !rationale) {
|
|
68
|
+
throw new Error(`Decision for "${record.id}" needs a reviewer, review date, and rationale.`);
|
|
69
|
+
}
|
|
70
|
+
const next = {
|
|
71
|
+
...record,
|
|
72
|
+
applicabilityReview: {
|
|
73
|
+
decision: result,
|
|
74
|
+
rationale,
|
|
75
|
+
reviewedByIds,
|
|
76
|
+
reviewedOn,
|
|
77
|
+
scopeRevision
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
if (record.type === "requirement") {
|
|
81
|
+
if (!["applicable", "not-applicable"].includes(result)) {
|
|
82
|
+
throw new Error(`Requirement "${record.id}" must be applicable or not-applicable.`);
|
|
83
|
+
}
|
|
84
|
+
next.applicability = result;
|
|
85
|
+
next.applicabilityRationale = rationale;
|
|
86
|
+
}
|
|
87
|
+
if (record.type === "control" && result === "not-applicable") next.status = "not-applicable";
|
|
88
|
+
if (record.type === "control" && result === "applicable" && record.status === "not-applicable") next.status = "planned";
|
|
89
|
+
return next;
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
operation: "applicability-review",
|
|
93
|
+
reviewedIds: update.map(({ id }) => id),
|
|
94
|
+
changes: {
|
|
95
|
+
update,
|
|
96
|
+
expectedRevisions: options.expectedRevisions || {},
|
|
97
|
+
validateWholeWorkspace: true
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function applyApplicabilityReview(input = process.cwd(), options = {}) {
|
|
103
|
+
if (options.confirmed !== true) {
|
|
104
|
+
throw new Error("Preview the applicability decisions and confirm the write.");
|
|
105
|
+
}
|
|
106
|
+
const plan = await planApplicabilityReview(input, options);
|
|
107
|
+
const result = await applyResourceBatch(input, plan.changes);
|
|
108
|
+
return { ...plan, result };
|
|
109
|
+
}
|