filegrc 0.16.19 → 0.16.21

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.19",
3
+ "version": "0.16.21",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  import { modelSupports } from "../model/index.js";
2
2
  import { programComponents, selectedRequirementIds } from "./program.js";
3
- import { resourceReviewRevisionsSync, retentionReviewResourceIds, retentionUses } from "./retention.js";
3
+ import { resourceReviewRevisionsSync, retentionReviewResourceIds, retentionSystemUses, retentionUses } from "./retention.js";
4
4
  import { documentIsAuditSpecific } from "./program-lifecycle.js";
5
5
 
6
6
  export function scopedCollectionRecords(loaded, resourceType, program) {
@@ -625,6 +625,10 @@ function dependencyRevisionValue(resourceType, record, legacy, historicalReviewM
625
625
  const value = Object.fromEntries(fields
626
626
  .filter((field) => record[field] !== undefined)
627
627
  .map((field) => [field, record[field]]));
628
+ if (resourceType === "retention-schedule-item" && currentModel && !historicalReviewMetadata
629
+ && !legacyRetentionSourceBasis && record.type === "component") {
630
+ value.systemUses = retentionSystemUses(value.systemUses);
631
+ }
628
632
  if (controlDecision) value.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
629
633
  return value;
630
634
  }
package/src/retention.js CHANGED
@@ -167,8 +167,9 @@ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "leg
167
167
  const entry = effectiveReviewEntry(loaded, entries.get(id));
168
168
  if (!entry || reviewing.has(key)) return null;
169
169
  reviewing.add(key);
170
- const parts = [reviewSource(loaded, entry, scopeHashInput, false, heading)];
171
- let matchedSection = !heading;
170
+ const decisionHeading = heading || retentionPolicyHeading(loaded, entry, reviewer);
171
+ const parts = [reviewSource(loaded, entry, scopeHashInput, false, decisionHeading, reviewer)];
172
+ let matchedSection = !decisionHeading;
172
173
  for (const markdown of reviewMarkdownEntries(loaded, entry, false)) {
173
174
  try {
174
175
  const source = entry.reviewCommit
@@ -176,7 +177,7 @@ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "leg
176
177
  ?? getFileAtRevision(loaded.root, entry.reviewCommit, `data/${markdown.path}`)
177
178
  : await readFile(resolveDataPath(loaded.root, markdown.path), "utf8");
178
179
  if (source !== null) {
179
- const section = reviewedMarkdownSection(source, heading);
180
+ const section = reviewedMarkdownSection(source, decisionHeading);
180
181
  if (section !== null) { parts.push(section); matchedSection = true; }
181
182
  }
182
183
  } catch (error) {
@@ -214,8 +215,9 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac
214
215
  const entry = historicalCommit ? entries.get(id) : effectiveReviewEntry(loaded, entries.get(id));
215
216
  if (!entry || reviewing.has(key)) return null;
216
217
  reviewing.add(key);
217
- const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource, heading)];
218
- let matchedSection = !heading;
218
+ const decisionHeading = legacySource ? heading : heading || retentionPolicyHeading(loaded, entry, reviewer);
219
+ const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource, decisionHeading, reviewer)];
220
+ let matchedSection = !decisionHeading;
219
221
  for (const markdown of reviewMarkdownEntries(loaded, entry, legacySource)) {
220
222
  try {
221
223
  const source = (historicalCommit || entry.reviewCommit)
@@ -224,7 +226,7 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac
224
226
  ?? getFileAtRevision(loaded.root, historicalCommit || entry.reviewCommit, `data/${markdown.path}`)
225
227
  : readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8");
226
228
  if (source !== null) {
227
- const section = legacySource ? source : reviewedMarkdownSection(source, heading);
229
+ const section = legacySource ? source : reviewedMarkdownSection(source, decisionHeading);
228
230
  if (section !== null) { parts.push(section); matchedSection = true; }
229
231
  }
230
232
  } catch (error) {
@@ -250,25 +252,26 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac
250
252
  }
251
253
 
252
254
  export function resourceReviewRevisionMatches(loaded, revisions, id, stored, reviewer = null) {
253
- const current = sourceHeading(reviewer, id)
255
+ const current = (sourceHeading(reviewer, id) || reviewer?.type === "retention-schedule-item")
254
256
  ? resourceReviewRevisionsSync(loaded, [id], "legacy", false, null, reviewer).get(id)
255
257
  : revisions.get(id);
256
258
  if (!current || !revisionDigest("content", stored)) return false;
257
259
  if (revisionsMatch("content", stored, current)) return true;
258
- if (sourceHeading(reviewer, id)) return false;
259
- const legacy = resourceReviewRevisionsSync(loaded, [id], "legacy", true).get(id);
260
- if (revisionsMatch("content", stored, legacy)) return true;
261
- // Version 0.16.0 used a bare digest as the hash input for scopeRevision.
262
- // Read that binding without changing the legacy scope: basis restored here.
263
- const compatible = resourceReviewRevisionsSync(loaded, [id], "digest", true).get(id);
264
- if (revisionsMatch("content", stored, compatible)) return true;
265
- return historicallyEquivalentReviewSource(loaded, id, stored, current);
260
+ if (!sourceHeading(reviewer, id)) {
261
+ const legacy = resourceReviewRevisionsSync(loaded, [id], "legacy", true).get(id);
262
+ if (revisionsMatch("content", stored, legacy)) return true;
263
+ // Version 0.16.0 used a bare digest as the hash input for scopeRevision.
264
+ // Read that binding without changing the legacy scope: basis restored here.
265
+ const compatible = resourceReviewRevisionsSync(loaded, [id], "digest", true).get(id);
266
+ if (revisionsMatch("content", stored, compatible)) return true;
267
+ }
268
+ return historicallyEquivalentReviewSource(loaded, id, stored, current, reviewer);
266
269
  }
267
270
 
268
271
  // Recompute the old byte-sensitive binding at each committed state. The
269
272
  // semantic source graph must still equal today's graph, including linked
270
273
  // records and Markdown, before the old approval is treated as current.
271
- function historicallyEquivalentReviewSource(loaded, id, stored, current) {
274
+ function historicallyEquivalentReviewSource(loaded, id, stored, current, reviewer = null) {
272
275
  if (!loaded.root) return false;
273
276
  const context = reviewHistoryContext(loaded.root, loaded);
274
277
  const { index } = context;
@@ -289,8 +292,13 @@ function historicallyEquivalentReviewSource(loaded, id, stored, current) {
289
292
  if (relevantPaths && ![...changed.keys()].some((path) => relevantPaths.has(path) || path.endsWith(".md"))) continue;
290
293
  const snapshot = reviewHistoricalWorkspace(context, commit);
291
294
  if (!snapshot?.entries.some(({ record }) => record.id === id)) continue;
292
- const semantic = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit).get(id);
295
+ const semantic = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit, reviewer).get(id);
293
296
  if (!revisionsMatch("content", semantic, current)) continue;
297
+ // A row may narrow a source to its retention facts or a named Markdown
298
+ // section. Accept its old binding only when those facts still match the
299
+ // committed source that supplied the binding.
300
+ const original = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit).get(id);
301
+ if (revisionsMatch("content", stored, original)) return true;
294
302
  for (const scopeHashInput of ["legacy", "digest"]) {
295
303
  const legacy = resourceReviewRevisionsSync(snapshot, [id], scopeHashInput, true, commit).get(id);
296
304
  if (revisionsMatch("content", stored, legacy)) return true;
@@ -320,7 +328,7 @@ export function retentionUses(loaded, program) {
320
328
  return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
321
329
  }
322
330
 
323
- function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false, heading = null) {
331
+ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false, heading = null, reviewer = null) {
324
332
  if (heading && !legacySource) {
325
333
  const governingFields = entry.record.type === "policy"
326
334
  ? ["policyKind", "effectiveOn", "supersedesId", "parentPolicyId", "relatedPolicyIds", "relatedDocumentIds", "requirementIds", "audience", "acknowledgementRequired", "programRole", "reportingRouteRequirements"]
@@ -358,7 +366,7 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = f
358
366
  return legacySource
359
367
  ? canonicalCalculatedRevisionJson(entry.source, scopeHashInput)
360
368
  : canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource(
361
- JSON.parse(entry.source), modelSupports(loaded.model, "retention-schedule-approval")
369
+ JSON.parse(entry.source), modelSupports(loaded.model, "retention-schedule-approval"), reviewer
362
370
  )), scopeHashInput);
363
371
  }
364
372
 
@@ -454,6 +462,32 @@ function sourceHeading(record, sourceId) {
454
462
  ? headings[sourceId] : null;
455
463
  }
456
464
 
465
+ function retentionPolicyHeading(loaded, entry, reviewer) {
466
+ if (reviewer?.type !== "retention-schedule-item" || entry.record.type !== "policy") return null;
467
+ // Older rows often bind a whole Policy. A single retention section gives
468
+ // that decision a narrower basis while an explicit heading still wins.
469
+ const headings = new Set();
470
+ for (const markdown of markdownEntries(loaded.model, entry.record)) {
471
+ let source;
472
+ try {
473
+ source = loaded.historicalFiles?.get(`data/${markdown.path}`)
474
+ ?? (entry.reviewCommit
475
+ ? entry.reviewFiles?.get(`data/${markdown.path}`)
476
+ ?? getFileAtRevision(loaded.root, entry.reviewCommit, `data/${markdown.path}`)
477
+ : readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"));
478
+ } catch (error) {
479
+ if (error.code === "ENOENT") continue;
480
+ throw error;
481
+ }
482
+ for (const line of (source || "").split("\n")) {
483
+ const heading = /^#{2,6}\s+(.+?)\s*#*\s*$/.exec(line)?.[1];
484
+ if (heading && /\b(retention|disposal|deletion|destruction|archiving)\b/i.test(heading)
485
+ && reviewedMarkdownSection(source, heading) !== null) headings.add(heading);
486
+ }
487
+ }
488
+ return headings.size === 1 ? [...headings][0] : null;
489
+ }
490
+
457
491
  const reviewDecisionFields = {
458
492
  control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
459
493
  commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "sourceSectionHeadings", "requirementIds", "controlIds", "customerFacing", "effectiveOn", "reportingRouteRequirements"],
@@ -469,7 +503,7 @@ const reviewDecisionFields = {
469
503
  document: ["documentKind", "workflowScope", "template", "version", "effectiveOn", "supersedesId", "systemIds", "controlIds", "relatedDocumentIds", "audience", "acknowledgementRequired", "trainingIds", "classificationId", "programRole", "componentIds", "reportingRouteRequirements"]
470
504
  };
471
505
 
472
- function substantiveReviewSource(record, currentModel) {
506
+ function substantiveReviewSource(record, currentModel, reviewer = null) {
473
507
  if (currentModel && reviewDecisionFields[record.type]) {
474
508
  const fields = ["id", "type", "title", "extensions", "externalIds", ...reviewDecisionFields[record.type]];
475
509
  const decision = Object.fromEntries(fields.filter((field) => record[field] !== undefined)
@@ -481,6 +515,10 @@ function substantiveReviewSource(record, currentModel) {
481
515
  } else {
482
516
  decision.status = record.status || null;
483
517
  }
518
+ if (record.type === "component" && reviewer?.type === "retention-schedule-item") {
519
+ decision.systemUses = retentionSystemUses(decision.systemUses);
520
+ delete decision.evidenceSourceKinds;
521
+ }
484
522
  if (["control", "commitment", "complementary-control"].includes(record.type)) {
485
523
  decision.nonApplicableDecision = ["not-applicable", "externally-managed", "zero-population"]
486
524
  .includes(record.applicabilityReview?.decision) ? record.applicabilityReview.decision : null;
@@ -511,6 +549,13 @@ function substantiveReviewSource(record, currentModel) {
511
549
  return source;
512
550
  }
513
551
 
552
+ export function retentionSystemUses(uses) {
553
+ return uses?.map((use) => ({
554
+ ...use,
555
+ roles: use.roles?.filter((role) => role !== "evidence-source")
556
+ }));
557
+ }
558
+
514
559
  export function nearDuplicateInformationTypes(records) {
515
560
  const types = records.filter((record) => record.type === "information-type" && !["retired", "superseded"].includes(record.status));
516
561
  const pairs = [];