filegrc 0.16.11 → 0.16.13
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/applicability-scope.js +46 -10
- package/src/batch-review.js +16 -11
- package/src/collection-revision.js +8 -5
- package/src/collection-scope.js +20 -10
- package/src/historical-workspace.js +103 -5
- package/src/retention.js +69 -9
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { reviewCommitsChangingTypes, reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js";
|
|
3
|
+
|
|
4
|
+
const scopeSourceTypes = new Set([
|
|
5
|
+
"workspace", "program", "commitment", "control", "complementary-control",
|
|
6
|
+
"system", "framework", "component", "vendor", "policy", "requirement"
|
|
7
|
+
]);
|
|
4
8
|
|
|
5
9
|
const excludedResourceFields = new Set([
|
|
6
10
|
"applicabilityReview",
|
|
@@ -23,6 +27,13 @@ const unorderedStringArrayFields = new Set([
|
|
|
23
27
|
"tags"
|
|
24
28
|
]);
|
|
25
29
|
|
|
30
|
+
const applicabilityDecisionFields = {
|
|
31
|
+
requirement: ["frameworkId", "reference", "description", "parentRequirementId"],
|
|
32
|
+
control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
|
|
33
|
+
commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "reportingRouteRequirements"],
|
|
34
|
+
"complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds", "componentIds"]
|
|
35
|
+
};
|
|
36
|
+
|
|
26
37
|
export function applicabilityScopeRevision(record, program, resources, model, options = {}) {
|
|
27
38
|
const scopedResource = ["commitment", "control", "complementary-control"].includes(record?.type)
|
|
28
39
|
&& !options.legacyBroadScope && !options.legacyBroadCommitment;
|
|
@@ -79,8 +90,7 @@ export function applicabilityScopeRevision(record, program, resources, model, op
|
|
|
79
90
|
.sort((left, right) => left.requirementId.localeCompare(right.requirementId))
|
|
80
91
|
})
|
|
81
92
|
},
|
|
82
|
-
resource:
|
|
83
|
-
.filter(([field]) => !excludedResourceFields.has(field)))),
|
|
93
|
+
resource: applicabilityResourceFacts(record, model, options),
|
|
84
94
|
systems: resources
|
|
85
95
|
.filter(({ type, id }) => type === "system" && selectedSystemIds.has(id))
|
|
86
96
|
.sort(compareRecordIds)
|
|
@@ -192,6 +202,17 @@ function compareRecordIds(left, right) {
|
|
|
192
202
|
return left.id.localeCompare(right.id);
|
|
193
203
|
}
|
|
194
204
|
|
|
205
|
+
function applicabilityResourceFacts(record, model, options) {
|
|
206
|
+
const fields = Number(model.modelVersion) >= 11 && !options.legacyResourceProjection
|
|
207
|
+
? applicabilityDecisionFields[record.type] : null;
|
|
208
|
+
if (!fields) return canonicalObject(model, record.type, Object.fromEntries(Object.entries(record)
|
|
209
|
+
.filter(([field]) => !excludedResourceFields.has(field))));
|
|
210
|
+
const value = Object.fromEntries(["id", "type", "title", "externalIds", "extensions", ...fields]
|
|
211
|
+
.filter((field) => record[field] !== undefined).map((field) => [field, record[field]]));
|
|
212
|
+
value.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
|
|
213
|
+
return canonicalObject(model, record.type, value);
|
|
214
|
+
}
|
|
215
|
+
|
|
195
216
|
export function applicabilityReviewIsCurrent(review, record, program, resources, model, root = null) {
|
|
196
217
|
if (
|
|
197
218
|
review?.scopeRevision
|
|
@@ -200,12 +221,17 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
|
|
|
200
221
|
&& Number(model?.modelVersion || 0) < 7
|
|
201
222
|
) return true;
|
|
202
223
|
if (!review?.scopeRevision) return false;
|
|
224
|
+
if (Number(model.modelVersion) >= 11 && record?.type === "control"
|
|
225
|
+
&& (review.decision === "not-applicable") !== (record.status === "not-applicable")) return false;
|
|
226
|
+
if (Number(model.modelVersion) >= 11 && ["retired", "superseded"].includes(record?.status)) return false;
|
|
203
227
|
const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision);
|
|
204
228
|
if (matches(applicabilityScopeRevision(record, program, resources, model))) return true;
|
|
229
|
+
if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type]
|
|
230
|
+
&& matches(applicabilityScopeRevision(record, program, resources, model, { legacyResourceProjection: true }))) return true;
|
|
205
231
|
if (["commitment", "control", "complementary-control"].includes(record?.type) && matches(applicabilityScopeRevision(
|
|
206
232
|
record, program, resources, model, { legacyBroadScope: true }
|
|
207
233
|
))) return true;
|
|
208
|
-
if (
|
|
234
|
+
if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type] && root && historicalScopedReviewMatches(
|
|
209
235
|
review.scopeRevision, record, program, resources, model, root
|
|
210
236
|
)) return true;
|
|
211
237
|
// Older reviews hashed Policy approval metadata alongside scope facts.
|
|
@@ -243,8 +269,13 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
|
|
|
243
269
|
|
|
244
270
|
function historicalScopedReviewMatches(stored, record, program, resources, model, root) {
|
|
245
271
|
const current = applicabilityScopeRevision(record, program, resources, model);
|
|
246
|
-
|
|
247
|
-
|
|
272
|
+
const context = reviewHistoryContext(root, resources);
|
|
273
|
+
const { index } = context;
|
|
274
|
+
const commits = index?.available
|
|
275
|
+
? reviewCommitsChangingTypes(index, scopeSourceTypes)
|
|
276
|
+
: reviewHistoryCommits(context);
|
|
277
|
+
for (const commit of [...commits].reverse()) {
|
|
278
|
+
const snapshot = reviewHistoricalWorkspace(context, commit);
|
|
248
279
|
if (!snapshot) continue;
|
|
249
280
|
const historicalResources = snapshot.resources;
|
|
250
281
|
const historicalRecord = historicalResources.find(({ id }) => id === record.id);
|
|
@@ -254,9 +285,14 @@ function historicalScopedReviewMatches(stored, record, program, resources, model
|
|
|
254
285
|
if (!revisionsMatch("applicability-scope", current, applicabilityScopeRevision(
|
|
255
286
|
historicalRecord, historicalProgram, historicalResources, historicalModel
|
|
256
287
|
))) continue;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
288
|
+
for (const legacyBroadScope of [false, true]) {
|
|
289
|
+
for (const legacyResourceProjection of [false, true]) {
|
|
290
|
+
if (revisionsMatch("applicability-scope", stored, applicabilityScopeRevision(
|
|
291
|
+
historicalRecord, historicalProgram, historicalResources, historicalModel,
|
|
292
|
+
{ legacyBroadScope, legacyResourceProjection }
|
|
293
|
+
))) return true;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
260
296
|
}
|
|
261
297
|
return false;
|
|
262
298
|
}
|
package/src/batch-review.js
CHANGED
|
@@ -142,16 +142,23 @@ function planApplicabilityReviewWithContext(context, options) {
|
|
|
142
142
|
}
|
|
143
143
|
: program;
|
|
144
144
|
const update = reviewedDecisions.flatMap(({ record, result, rationale, reviewedByIds, reviewedOn }) => {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
rationale,
|
|
151
|
-
reviewedByIds,
|
|
152
|
-
reviewedOn,
|
|
153
|
-
scopeRevision
|
|
145
|
+
const next = { ...record };
|
|
146
|
+
if (record.type === "control" && result === "not-applicable" && record.status !== "not-applicable") {
|
|
147
|
+
next.status = "not-applicable";
|
|
148
|
+
if (loaded.model.resources.control.fields.statusTransition) {
|
|
149
|
+
next.statusTransition = { changedByIds: reviewedByIds, changedOn: reviewedOn, reason: rationale };
|
|
154
150
|
}
|
|
151
|
+
}
|
|
152
|
+
if (record.type === "control" && result !== "not-applicable" && record.status === "not-applicable") {
|
|
153
|
+
next.status = "planned";
|
|
154
|
+
delete next.statusTransition;
|
|
155
|
+
}
|
|
156
|
+
next.applicabilityReview = {
|
|
157
|
+
decision: result,
|
|
158
|
+
rationale,
|
|
159
|
+
reviewedByIds,
|
|
160
|
+
reviewedOn,
|
|
161
|
+
scopeRevision: applicabilityScopeRevision(next, reviewedProgram, loaded.resources, loaded.model)
|
|
155
162
|
};
|
|
156
163
|
if (record.type === "requirement") {
|
|
157
164
|
if (modelSupports(loaded.model, "program-scope")) {
|
|
@@ -160,8 +167,6 @@ function planApplicabilityReviewWithContext(context, options) {
|
|
|
160
167
|
next.applicability = result;
|
|
161
168
|
next.applicabilityRationale = rationale;
|
|
162
169
|
}
|
|
163
|
-
if (record.type === "control" && result === "not-applicable") next.status = "not-applicable";
|
|
164
|
-
if (record.type === "control" && result === "applicable" && record.status === "not-applicable") next.status = "planned";
|
|
165
170
|
return [next];
|
|
166
171
|
});
|
|
167
172
|
if (v4RequirementDecisions.length) {
|
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
collectionScopeRevisionFacts
|
|
8
8
|
} from "./collection-scope.js";
|
|
9
9
|
import { resolveDataPath } from "./paths.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { getFileAtRevision } from "./git.js";
|
|
11
|
+
import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js";
|
|
12
12
|
import { resolveProgram } from "./program.js";
|
|
13
13
|
import { markdownEntries } from "./resource-markdown.js";
|
|
14
14
|
import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
|
|
@@ -46,8 +46,9 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
|
|
|
46
46
|
|
|
47
47
|
function historicallyEquivalentCollection(loaded, resourceType, stored, options, current) {
|
|
48
48
|
if (!loaded.root) return false;
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
const context = reviewHistoryContext(loaded.root, loaded);
|
|
50
|
+
for (const commit of [...reviewHistoryCommits(context)].reverse()) {
|
|
51
|
+
const snapshot = reviewHistoricalWorkspace(context, commit);
|
|
51
52
|
if (!snapshot) continue;
|
|
52
53
|
const historicalOptions = {
|
|
53
54
|
...options,
|
|
@@ -103,7 +104,9 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
|
|
|
103
104
|
contentRevisions: (legacy || includeContent ? markdownEntries(loaded.model, record) : []).flatMap(({ path }) => {
|
|
104
105
|
try {
|
|
105
106
|
const content = options.historicalCommit
|
|
106
|
-
?
|
|
107
|
+
? loaded.historicalFiles
|
|
108
|
+
? loaded.historicalFiles.get(`data/${path}`) ?? null
|
|
109
|
+
: getFileAtRevision(loaded.root, options.historicalCommit, `data/${path}`)
|
|
107
110
|
: readFileSync(resolveDataPath(loaded.root, path), "utf8");
|
|
108
111
|
if (content === null) return [];
|
|
109
112
|
return [{ path, revision: createHash("sha256").update(content).digest("hex") }];
|
package/src/collection-scope.js
CHANGED
|
@@ -200,6 +200,7 @@ function scopedProgramPeople(loaded, program = {}) {
|
|
|
200
200
|
|
|
201
201
|
export function collectionRevisionInputs(loaded, resourceType, program, options = {}) {
|
|
202
202
|
const legacy = options.legacy === true;
|
|
203
|
+
const currentModel = modelSupports(loaded.model, "retention-schedule-approval");
|
|
203
204
|
const reviewed = scopedCollectionRecords(loaded, resourceType, program);
|
|
204
205
|
if (!modelSupports(loaded.model, "program-scope")) {
|
|
205
206
|
return reviewed.map((record) => ({ record, value: record, includeContent: true }));
|
|
@@ -367,11 +368,11 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
367
368
|
&& record.type === "document"
|
|
368
369
|
? retentionScheduleDocumentProposalValue(record)
|
|
369
370
|
: options.historicalReviewMetadata
|
|
370
|
-
? dependencyRevisionValue(resourceType, record, legacy)
|
|
371
|
-
: substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy)),
|
|
371
|
+
? dependencyRevisionValue(resourceType, record, legacy, true, currentModel)
|
|
372
|
+
: substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel)),
|
|
372
373
|
includeContent: legacy
|
|
373
374
|
|| reviewedIds.has(record.id)
|
|
374
|
-
|| dependencyContentAffectsRevision(resourceType, record.type)
|
|
375
|
+
|| dependencyContentAffectsRevision(resourceType, record.type, options.historicalReviewMetadata, currentModel)
|
|
375
376
|
}));
|
|
376
377
|
}
|
|
377
378
|
|
|
@@ -379,7 +380,7 @@ function retentionScheduleRowProposalValue(record, historicalReviewMetadata = fa
|
|
|
379
380
|
return Object.fromEntries(Object.entries(record).filter(([field]) => ![
|
|
380
381
|
"approvedByIds",
|
|
381
382
|
"approvedOn",
|
|
382
|
-
...(historicalReviewMetadata ? [] : ["reviewedSourceRevisions"])
|
|
383
|
+
...(historicalReviewMetadata ? [] : ["reviewedSourceRevisions", "tags", "statusTransition"])
|
|
383
384
|
].includes(field)));
|
|
384
385
|
}
|
|
385
386
|
|
|
@@ -591,22 +592,31 @@ const dependencyContentTypes = {
|
|
|
591
592
|
component: new Set(["system"]),
|
|
592
593
|
control: new Set(["system", "component", "obligation", "source-coverage", "evidence", "retention-schedule-item", "document", "policy", "training"]),
|
|
593
594
|
"retention-schedule-item": new Set(["document", "policy"]),
|
|
594
|
-
"complementary-control": new Set(["system", "
|
|
595
|
+
"complementary-control": new Set(["system", "document", "component"])
|
|
595
596
|
};
|
|
596
597
|
|
|
597
|
-
function dependencyRevisionValue(resourceType, record, legacy) {
|
|
598
|
-
const
|
|
598
|
+
function dependencyRevisionValue(resourceType, record, legacy, historicalReviewMetadata = false, currentModel = false) {
|
|
599
|
+
const controlDecision = currentModel && !historicalReviewMetadata && record.type === "control"
|
|
600
|
+
&& ["retention-schedule-item", "complementary-control"].includes(resourceType);
|
|
601
|
+
const fields = controlDecision
|
|
602
|
+
? resourceType === "retention-schedule-item"
|
|
603
|
+
? ["id", "type", "statement", "activity", "controlType", "operationMode", "operationPattern", "requirementIds", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"]
|
|
604
|
+
: ["id", "type", "statement", "activity", "controlType", "operationMode", "operationPattern", "requirementIds", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"]
|
|
605
|
+
: legacy
|
|
599
606
|
? legacyDependencyFieldOverrides[resourceType]?.[record.type]
|
|
600
607
|
|| dependencyFields[resourceType]?.[record.type]
|
|
601
608
|
: dependencyFields[resourceType]?.[record.type];
|
|
602
609
|
if (!fields) return { id: record.id, type: record.type };
|
|
603
|
-
|
|
610
|
+
const value = Object.fromEntries(fields
|
|
604
611
|
.filter((field) => record[field] !== undefined)
|
|
605
612
|
.map((field) => [field, record[field]]));
|
|
613
|
+
if (controlDecision) value.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
|
|
614
|
+
return value;
|
|
606
615
|
}
|
|
607
616
|
|
|
608
|
-
function dependencyContentAffectsRevision(resourceType, dependencyType) {
|
|
609
|
-
return
|
|
617
|
+
function dependencyContentAffectsRevision(resourceType, dependencyType, historicalReviewMetadata = false, currentModel = false) {
|
|
618
|
+
return ((!currentModel || historicalReviewMetadata) && resourceType === "complementary-control" && dependencyType === "control")
|
|
619
|
+
|| dependencyContentTypes[resourceType]?.has(dependencyType) || false;
|
|
610
620
|
}
|
|
611
621
|
|
|
612
622
|
function sorted(values) {
|
|
@@ -1,10 +1,55 @@
|
|
|
1
1
|
import { loadModel } from "../model/index.js";
|
|
2
|
-
import { getDataFilesAtRevision, getFileAtRevision } from "./git.js";
|
|
2
|
+
import { getDataCommitHistory, getDataFilesAtRevision, getDataRecordHistoryIndex, getFileAtRevision } from "./git.js";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const reviewContexts = new WeakMap();
|
|
6
|
+
|
|
7
|
+
export function reviewHistoryContext(root, owner) {
|
|
8
|
+
const key = owner.resources || owner;
|
|
9
|
+
let context = reviewContexts.get(key);
|
|
10
|
+
if (context) return context;
|
|
11
|
+
let index = null;
|
|
12
|
+
try {
|
|
13
|
+
// Leave time for the original Git recovery path if this larger index
|
|
14
|
+
// cannot be built within a browser request.
|
|
15
|
+
index = getDataRecordHistoryIndex(root, { deadline: performance.now() + 3_000 });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error.code !== "FILEGRC_HISTORY_DEADLINE") throw error;
|
|
18
|
+
}
|
|
19
|
+
context = { root, index, statesByCommit: new Map(), workspacesByCommit: new Map() };
|
|
20
|
+
reviewContexts.set(key, context);
|
|
21
|
+
return context;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function reviewHistoryCommits(context) {
|
|
25
|
+
if (context.index?.available) return context.index.commits;
|
|
26
|
+
context.fallbackCommits ||= getDataCommitHistory(context.root);
|
|
27
|
+
return context.fallbackCommits;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function reviewCommitsChangingTypes(index, types) {
|
|
31
|
+
const paths = new Set();
|
|
32
|
+
for (const records of index.recordsByCommit.values()) {
|
|
33
|
+
for (const { record, path } of records.values()) {
|
|
34
|
+
if (types.has(record.type)) paths.add(path);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return index.commits.filter((commit) => [...(index.fileChangesByCommit.get(commit)?.keys() || [])]
|
|
38
|
+
.some((path) => paths.has(path)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function reviewHistoricalWorkspace(context, commit) {
|
|
42
|
+
if (context.index?.available) return indexedHistoricalWorkspace(context, commit);
|
|
43
|
+
if (context.workspacesByCommit.has(commit)) return context.workspacesByCommit.get(commit);
|
|
44
|
+
const loaded = historicalWorkspace(context.root, commit);
|
|
45
|
+
context.workspacesByCommit.set(commit, loaded);
|
|
46
|
+
if (context.workspacesByCommit.size > 128) context.workspacesByCommit.delete(context.workspacesByCommit.keys().next().value);
|
|
47
|
+
return loaded;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Preserve recovery beyond the reconciliation index's size and build limits.
|
|
51
|
+
// It is slower, but still requires the same substantive comparison.
|
|
52
|
+
function historicalWorkspace(root, commit) {
|
|
8
53
|
const entries = [];
|
|
9
54
|
for (const path of getDataFilesAtRevision(root, commit).filter(authoritativeJsonPath)) {
|
|
10
55
|
const source = getFileAtRevision(root, commit, path);
|
|
@@ -25,6 +70,59 @@ export function historicalWorkspace(root, commit) {
|
|
|
25
70
|
}
|
|
26
71
|
}
|
|
27
72
|
|
|
73
|
+
// The history index already contains every committed JSON and Markdown source.
|
|
74
|
+
// Materialize its first-parent states once instead of asking Git to list and
|
|
75
|
+
// read the entire workspace for every legacy review binding.
|
|
76
|
+
export function indexedHistoricalWorkspace(context, commit) {
|
|
77
|
+
const { root, index } = context;
|
|
78
|
+
if (!index?.available || !index.parentsByCommit.has(commit)) return null;
|
|
79
|
+
// Reuse a full request-sized history for ordinary workspaces while bounding
|
|
80
|
+
// retained maps when a repository contains many thousands of data files.
|
|
81
|
+
const cacheLimit = Math.max(1, Math.min(512, Math.floor(200_000 / Math.max(1, index.historicalRecordPaths.size))));
|
|
82
|
+
if (context.workspacesByCommit.has(commit)) return context.workspacesByCommit.get(commit);
|
|
83
|
+
let state = context.statesByCommit.get(commit);
|
|
84
|
+
if (!state) {
|
|
85
|
+
const lineage = [];
|
|
86
|
+
let cursor = commit;
|
|
87
|
+
while (cursor && !context.statesByCommit.has(cursor)) {
|
|
88
|
+
lineage.push(cursor);
|
|
89
|
+
cursor = index.parentsByCommit.get(cursor)?.[0] || null;
|
|
90
|
+
}
|
|
91
|
+
state = cursor ? context.statesByCommit.get(cursor) : new Map();
|
|
92
|
+
for (let position = lineage.length - 1; position >= 0; position -= 1) {
|
|
93
|
+
const changedAt = lineage[position];
|
|
94
|
+
const next = new Map(state);
|
|
95
|
+
for (const [path, source] of index.fileChangesByCommit.get(changedAt) || []) {
|
|
96
|
+
if (source === null) next.delete(path);
|
|
97
|
+
else next.set(path, source);
|
|
98
|
+
}
|
|
99
|
+
context.statesByCommit.set(changedAt, next);
|
|
100
|
+
if (context.statesByCommit.size > cacheLimit) context.statesByCommit.delete(context.statesByCommit.keys().next().value);
|
|
101
|
+
state = next;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const entries = [];
|
|
105
|
+
for (const [path, source] of state) {
|
|
106
|
+
if (!authoritativeJsonPath(path)) continue;
|
|
107
|
+
try {
|
|
108
|
+
entries.push({ record: JSON.parse(source), source, relativePath: path.slice("data/".length) });
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const resources = entries.map(({ record }) => record);
|
|
114
|
+
const workspace = resources.find(({ type }) => type === "workspace");
|
|
115
|
+
if (!workspace) return null;
|
|
116
|
+
try {
|
|
117
|
+
const loaded = { root, entries, resources, workspace, model: loadModel(workspace.dataModelVersion), historicalFiles: state };
|
|
118
|
+
context.workspacesByCommit.set(commit, loaded);
|
|
119
|
+
if (context.workspacesByCommit.size > cacheLimit) context.workspacesByCommit.delete(context.workspacesByCommit.keys().next().value);
|
|
120
|
+
return loaded;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
28
126
|
function authoritativeJsonPath(path) {
|
|
29
127
|
const parts = path.split("/");
|
|
30
128
|
if (parts[0] !== "data" || !path.endsWith(".json")) return false;
|
package/src/retention.js
CHANGED
|
@@ -6,8 +6,8 @@ import { programComponents } from "./program.js";
|
|
|
6
6
|
import { markdownEntries } from "./resource-markdown.js";
|
|
7
7
|
import { modelSupports } from "../model/index.js";
|
|
8
8
|
import { canonicalCalculatedRevisionJson, revisionDigest, revisionsMatch } from "./revisions.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { getFileAtRevision } from "./git.js";
|
|
10
|
+
import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js";
|
|
11
11
|
|
|
12
12
|
export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
13
13
|
if (!loaded.model.resources["retention-schedule-item"]) return [];
|
|
@@ -167,7 +167,7 @@ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "leg
|
|
|
167
167
|
if (!entry || reviewing.has(id)) return null;
|
|
168
168
|
reviewing.add(id);
|
|
169
169
|
const parts = [reviewSource(loaded, entry, scopeHashInput)];
|
|
170
|
-
for (const markdown of
|
|
170
|
+
for (const markdown of reviewMarkdownEntries(loaded, entry, false)) {
|
|
171
171
|
try {
|
|
172
172
|
parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
|
|
173
173
|
} catch (error) {
|
|
@@ -200,10 +200,12 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac
|
|
|
200
200
|
if (!entry || reviewing.has(id)) return null;
|
|
201
201
|
reviewing.add(id);
|
|
202
202
|
const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource)];
|
|
203
|
-
for (const markdown of
|
|
203
|
+
for (const markdown of reviewMarkdownEntries(loaded, entry, legacySource)) {
|
|
204
204
|
try {
|
|
205
205
|
const source = historicalCommit
|
|
206
|
-
?
|
|
206
|
+
? loaded.historicalFiles
|
|
207
|
+
? loaded.historicalFiles.get(`data/${markdown.path}`) ?? null
|
|
208
|
+
: getFileAtRevision(loaded.root, historicalCommit, `data/${markdown.path}`)
|
|
207
209
|
: readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8");
|
|
208
210
|
if (source !== null) parts.push(source);
|
|
209
211
|
} catch (error) {
|
|
@@ -241,8 +243,24 @@ export function resourceReviewRevisionMatches(loaded, revisions, id, stored) {
|
|
|
241
243
|
// records and Markdown, before the old approval is treated as current.
|
|
242
244
|
function historicallyEquivalentReviewSource(loaded, id, stored, current) {
|
|
243
245
|
if (!loaded.root) return false;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
+
const context = reviewHistoryContext(loaded.root, loaded);
|
|
247
|
+
const { index } = context;
|
|
248
|
+
const relevantPaths = index?.available ? new Set(["data/workspace.json"]) : null;
|
|
249
|
+
const graphIds = new Set();
|
|
250
|
+
const visit = (resourceId) => {
|
|
251
|
+
if (graphIds.has(resourceId)) return;
|
|
252
|
+
graphIds.add(resourceId);
|
|
253
|
+
const entry = loaded.entries.find(({ record }) => record.id === resourceId);
|
|
254
|
+
if (!entry) return;
|
|
255
|
+
for (const path of (index.historiesById.get(resourceId) || []).map(({ path }) => path)) relevantPaths.add(path);
|
|
256
|
+
for (const markdown of markdownEntries(loaded.model, entry.record)) relevantPaths.add(`data/${markdown.path}`);
|
|
257
|
+
for (const sourceId of entry.record.sourceResourceIds || []) visit(sourceId);
|
|
258
|
+
};
|
|
259
|
+
if (relevantPaths) visit(id);
|
|
260
|
+
for (const commit of [...reviewHistoryCommits(context)].reverse()) {
|
|
261
|
+
const changed = index?.available ? index.fileChangesByCommit.get(commit) : null;
|
|
262
|
+
if (relevantPaths && ![...changed.keys()].some((path) => relevantPaths.has(path) || path.endsWith(".md"))) continue;
|
|
263
|
+
const snapshot = reviewHistoricalWorkspace(context, commit);
|
|
246
264
|
if (!snapshot?.entries.some(({ record }) => record.id === id)) continue;
|
|
247
265
|
const semantic = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit).get(id);
|
|
248
266
|
if (!revisionsMatch("content", semantic, current)) continue;
|
|
@@ -299,10 +317,52 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = f
|
|
|
299
317
|
}
|
|
300
318
|
return legacySource
|
|
301
319
|
? canonicalCalculatedRevisionJson(entry.source, scopeHashInput)
|
|
302
|
-
: canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource(
|
|
320
|
+
: canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource(
|
|
321
|
+
JSON.parse(entry.source), modelSupports(loaded.model, "retention-schedule-approval")
|
|
322
|
+
)), scopeHashInput);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function reviewMarkdownEntries(loaded, entry, legacySource) {
|
|
326
|
+
// Control Markdown records the implementation procedure. Retention and
|
|
327
|
+
// mapping decisions depend on the structured Control design instead.
|
|
328
|
+
if (!legacySource && modelSupports(loaded.model, "retention-schedule-approval")
|
|
329
|
+
&& entry.record.type === "control") return [];
|
|
330
|
+
return markdownEntries(loaded.model, entry.record);
|
|
303
331
|
}
|
|
304
332
|
|
|
305
|
-
|
|
333
|
+
const reviewDecisionFields = {
|
|
334
|
+
control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
|
|
335
|
+
commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "effectiveOn", "reportingRouteRequirements"],
|
|
336
|
+
"complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds", "componentIds"],
|
|
337
|
+
"source-coverage": ["sourceFamilyId", "coverageKind", "scopeResourceIds", "excludedPopulation", "collectionCadence", "reconciliationMethod", "validFrom", "validThrough", "componentId", "retentionScheduleItemIds"],
|
|
338
|
+
system: ["purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
|
|
339
|
+
component: ["componentKind", "description", "criticality", "environment", "vendorId", "systemUses", "informationUses", "evidenceSourceKinds", "internetExposed", "classificationId", "continuityObjectives"],
|
|
340
|
+
vendor: ["category", "criticality", "description", "standardAgreement", "agreementDocumentId", "startDate", "endDate", "informationTypeIds", "classificationId"],
|
|
341
|
+
"information-type": ["classificationId", "description"],
|
|
342
|
+
framework: ["version", "publisher", "description", "sourceReference", "effectiveOn"],
|
|
343
|
+
requirement: ["frameworkId", "reference", "description", "parentRequirementId"],
|
|
344
|
+
policy: ["policyNumber", "policyKind", "version", "effectiveOn", "supersedesId", "parentPolicyId", "relatedPolicyIds", "relatedDocumentIds", "requirementIds", "audience", "acknowledgementRequired", "programRole", "reportingRouteRequirements"],
|
|
345
|
+
document: ["documentKind", "workflowScope", "template", "version", "effectiveOn", "supersedesId", "systemIds", "controlIds", "relatedDocumentIds", "audience", "acknowledgementRequired", "trainingIds", "classificationId", "programRole", "componentIds", "reportingRouteRequirements"]
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
function substantiveReviewSource(record, currentModel) {
|
|
349
|
+
if (currentModel && reviewDecisionFields[record.type]) {
|
|
350
|
+
const fields = ["id", "type", "title", "extensions", "externalIds", ...reviewDecisionFields[record.type]];
|
|
351
|
+
const decision = Object.fromEntries(fields.filter((field) => record[field] !== undefined)
|
|
352
|
+
.map((field) => [field, record[field]]));
|
|
353
|
+
if (record.type === "control") {
|
|
354
|
+
decision.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
|
|
355
|
+
} else if (["policy", "document"].includes(record.type)) {
|
|
356
|
+
decision.governingStatus = ["approved", "active"].includes(record.status) ? "approved" : record.status || null;
|
|
357
|
+
} else {
|
|
358
|
+
decision.status = record.status || null;
|
|
359
|
+
}
|
|
360
|
+
if (["control", "commitment", "complementary-control"].includes(record.type)) {
|
|
361
|
+
decision.nonApplicableDecision = ["not-applicable", "externally-managed", "zero-population"]
|
|
362
|
+
.includes(record.applicabilityReview?.decision) ? record.applicabilityReview.decision : null;
|
|
363
|
+
}
|
|
364
|
+
return decision;
|
|
365
|
+
}
|
|
306
366
|
const source = record.type === "program" ? {
|
|
307
367
|
id: record.id,
|
|
308
368
|
type: record.type,
|