filegrc 0.12.4 → 0.13.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/package.json +1 -1
- package/src/files.js +13 -5
- package/src/git.js +122 -25
- package/src/program-amendment.js +3 -2
- package/src/program-path.js +4 -4
- package/src/program-readiness.js +26 -7
- package/src/reconciliation.js +10 -5
- package/src/reporting-route-integrity.js +181 -0
- package/src/reporting-route-sets.js +157 -25
- package/src/server.js +154 -68
- package/src/state.js +30 -5
- package/src/validate.js +136 -14
- package/src/web.js +218 -22
package/src/state.js
CHANGED
|
@@ -368,15 +368,39 @@ async function createResourceDetailFromLoaded(loaded, type, id, options) {
|
|
|
368
368
|
const entry = loaded.entries.find(({ record }) => record.type === type && record.id === id);
|
|
369
369
|
if (!entry) return null;
|
|
370
370
|
const relativePath = `data/${entry.relativePath}`;
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
371
|
+
const includeHistory = options.includeHistory !== false;
|
|
372
|
+
const histories = includeHistory
|
|
373
|
+
? getWorkspaceHistories(loaded.root, [relativePath], 12, {
|
|
374
|
+
deadlineAt: options.historyDeadlineAt
|
|
375
|
+
})
|
|
376
|
+
: new Map();
|
|
374
377
|
return createStateEntry(loaded, entry, {
|
|
375
378
|
includeDetails: true,
|
|
376
|
-
|
|
379
|
+
includeHistory,
|
|
380
|
+
history: includeHistory ? histories.get(relativePath) ?? [] : undefined
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function createResourceHistory(input, type, id, options = {}) {
|
|
385
|
+
if (input?.entries && input?.root) return createResourceHistoryFromLoaded(input, type, id, options);
|
|
386
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
387
|
+
const validation = await validateWorkspace(root);
|
|
388
|
+
return createResourceHistoryFromLoaded(validation.loaded, type, id, options);
|
|
377
389
|
});
|
|
378
390
|
}
|
|
379
391
|
|
|
392
|
+
function createResourceHistoryFromLoaded(loaded, type, id, options) {
|
|
393
|
+
const entry = loaded.entries.find(({ record }) => record.type === type && record.id === id);
|
|
394
|
+
if (!entry) return null;
|
|
395
|
+
const relativePath = `data/${entry.relativePath}`;
|
|
396
|
+
return {
|
|
397
|
+
history: getWorkspaceHistories(loaded.root, [relativePath], 12, {
|
|
398
|
+
deadlineAt: options.historyDeadlineAt
|
|
399
|
+
}).get(relativePath) ?? [],
|
|
400
|
+
historyLoaded: true
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
380
404
|
async function createStateEntry(loaded, entry, options) {
|
|
381
405
|
const record = structuredClone(entry.record);
|
|
382
406
|
const content = {};
|
|
@@ -401,7 +425,8 @@ async function createStateEntry(loaded, entry, options) {
|
|
|
401
425
|
relativePath: `data/${entry.relativePath}`,
|
|
402
426
|
revision: contentRevision(entry.source),
|
|
403
427
|
content,
|
|
404
|
-
history: options.includeDetails ? options.history : undefined,
|
|
428
|
+
history: options.includeDetails && options.includeHistory !== false ? options.history : undefined,
|
|
429
|
+
historyLoaded: options.includeDetails ? options.includeHistory !== false : false,
|
|
405
430
|
detailsLoaded: options.includeDetails
|
|
406
431
|
};
|
|
407
432
|
}
|
package/src/validate.js
CHANGED
|
@@ -30,10 +30,17 @@ import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "./
|
|
|
30
30
|
import {
|
|
31
31
|
reportingRouteRevision,
|
|
32
32
|
reportingRouteBindingExpectationForValidation,
|
|
33
|
+
reportingRouteCommitTimestamp,
|
|
33
34
|
reportingRouteEventCommit,
|
|
34
35
|
reportingRouteEventAuthorityIssueAtCommit,
|
|
36
|
+
reportingRouteExactHistoryEntry,
|
|
35
37
|
reportingRouteFixedEvidence,
|
|
36
|
-
|
|
38
|
+
reportingRouteProposalIssues,
|
|
39
|
+
reportingRouteProposalAssessmentTime,
|
|
40
|
+
reportingRouteRecordAtRevision,
|
|
41
|
+
reportingRouteRequirementsForProposal,
|
|
42
|
+
reportingRouteSupportIssues,
|
|
43
|
+
recordsAtRevision
|
|
37
44
|
} from "./reporting-route-integrity.js";
|
|
38
45
|
import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
|
|
39
46
|
|
|
@@ -61,6 +68,7 @@ const COMPLETION_DATE_FIELDS = [
|
|
|
61
68
|
const COMPLETION_TIMESTAMP_FIELDS = [
|
|
62
69
|
"completedAt", "endedAt", "closedAt", "provisionedOn", "deprovisionedOn"
|
|
63
70
|
];
|
|
71
|
+
const DEFERRED_VALIDATION_CONCURRENCY = 16;
|
|
64
72
|
|
|
65
73
|
export async function validateWorkspace(input = process.cwd()) {
|
|
66
74
|
const timingStarted = performance.now();
|
|
@@ -83,6 +91,8 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
83
91
|
]));
|
|
84
92
|
const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC");
|
|
85
93
|
const obligationsByControl = new Map();
|
|
94
|
+
const deferredDiagnosticTasks = [];
|
|
95
|
+
const serialContentDiagnosticTasks = [];
|
|
86
96
|
const reviewRecords = loaded.resources.filter((record) => (
|
|
87
97
|
record.status === "active" && ["retention-schedule-item", "requirement-mapping"].includes(record.type)
|
|
88
98
|
));
|
|
@@ -156,7 +166,11 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
156
166
|
validateClassification(record, loaded, displayPath, diagnostics);
|
|
157
167
|
validateCompletionDates(record, displayPath, diagnostics);
|
|
158
168
|
validateReportingRouteBinding(record, loaded, displayPath, diagnostics);
|
|
159
|
-
|
|
169
|
+
serialContentDiagnosticTasks.push(async () => {
|
|
170
|
+
const deferredDiagnostics = [];
|
|
171
|
+
await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, deferredDiagnostics);
|
|
172
|
+
return deferredDiagnostics;
|
|
173
|
+
});
|
|
160
174
|
|
|
161
175
|
const fields = { ...loaded.model.commonFields, ...definition.fields };
|
|
162
176
|
for (const [fieldName, field] of Object.entries(fields)) {
|
|
@@ -166,16 +180,19 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
166
180
|
const values = Array.isArray(value) ? value : [value];
|
|
167
181
|
for (const item of values) {
|
|
168
182
|
if (typeof item !== "string") continue;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
183
|
+
deferredDiagnosticTasks.push(async () => {
|
|
184
|
+
try {
|
|
185
|
+
const path = resolveDataPath(loaded.root, item);
|
|
186
|
+
if (!(await stat(path)).isFile()) throw new Error("The data path is not a file.");
|
|
187
|
+
return [];
|
|
188
|
+
} catch {
|
|
189
|
+
return [error(
|
|
190
|
+
"missing-content",
|
|
191
|
+
displayPath,
|
|
192
|
+
`${fieldName} points to unavailable data path "${item}".`
|
|
193
|
+
)];
|
|
194
|
+
}
|
|
195
|
+
});
|
|
179
196
|
}
|
|
180
197
|
}
|
|
181
198
|
if (field.relation) {
|
|
@@ -202,8 +219,19 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
202
219
|
validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
|
|
203
220
|
validateActionObligationRule(record, byId, displayPath, diagnostics);
|
|
204
221
|
validateImplementedControlSchedules(record, obligationsByControl, displayPath, diagnostics);
|
|
205
|
-
|
|
206
|
-
|
|
222
|
+
serialContentDiagnosticTasks.push(async () => {
|
|
223
|
+
const markdownDiagnostics = [];
|
|
224
|
+
const approvalDiagnostics = [];
|
|
225
|
+
await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, markdownDiagnostics);
|
|
226
|
+
await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, approvalDiagnostics);
|
|
227
|
+
return [...markdownDiagnostics, ...approvalDiagnostics];
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
for (const deferredDiagnostics of await runDeferredValidationTasks(deferredDiagnosticTasks)) {
|
|
231
|
+
diagnostics.push(...deferredDiagnostics);
|
|
232
|
+
}
|
|
233
|
+
for (const task of serialContentDiagnosticTasks) {
|
|
234
|
+
diagnostics.push(...await task());
|
|
207
235
|
}
|
|
208
236
|
validateRelationshipConstraints(
|
|
209
237
|
loaded.resources,
|
|
@@ -278,6 +306,23 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
278
306
|
return result;
|
|
279
307
|
}
|
|
280
308
|
|
|
309
|
+
async function runDeferredValidationTasks(tasks) {
|
|
310
|
+
const results = new Array(tasks.length);
|
|
311
|
+
let nextIndex = 0;
|
|
312
|
+
const workers = Array.from(
|
|
313
|
+
{ length: Math.min(DEFERRED_VALIDATION_CONCURRENCY, tasks.length) },
|
|
314
|
+
async () => {
|
|
315
|
+
while (nextIndex < tasks.length) {
|
|
316
|
+
const index = nextIndex;
|
|
317
|
+
nextIndex += 1;
|
|
318
|
+
results[index] = await tasks[index]();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
await Promise.all(workers);
|
|
323
|
+
return results;
|
|
324
|
+
}
|
|
325
|
+
|
|
281
326
|
function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagnostics) {
|
|
282
327
|
const managementFields = [
|
|
283
328
|
"engagementTermsDocumentId",
|
|
@@ -902,6 +947,29 @@ function validateReportingRouteSets(loaded, byId, pathById, diagnostics) {
|
|
|
902
947
|
}
|
|
903
948
|
for (const route of routeSets) {
|
|
904
949
|
const path = pathById.get(route.id);
|
|
950
|
+
if (route.status === "proposed") {
|
|
951
|
+
const proposalHistory = repository.commit
|
|
952
|
+
? reportingRouteExactHistoryEntry(loaded, route, repository.commit)
|
|
953
|
+
: null;
|
|
954
|
+
const proposalRecords = proposalHistory ? recordsAtRevision(loaded, proposalHistory.commit) : loaded.resources;
|
|
955
|
+
const proposalRecord = proposalHistory
|
|
956
|
+
? proposalRecords.find(({ id }) => id === route.id) || route
|
|
957
|
+
: route;
|
|
958
|
+
const proposalAssessmentAt = proposalHistory
|
|
959
|
+
? reportingRouteProposalAssessmentTime(proposalHistory.timestamp, new Date())
|
|
960
|
+
: new Date();
|
|
961
|
+
if (proposalHistory && !proposalAssessmentAt) {
|
|
962
|
+
diagnostics.push(error("invalid-reporting-route-proposal-time", path, "The proposal commit time is too far in the future to establish a reliable proposal."));
|
|
963
|
+
}
|
|
964
|
+
for (const issue of reportingRouteProposalIssues(proposalRecords, proposalRecord, {
|
|
965
|
+
at: proposalAssessmentAt || new Date(),
|
|
966
|
+
timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
|
|
967
|
+
root: loaded.root,
|
|
968
|
+
commit: proposalHistory?.commit
|
|
969
|
+
})) {
|
|
970
|
+
diagnostics.push(error(issue.code, path, issue.message));
|
|
971
|
+
}
|
|
972
|
+
}
|
|
905
973
|
if (["draft", "proposed", "approved"].includes(route.status)) {
|
|
906
974
|
const key = `${route.programId}\0${route.purposeKey}`;
|
|
907
975
|
const current = currentByPurpose.get(key) || { approved: [], pending: [] };
|
|
@@ -937,6 +1005,60 @@ function validateReportingRouteSets(loaded, byId, pathById, diagnostics) {
|
|
|
937
1005
|
const proposal = entry ? reportingRouteRecordAtRevision(loaded, entry, route.proposalCommit) : null;
|
|
938
1006
|
if (!proposal || proposal.status !== "proposed" || !sameRouteProposal(proposal, route)) {
|
|
939
1007
|
diagnostics.push(error("changed-reporting-route-proposal", path, "The approved Route Set facts must exactly match the committed proposal; only managed approval fields may differ."));
|
|
1008
|
+
} else {
|
|
1009
|
+
const proposalRecords = recordsAtRevision(loaded, route.proposalCommit);
|
|
1010
|
+
const proposalTimestamp = reportingRouteCommitTimestamp(loaded, route.id, route.proposalCommit);
|
|
1011
|
+
const proposalAssessmentAt = proposalTimestamp
|
|
1012
|
+
? reportingRouteProposalAssessmentTime(proposalTimestamp, new Date())
|
|
1013
|
+
: null;
|
|
1014
|
+
if (!proposalTimestamp) {
|
|
1015
|
+
diagnostics.push(error("invalid-reporting-route-proposal", path, "The proposal commit must be an exact Reporting Channel Set history entry."));
|
|
1016
|
+
} else if (!proposalAssessmentAt) {
|
|
1017
|
+
diagnostics.push(error("invalid-reporting-route-proposal-time", path, "The proposal commit time is too far in the future to establish a reliable proposal."));
|
|
1018
|
+
} else {
|
|
1019
|
+
const proposalIssues = reportingRouteProposalIssues(
|
|
1020
|
+
proposalRecords,
|
|
1021
|
+
proposal,
|
|
1022
|
+
{
|
|
1023
|
+
at: proposalAssessmentAt,
|
|
1024
|
+
timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
|
|
1025
|
+
root: loaded.root,
|
|
1026
|
+
commit: route.proposalCommit
|
|
1027
|
+
}
|
|
1028
|
+
);
|
|
1029
|
+
for (const issue of proposalIssues) {
|
|
1030
|
+
diagnostics.push(error(issue.code, path, issue.message));
|
|
1031
|
+
}
|
|
1032
|
+
if (!proposalIssues.length) {
|
|
1033
|
+
const approvalCommit = reportingRouteEventCommit(loaded, route, "approval");
|
|
1034
|
+
const approvalRecords = approvalCommit ? recordsAtRevision(loaded, approvalCommit) : loaded.resources;
|
|
1035
|
+
const liveCommit = route.status === "canceled"
|
|
1036
|
+
? reportingRouteEventCommit(loaded, route, "cancellation")
|
|
1037
|
+
: null;
|
|
1038
|
+
const liveRecords = liveCommit ? recordsAtRevision(loaded, liveCommit) : loaded.resources;
|
|
1039
|
+
const cutoverAt = route.approval?.effectiveAt || route.approval?.approvedAt || new Date();
|
|
1040
|
+
const cutoverTimezone = route.approval?.timezone || loaded.workspace?.timezone || "UTC";
|
|
1041
|
+
for (const issue of reportingRouteSupportIssues(
|
|
1042
|
+
reportingRouteRequirementsForProposal(approvalRecords, route, {
|
|
1043
|
+
at: cutoverAt,
|
|
1044
|
+
timezone: cutoverTimezone
|
|
1045
|
+
}),
|
|
1046
|
+
route,
|
|
1047
|
+
proposalRecords,
|
|
1048
|
+
liveRecords,
|
|
1049
|
+
{
|
|
1050
|
+
at: cutoverAt,
|
|
1051
|
+
availableAt: route.approval?.approvedAt || new Date(),
|
|
1052
|
+
timezone: cutoverTimezone,
|
|
1053
|
+
root: loaded.root,
|
|
1054
|
+
proposalCommit: route.proposalCommit,
|
|
1055
|
+
currentCommit: liveCommit
|
|
1056
|
+
}
|
|
1057
|
+
)) {
|
|
1058
|
+
diagnostics.push(error(issue.code, path, issue.message));
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
940
1062
|
}
|
|
941
1063
|
for (const markdown of markdownEntries(loaded.model, route)) {
|
|
942
1064
|
const currentPath = `data/${markdown.path}`;
|