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.
@@ -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 { getGitSummary } from "./git.js";
3
- import { loadWorkspace } from "./workspace.js";
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 loaded = await loadWorkspace(input);
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 loaded = await loadWorkspace(input);
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({ requirementId: record.id, decision: result, rationale, reviewedByIds, reviewedOn, scopeRevision });
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: options.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 async function applyApplicabilityReview(input = process.cwd(), options = {}) {
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
- const plan = await planApplicabilityReview(input, options);
130
- const result = await applyResourceBatch(input, plan.changes);
131
- return { ...plan, result };
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
- applyApplicabilityReview,
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 withWorkflowDelta(root, () => applyApplicabilityReview(root, options));
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,
@@ -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, selectedRequirementIds } from "./program.js";
7
+ import { resolveProgram } from "./program.js";
7
8
 
8
- export function collectionRevision(loaded, resourceType, options = {}) {
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 currentRevision = collectionRevision(loaded, resourceType, { programId: program.id });
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: assessment.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
+ }