filegrc 0.16.0 → 0.16.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -207,7 +207,7 @@ function canonicalFieldValue(model, value, field, name, revisionMap = false) {
207
207
  : value;
208
208
  }
209
209
  return typeof value === "string" && (revisionMap || CALCULATED_REVISION_FIELDS.has(name))
210
- ? canonicalCalculatedRevision(value)
210
+ ? canonicalCalculatedRevision(value, name)
211
211
  : value;
212
212
  }
213
213
 
package/src/cli.js CHANGED
@@ -1846,7 +1846,11 @@ function buildProgramPathResult(model, readiness, auditReadiness) {
1846
1846
  nextActions: (current?.items || []).filter((item) => item.status === "action")
1847
1847
  };
1848
1848
  });
1849
- const currentStep = stages.find((stage) => !["complete", "operating", "management-ready"].includes(stage.status)) || stages.at(-1);
1849
+ const recommendedStage = readiness.stages.find((stage) => stage.items.includes(readiness.firstAction));
1850
+ const recommendedId = recommendedStage?.id === "operation" ? "run" : recommendedStage?.id;
1851
+ const currentStep = stages.find(({ id }) => id === recommendedId)
1852
+ || stages.find((stage) => !["complete", "operating", "management-ready"].includes(stage.status))
1853
+ || stages.at(-1);
1850
1854
  return {
1851
1855
  schemaVersion: 1,
1852
1856
  dataModelVersion: String(model.modelVersion),
@@ -1,5 +1,5 @@
1
1
  import { loadModel } from "../model/index.js";
2
- import { collectionRevision } from "./collection-revision.js";
2
+ import { collectionRevision, collectionRevisionMatches } from "./collection-revision.js";
3
3
  import { scopedCollectionRecords } from "./collection-scope.js";
4
4
  import { getDataFilesAtRevision, getFileAtRevision, getRecordIdentityHistory, hasGitRevision } from "./git.js";
5
5
  import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
@@ -70,7 +70,11 @@ export function historicalCollectionReviewSnapshot(root, record, model, timezone
70
70
  authoritativeSourceId: record.authoritativeComponentId
71
71
  });
72
72
  if (
73
- !revisionsMatch("collection", record.collectionRevision, currentRevision)
73
+ !collectionRevisionMatches(snapshot, resourceType, record.collectionRevision, {
74
+ program,
75
+ authoritativeSourceId: record.authoritativeComponentId,
76
+ currentRevision
77
+ })
74
78
  || JSON.stringify([...(record.populationResourceIds || [])].sort()) !== JSON.stringify(collectionIds)
75
79
  ) return null;
76
80
  const selectedIds = selector
@@ -12,25 +12,37 @@ import { markdownEntries } from "./resource-markdown.js";
12
12
  import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
13
13
 
14
14
  export function collectionRevision(loaded, resourceType, options = {}) {
15
- return calculateCollectionRevision(loaded, resourceType, options, false);
15
+ return calculateCollectionRevision(loaded, resourceType, options, false, options.scopeHashInput || "legacy", options.scopeFactsInput || "legacy");
16
16
  }
17
17
 
18
18
  export function legacyCollectionRevision(loaded, resourceType, options = {}) {
19
- return calculateCollectionRevision(loaded, resourceType, options, true);
19
+ return calculateCollectionRevision(loaded, resourceType, options, true, options.scopeHashInput || "legacy", options.scopeFactsInput || "legacy");
20
20
  }
21
21
 
22
22
  export function collectionRevisionMatches(loaded, resourceType, storedRevision, options = {}) {
23
23
  if (!storedRevision) return false;
24
24
  const currentRevision = options.currentRevision
25
25
  || collectionRevision(loaded, resourceType, options);
26
- // Version 0.9.2 narrowed this hash basis. Keep unchanged 0.9.1 reviews valid
27
- // until management records a new review on the current basis.
28
- return revisionsMatch("collection", storedRevision, currentRevision)
29
- || (resourceType !== "retention-schedule-item" || !modelSupports(loaded.model, "retention-schedule-approval"))
30
- && revisionsMatch("collection", storedRevision, legacyCollectionRevision(loaded, resourceType, options));
26
+ // Accept both the pre-0.16 scope: input and the digest-only input used by
27
+ // 0.16.0, as well as the older collection basis from 0.9.1.
28
+ if (revisionsMatch("collection", storedRevision, currentRevision)) return true;
29
+ const allowOlderBasis = resourceType !== "retention-schedule-item"
30
+ || !modelSupports(loaded.model, "retention-schedule-approval");
31
+ for (const olderBasis of [false, true]) {
32
+ if (olderBasis && !allowOlderBasis) continue;
33
+ for (const scopeHashInput of ["legacy", "digest"]) {
34
+ for (const scopeFactsInput of ["legacy", "source"]) {
35
+ if (!olderBasis && scopeHashInput === "legacy" && scopeFactsInput === "legacy") continue;
36
+ if (revisionsMatch("collection", storedRevision, calculateCollectionRevision(
37
+ loaded, resourceType, options, olderBasis, scopeHashInput, scopeFactsInput
38
+ ))) return true;
39
+ }
40
+ }
41
+ }
42
+ return false;
31
43
  }
32
44
 
33
- function calculateCollectionRevision(loaded, resourceType, options, legacy) {
45
+ function calculateCollectionRevision(loaded, resourceType, options, legacy, scopeHashInput = "legacy", scopeFactsInput = "legacy") {
34
46
  const workspaceWideRetentionReview = resourceType === "retention-schedule-item"
35
47
  && modelSupports(loaded.model, "retention-schedule-approval")
36
48
  && !legacy;
@@ -55,7 +67,7 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy) {
55
67
  .map(({ record, value, includeContent }) => ({
56
68
  id: record.id,
57
69
  revision: createHash("sha256")
58
- .update(JSON.stringify(canonicalRecordValue(loaded.model, record.type, value)))
70
+ .update(JSON.stringify(canonicalRecordValue(loaded.model, record.type, value, scopeHashInput)))
59
71
  .digest("hex"),
60
72
  contentRevisions: (legacy || includeContent ? markdownEntries(loaded.model, record) : []).flatMap(({ path }) => {
61
73
  try {
@@ -68,35 +80,46 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy) {
68
80
  })
69
81
  }))
70
82
  .sort((left, right) => left.id.localeCompare(right.id));
71
- const workspaceScope = collectionScopeRevisionFacts(loaded, resourceType, program);
83
+ const scopeFacts = collectionScopeRevisionFacts(loaded, resourceType, program);
84
+ const workspaceScope = scopeFactsInput === "source" ? scopeFacts : canonicalScopeFacts(scopeFacts);
72
85
  const source = JSON.stringify({ resourceType, records, workspaceScope });
73
86
  return legacy
74
87
  ? createHash("sha256").update(source).digest("hex")
75
88
  : calculateRevision("collection", source);
76
89
  }
77
90
 
78
- function canonicalRecordValue(model, resourceType, value) {
91
+ function canonicalScopeFacts(value, field = null) {
92
+ if (Array.isArray(value)) return value.map((item) => canonicalScopeFacts(item));
93
+ if (value && typeof value === "object") {
94
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, canonicalScopeFacts(item, key)]));
95
+ }
96
+ return field === "scopeRevision" && typeof value === "string"
97
+ ? canonicalCalculatedRevision(value, field)
98
+ : value;
99
+ }
100
+
101
+ function canonicalRecordValue(model, resourceType, value, scopeHashInput) {
79
102
  const fields = {
80
103
  ...model.commonFields,
81
104
  ...model.resources[resourceType]?.fields
82
105
  };
83
- return canonicalObject(model, value, fields);
106
+ return canonicalObject(model, value, fields, false, scopeHashInput);
84
107
  }
85
108
 
86
- function canonicalObject(model, value, fields = {}, revisionMap = false) {
109
+ function canonicalObject(model, value, fields = {}, revisionMap = false, scopeHashInput = "legacy") {
87
110
  return Object.fromEntries(Object.keys(value).sort().map((name) => [
88
111
  name,
89
- canonicalFieldValue(model, value[name], fields[name], name, revisionMap)
112
+ canonicalFieldValue(model, value[name], fields[name], name, revisionMap, scopeHashInput)
90
113
  ]));
91
114
  }
92
115
 
93
- function canonicalFieldValue(model, value, field, name, revisionMap = false) {
116
+ function canonicalFieldValue(model, value, field, name, revisionMap = false, scopeHashInput = "legacy") {
94
117
  if (Array.isArray(value)) {
95
118
  const objectType = field?.itemObjectType;
96
119
  const itemFields = objectType ? model.objectTypes?.[objectType]?.properties : undefined;
97
120
  const items = value.map((item) => (
98
121
  item && typeof item === "object" && !Array.isArray(item)
99
- ? canonicalObject(model, item, itemFields)
122
+ ? canonicalObject(model, item, itemFields, false, scopeHashInput)
100
123
  : item
101
124
  ));
102
125
  return field?.type === "array"
@@ -105,10 +128,10 @@ function canonicalFieldValue(model, value, field, name, revisionMap = false) {
105
128
  }
106
129
  if (value && typeof value === "object") {
107
130
  const objectType = field?.objectType;
108
- return canonicalObject(model, value, objectType ? model.objectTypes?.[objectType]?.properties : undefined, CALCULATED_REVISION_MAP_FIELDS.has(name));
131
+ return canonicalObject(model, value, objectType ? model.objectTypes?.[objectType]?.properties : undefined, CALCULATED_REVISION_MAP_FIELDS.has(name), scopeHashInput);
109
132
  }
110
133
  return typeof value === "string" && (revisionMap || CALCULATED_REVISION_FIELDS.has(name))
111
- ? canonicalCalculatedRevision(value)
134
+ ? canonicalCalculatedRevision(value, name, scopeHashInput)
112
135
  : value;
113
136
  }
114
137
 
@@ -65,6 +65,24 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
65
65
  ));
66
66
  }
67
67
 
68
+ // These records can change which members enter a collection even when they
69
+ // are not part of its current revision inputs yet.
70
+ const COLLECTION_MEMBERSHIP_SOURCES = {
71
+ person: ["workspace", "program", "appointment", "team", "system", "component", "vendor", "control", "policy", "document", "obligation"],
72
+ vendor: ["program", "system", "component", "audit"],
73
+ component: ["program", "system", "control"],
74
+ "complementary-control": ["program", "system", "component", "control"],
75
+ "information-type": ["program", "system", "component", "vendor"],
76
+ "retention-schedule-item": ["program", "system", "component", "vendor", "control", "information-type", "source-coverage"],
77
+ framework: ["program"],
78
+ system: ["program"],
79
+ control: ["program"]
80
+ };
81
+
82
+ export function collectionMembershipSourceTypes(resourceType) {
83
+ return COLLECTION_MEMBERSHIP_SOURCES[resourceType] || [];
84
+ }
85
+
68
86
  export function retentionScheduleReviewScope(loaded) {
69
87
  const programs = loaded.resources.filter((record) => (
70
88
  record.type === "program" && record.status !== "retired"
@@ -1,5 +1,5 @@
1
1
  import { findResourceReferences } from "./agent.js";
2
- import { resourceReviewRevision, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
2
+ import { resourceReviewRevision, resourceReviewRevisionMatches, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
3
3
  import { loadWorkspace } from "./workspace.js";
4
4
 
5
5
  const SOURCE_TYPES = new Set(["policy", "document", "framework", "requirement", "commitment"]);
@@ -114,7 +114,8 @@ export async function planProgramAmendment(input, options = {}) {
114
114
  && reviewBindingsDiffer(
115
115
  [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])],
116
116
  record.reviewedSourceRevisions,
117
- currentReviewRevisions
117
+ currentReviewRevisions,
118
+ loaded
118
119
  )
119
120
  ));
120
121
  if (staleMappings.length) {
@@ -127,7 +128,7 @@ export async function planProgramAmendment(input, options = {}) {
127
128
  const retention = related.filter((record) => (
128
129
  record.type === "retention-schedule-item"
129
130
  && record.status === "active"
130
- && reviewBindingsDiffer(retentionReviewResourceIds(record, loaded), record.reviewedSourceRevisions, currentReviewRevisions)
131
+ && reviewBindingsDiffer(retentionReviewResourceIds(record, loaded), record.reviewedSourceRevisions, currentReviewRevisions, loaded)
131
132
  ));
132
133
  if (retention.length) {
133
134
  missing.push({
@@ -160,10 +161,10 @@ export async function planProgramAmendment(input, options = {}) {
160
161
  };
161
162
  }
162
163
 
163
- function reviewBindingsDiffer(expectedIds, reviewed = {}, current) {
164
+ function reviewBindingsDiffer(expectedIds, reviewed = {}, current, loaded) {
164
165
  const expected = new Set(expectedIds);
165
166
  if (Object.keys(reviewed).length !== expected.size) return true;
166
- return [...expected].some((id) => !current.get(id) || reviewed[id] !== current.get(id));
167
+ return [...expected].some((id) => !resourceReviewRevisionMatches(loaded, current, id, reviewed[id]));
167
168
  }
168
169
 
169
170
  export async function assessProgramAmendmentReadiness(loaded) {
@@ -3,6 +3,7 @@ import { modelSupports } from "../model/index.js";
3
3
  import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
4
4
  import { assessRequiredAppointments } from "./appointments.js";
5
5
  import { assessCollectionReviews } from "./collection-review.js";
6
+ import { collectionMembershipSourceTypes, collectionRevisionInputs, collectionScopeRevisionFacts } from "./collection-scope.js";
6
7
  import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
7
8
  import { coverageEnd, coverageStart } from "./coverage.js";
8
9
  import { planObligations } from "./obligations.js";
@@ -233,6 +234,7 @@ export async function assessProgramReadiness(input, options = {}) {
233
234
  policyStage,
234
235
  controlStage
235
236
  ];
237
+ const prioritizedSetupItems = prioritizeReviewDependencies(evidenceGateStages, loaded, program);
236
238
  for (const current of evidenceGateStages) finalizeStage(current);
237
239
  const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
238
240
  const stages = [
@@ -260,7 +262,8 @@ export async function assessProgramReadiness(input, options = {}) {
260
262
  const items = stages.flatMap((current) => current.items);
261
263
  const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
262
264
  const complete = managedItems.filter((current) => current.status === "complete").length;
263
- const firstAction = items.find((current) => current.status === "action") || null;
265
+ const firstAction = [...prioritizedSetupItems, ...stages.at(-1).items]
266
+ .find((current) => current.status === "action") || null;
264
267
  const target = {
265
268
  programId: program?.id || null,
266
269
  goal: program?.assuranceGoal || "none",
@@ -1966,6 +1969,50 @@ function finalizeStage(current) {
1966
1969
  current.status = current.counts.action ? "action" : current.counts.later ? "later" : "complete";
1967
1970
  }
1968
1971
 
1972
+ // A collection confirmation can be performed early, but an unfinished input
1973
+ // would make it stale again. Derive the ordering from the same revision inputs
1974
+ // used to decide whether that confirmation is current.
1975
+ export function prioritizeReviewDependencies(stages, loaded, program) {
1976
+ const items = stages.flatMap(({ items }) => items);
1977
+ const edges = new Map(items.map((_, index) => [index, new Set()]));
1978
+ const indegree = items.map(() => 0);
1979
+ for (const [reviewIndex, review] of items.entries()) {
1980
+ if (review.status !== "action" || !review.id.startsWith("collection-review-")) continue;
1981
+ const resourceType = review.id.slice("collection-review-".length);
1982
+ if (!loaded.model.collectionReviews?.[resourceType]) continue;
1983
+ const inputTypes = new Set(collectionRevisionInputs(loaded, resourceType, program)
1984
+ .map(({ record }) => record.type));
1985
+ for (const type of collectionMembershipSourceTypes(resourceType)) inputTypes.add(type);
1986
+ const scopeFacts = collectionScopeRevisionFacts(loaded, resourceType, program);
1987
+ if (Object.keys(scopeFacts).some((key) => key !== "programId")) inputTypes.add("program");
1988
+ for (const key of Object.keys(scopeFacts)) {
1989
+ if (!key.endsWith("Ids")) continue;
1990
+ const type = key.slice(0, -3).replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
1991
+ if (loaded.model.resources?.[type]) inputTypes.add(type);
1992
+ }
1993
+ for (const [sourceIndex, source] of items.entries()) {
1994
+ if (sourceIndex === reviewIndex || source.status !== "action"
1995
+ || source.id.startsWith("collection-review-")
1996
+ || !inputTypes.has(source.resourceType)) continue;
1997
+ edges.get(sourceIndex).add(reviewIndex);
1998
+ indegree[reviewIndex]++;
1999
+ }
2000
+ }
2001
+ const remaining = new Set(items.map((_, index) => index));
2002
+ const ordered = [];
2003
+ while (remaining.size) {
2004
+ const next = [...remaining].find((index) => indegree[index] === 0);
2005
+ if (next === undefined) break;
2006
+ remaining.delete(next);
2007
+ ordered.push(items[next]);
2008
+ for (const dependent of edges.get(next)) indegree[dependent]--;
2009
+ }
2010
+ if (ordered.length !== items.length) return items;
2011
+ const rank = new Map(ordered.map((item, index) => [item, index]));
2012
+ for (const stage of stages) stage.items.sort((left, right) => rank.get(left) - rank.get(right));
2013
+ return ordered;
2014
+ }
2015
+
1969
2016
  function item(id, status, title, message, resource = {}, details = {}) {
1970
2017
  return {
1971
2018
  id,
@@ -1,5 +1,4 @@
1
- import { resourceReviewRevisions } from "./retention.js";
2
- import { revisionsMatch } from "./revisions.js";
1
+ import { resourceReviewRevisionMatches, resourceReviewRevisions } from "./retention.js";
3
2
 
4
3
  export async function assessRequirementMappingReadiness(loaded) {
5
4
  if (!loaded.model.resources["requirement-mapping"]) return [];
@@ -18,7 +17,7 @@ export async function assessRequirementMappingReadiness(loaded) {
18
17
  ...(mapping.targetResourceIds || [])
19
18
  ])];
20
19
  const staleIds = mappedIds.filter((id) => (
21
- !revisions.get(id) || !revisionsMatch("content", mapping.reviewedSourceRevisions?.[id], revisions.get(id))
20
+ !resourceReviewRevisionMatches(loaded, revisions, id, mapping.reviewedSourceRevisions?.[id])
22
21
  )).concat(Object.keys(mapping.reviewedSourceRevisions || {}).filter((id) => !mappedIds.includes(id)));
23
22
  const structurallyComplete = mappingStructureIsComplete(mapping);
24
23
  const complete = mapping.status === "active" && structurallyComplete && staleIds.length === 0;
package/src/retention.js CHANGED
@@ -154,7 +154,7 @@ export async function resourceReviewRevision(loaded, resourceId) {
154
154
  return (await resourceReviewRevisions(loaded, [resourceId])).get(resourceId) || null;
155
155
  }
156
156
 
157
- export async function resourceReviewRevisions(loaded, ids) {
157
+ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "legacy") {
158
158
  const wanted = new Set(ids);
159
159
  const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
160
160
  const revisions = new Map();
@@ -164,7 +164,7 @@ export async function resourceReviewRevisions(loaded, ids) {
164
164
  const entry = entries.get(id);
165
165
  if (!entry || reviewing.has(id)) return null;
166
166
  reviewing.add(id);
167
- const parts = [reviewSource(loaded, entry)];
167
+ const parts = [reviewSource(loaded, entry, scopeHashInput)];
168
168
  for (const markdown of markdownEntries(loaded.model, entry.record)) {
169
169
  try {
170
170
  parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
@@ -187,7 +187,7 @@ export async function resourceReviewRevisions(loaded, ids) {
187
187
  return new Map([...revisions].filter(([id]) => wanted.has(id)));
188
188
  }
189
189
 
190
- export function resourceReviewRevisionsSync(loaded, ids) {
190
+ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy") {
191
191
  const wanted = new Set(ids);
192
192
  const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
193
193
  const revisions = new Map();
@@ -197,7 +197,7 @@ export function resourceReviewRevisionsSync(loaded, ids) {
197
197
  const entry = entries.get(id);
198
198
  if (!entry || reviewing.has(id)) return null;
199
199
  reviewing.add(id);
200
- const parts = [reviewSource(loaded, entry)];
200
+ const parts = [reviewSource(loaded, entry, scopeHashInput)];
201
201
  for (const markdown of markdownEntries(loaded.model, entry.record)) {
202
202
  try {
203
203
  parts.push(readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"));
@@ -218,6 +218,16 @@ export function resourceReviewRevisionsSync(loaded, ids) {
218
218
  return new Map([...revisions].filter(([id]) => wanted.has(id)));
219
219
  }
220
220
 
221
+ export function resourceReviewRevisionMatches(loaded, revisions, id, stored) {
222
+ const current = revisions.get(id);
223
+ if (!current || !revisionDigest("content", stored)) return false;
224
+ if (revisionsMatch("content", stored, current)) return true;
225
+ // Version 0.16.0 used a bare digest as the hash input for scopeRevision.
226
+ // Read that binding without changing the legacy scope: basis restored here.
227
+ const compatible = resourceReviewRevisionsSync(loaded, [id], "digest").get(id);
228
+ return revisionsMatch("content", stored, compatible);
229
+ }
230
+
221
231
  export function retentionUses(loaded, program) {
222
232
  const systemIds = new Set(program.systemIds || []);
223
233
  const componentIds = new Set(programComponents(loaded, program).map(({ id }) => id));
@@ -239,7 +249,7 @@ export function retentionUses(loaded, program) {
239
249
  return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
240
250
  }
241
251
 
242
- function reviewSource(loaded, entry) {
252
+ function reviewSource(loaded, entry, scopeHashInput = "legacy") {
243
253
  if (
244
254
  modelSupports(loaded.model, "retention-schedule-approval")
245
255
  && entry.record.type === "document"
@@ -261,7 +271,7 @@ function reviewSource(loaded, entry) {
261
271
  ]) delete approved[field];
262
272
  return JSON.stringify(approved);
263
273
  }
264
- return canonicalCalculatedRevisionJson(entry.source);
274
+ return canonicalCalculatedRevisionJson(entry.source, scopeHashInput);
265
275
  }
266
276
 
267
277
  export function nearDuplicateInformationTypes(records) {
@@ -327,7 +337,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded
327
337
  const dependencyIds = retentionReviewResourceIds(rule, loaded);
328
338
  if (Object.keys(rule.reviewedSourceRevisions).length !== dependencyIds.length) return false;
329
339
  return dependencyIds.every((id) => (
330
- revisions.get(id) && revisionsMatch("content", rule.reviewedSourceRevisions?.[id], revisions.get(id))
340
+ resourceReviewRevisionMatches(loaded, revisions, id, rule.reviewedSourceRevisions?.[id])
331
341
  ));
332
342
  }
333
343
 
package/src/revisions.js CHANGED
@@ -59,8 +59,17 @@ export function calculatedRevisionDiagnostic(kind, stored, current) {
59
59
  };
60
60
  }
61
61
 
62
- export function canonicalCalculatedRevision(value) {
62
+ export function canonicalCalculatedRevision(value, field = null, scopeHashInput = "legacy") {
63
63
  const text = String(value || "");
64
+ // Legacy applicability reviews stored their scope digest with a `scope:`
65
+ // label. Keep that exact hash input when a newer scheme labels the same
66
+ // decision, including when the review is embedded in another source record.
67
+ if (field === "scopeRevision" && scopeHashInput === "legacy") {
68
+ const scopeDigest = revisionDigest("applicability-scope", text);
69
+ if (scopeDigest && (text.startsWith("scope:") || text.startsWith(PREFIXES["applicability-scope"]))) {
70
+ return `scope:${scopeDigest}`;
71
+ }
72
+ }
64
73
  // Keep the original digest as the calculation input. Merely installing a
65
74
  // scheme-aware engine must not stale a decision made over unchanged facts.
66
75
  if (/^[a-f0-9]{64}$/.test(text)) return text;
@@ -71,22 +80,22 @@ export function canonicalCalculatedRevision(value) {
71
80
 
72
81
  // Preserve JSON layout because older resource-review bindings hashed the exact
73
82
  // source bytes. Only scheme labels in model-defined revision slots may change.
74
- export function canonicalCalculatedRevisionJson(source) {
83
+ export function canonicalCalculatedRevisionJson(source, scopeHashInput = "legacy") {
75
84
  const fieldNames = [...CALCULATED_REVISION_FIELDS].join("|");
76
85
  const mapNames = [...CALCULATED_REVISION_MAP_FIELDS].join("|");
77
86
  const stringValue = /("(?:\\.|[^"\\])*")(\s*:\s*)("(?:\\.|[^"\\])*")/g;
78
- const canonicalValue = (quoted) => {
87
+ const canonicalValue = (quoted, field) => {
79
88
  const value = JSON.parse(quoted);
80
- const canonical = canonicalCalculatedRevision(value);
89
+ const canonical = canonicalCalculatedRevision(value, field, scopeHashInput);
81
90
  return canonical === value ? quoted : JSON.stringify(canonical);
82
91
  };
83
92
  return source
84
93
  .replace(new RegExp(`("(?:${fieldNames})")(\\s*:\\s*)("(?:\\\\.|[^"\\\\])*")`, "g"),
85
- (_match, key, separator, value) => `${key}${separator}${canonicalValue(value)}`)
94
+ (_match, key, separator, value) => `${key}${separator}${canonicalValue(value, JSON.parse(key))}`)
86
95
  .replace(new RegExp(`("(?:${mapNames})")(\\s*:\\s*\\{)([^{}]*)(\\})`, "g"),
87
96
  (_match, key, opening, body, closing) => (
88
97
  `${key}${opening}${body.replace(stringValue, (_pair, name, separator, value) => (
89
- `${name}${separator}${canonicalValue(value)}`
98
+ `${name}${separator}${canonicalValue(value, JSON.parse(name))}`
90
99
  ))}${closing}`
91
100
  ));
92
101
  }
package/src/validate.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  parseCalendarDate,
19
19
  validCalendarRecurrence
20
20
  } from "./recurrence.js";
21
- import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
21
+ import { resourceReviewRevisionMatches, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
22
22
  import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
23
23
  import { obligationIsEnabled } from "./program-lifecycle.js";
24
24
  import { currentPartyPeople, partyPeople } from "./parties.js";
@@ -156,7 +156,7 @@ async function validateWorkspaceUnmeasured(input) {
156
156
  validateObligationOccurrence(record, loaded.model, loaded.resources, loaded.entries, loaded.root, loaded.workspace.timezone, byId, asOf, displayPath, diagnostics);
157
157
  }
158
158
  if (record.type === "retention-schedule-item") validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisions, displayPath, diagnostics);
159
- if (record.type === "requirement-mapping") validateRequirementMapping(record, currentReviewRevisions, displayPath, diagnostics);
159
+ if (record.type === "requirement-mapping") validateRequirementMapping(record, loaded, currentReviewRevisions, displayPath, diagnostics);
160
160
  if (record.type === "action-item") {
161
161
  validateCompletedObligationAction(record, byId, loaded.model, displayPath, diagnostics);
162
162
  }
@@ -1998,7 +1998,7 @@ function validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisi
1998
1998
  const sources = retentionReviewResourceIds(record, loaded);
1999
1999
  const revisions = record.reviewedSourceRevisions || {};
2000
2000
  const missing = sources.filter((id) => (
2001
- !currentReviewRevisions.get(id) || !revisionsMatch("content", revisions[id], currentReviewRevisions.get(id))
2001
+ !resourceReviewRevisionMatches(loaded, currentReviewRevisions, id, revisions[id])
2002
2002
  )).concat(Object.keys(revisions).filter((id) => !sources.includes(id)));
2003
2003
  if (missing.length) {
2004
2004
  diagnostics.push(error(
@@ -2009,7 +2009,7 @@ function validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisi
2009
2009
  }
2010
2010
  }
2011
2011
 
2012
- function validateRequirementMapping(record, currentReviewRevisions, path, diagnostics) {
2012
+ function validateRequirementMapping(record, loaded, currentReviewRevisions, path, diagnostics) {
2013
2013
  const overlap = (record.sourceResourceIds || []).filter((id) => (record.targetResourceIds || []).includes(id));
2014
2014
  if (overlap.length) {
2015
2015
  diagnostics.push(error(
@@ -2022,7 +2022,7 @@ function validateRequirementMapping(record, currentReviewRevisions, path, diagno
2022
2022
  const mappedIds = [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])];
2023
2023
  const revisions = record.reviewedSourceRevisions || {};
2024
2024
  const missing = mappedIds.filter((id) => (
2025
- !currentReviewRevisions.get(id) || !revisionsMatch("content", revisions[id], currentReviewRevisions.get(id))
2025
+ !resourceReviewRevisionMatches(loaded, currentReviewRevisions, id, revisions[id])
2026
2026
  )).concat(Object.keys(revisions).filter((id) => !mappedIds.includes(id)));
2027
2027
  if (missing.length) {
2028
2028
  diagnostics.push(error(
package/src/web.js CHANGED
@@ -589,6 +589,13 @@ function readinessOverview() {
589
589
  }
590
590
 
591
591
  function nextProgramStageHref() {
592
+ const firstAction = state.programReadiness?.firstAction;
593
+ const recommendedStage = state.programReadiness?.stages?.find((stage) => stage.items.some((item) => (
594
+ item.id === firstAction?.id && item.resourceId === firstAction?.resourceId
595
+ )));
596
+ if (recommendedStage) {
597
+ return "#/stage/" + encodeURIComponent(recommendedStage.id === "operation" ? "run" : recommendedStage.id);
598
+ }
592
599
  const nextStage = READINESS_STAGES.find((stage) => {
593
600
  const progress = stageProgress(stage);
594
601
  return progress.complete < progress.total;
@@ -836,7 +836,7 @@ function canonicalJson(value, field = null, revisionMap = false) {
836
836
  if (Array.isArray(value)) return value.map((item) => canonicalJson(item));
837
837
  if (!value || typeof value !== "object") {
838
838
  return typeof value === "string" && (revisionMap || CALCULATED_REVISION_FIELDS.has(field))
839
- ? canonicalCalculatedRevision(value)
839
+ ? canonicalCalculatedRevision(value, field)
840
840
  : value;
841
841
  }
842
842
  return Object.fromEntries(Object.keys(value).sort().map((key) => [