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/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 histories = getWorkspaceHistories(loaded.root, [relativePath], 12, {
372
- deadlineAt: options.historyDeadlineAt
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
- history: histories.get(relativePath) ?? []
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
- reportingRouteRecordAtRevision
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
- await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, diagnostics);
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
- try {
170
- const path = resolveDataPath(loaded.root, item);
171
- if (!(await stat(path)).isFile()) throw new Error("The data path is not a file.");
172
- } catch {
173
- diagnostics.push(error(
174
- "missing-content",
175
- displayPath,
176
- `${fieldName} points to unavailable data path "${item}".`
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
- await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
206
- await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
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}`;