filegrc 0.15.2 → 0.15.3

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.15.2",
3
+ "version": "0.15.3",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -997,24 +997,30 @@ function occurrenceContinuityStage(audit, records, model, asOf) {
997
997
  export function occurrenceProgressUnits(plan, { selectedControlIds, periodStart, periodThrough }) {
998
998
  return [
999
999
  ...plan.calendarItems.flatMap((occurrence) => {
1000
- const completedMemberIds = new Set(occurrence.completedMemberIds || []);
1000
+ const resolvedMemberIds = new Set(occurrence.resolvedMemberIds || occurrence.completedMemberIds || []);
1001
+ const membersComplete = occurrence.membershipFinal && (occurrence.expectedMemberIds || [])
1002
+ .every((resourceId) => resolvedMemberIds.has(resourceId));
1001
1003
  const conclusionComplete = occurrence.reconciliationStatus === "reconciled"
1002
- && ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.operatingResult);
1004
+ && ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.operatingResult)
1005
+ && membersComplete
1006
+ && occurrence.conclusionValid !== false;
1003
1007
  const occurrenceComplete = conclusionComplete || occurrence.legacySchedule && occurrence.status === "complete";
1004
- const memberUnits = (occurrence.expectedMemberIds || []).map((resourceId) => ({
1005
- id: `occurrence-${occurrence.occurrenceKey}-member-${resourceId}`,
1006
- status: occurrenceComplete || completedMemberIds.has(resourceId) ? "complete" : "action",
1007
- title: `${occurrence.title} member ${resourceId}`
1008
- }));
1009
- const membersComplete = memberUnits.every(({ status }) => status === "complete");
1010
- return [
1011
- ...memberUnits,
1008
+ const workComplete = occurrenceComplete || membersComplete;
1009
+ const workUnit = {
1010
+ id: `occurrence-${occurrence.occurrenceKey}-work`,
1011
+ status: workComplete ? "complete" : "action",
1012
+ title: `${occurrence.title} occurrence work`
1013
+ };
1014
+ return occurrence.legacySchedule
1015
+ ? [workUnit]
1016
+ : [
1017
+ workUnit,
1012
1018
  {
1013
1019
  id: `occurrence-${occurrence.occurrenceKey}-reconciliation`,
1014
- status: occurrenceComplete ? "complete" : membersComplete && occurrence.membershipFinal ? "action" : "blocked",
1020
+ status: occurrenceComplete ? "complete" : workComplete && occurrence.membershipFinal ? "action" : "blocked",
1015
1021
  title: `${occurrence.title} reconciliation`
1016
1022
  }
1017
- ];
1023
+ ];
1018
1024
  }),
1019
1025
  ...plan.eventRuns
1020
1026
  .filter((run) => run.status !== "canceled" && run.occurredOn >= periodStart && run.occurredOn <= periodThrough)
@@ -0,0 +1,31 @@
1
+ export function occurrenceMemberExceptionIsValid(member, occurrence, byId) {
2
+ const exception = byId.get(member.exceptionId);
3
+ const occurrenceStart = occurrence.coverage?.kind === "as-of"
4
+ ? occurrence.coverage.on
5
+ : occurrence.coverage?.startsOn;
6
+ const occurrenceEnd = occurrence.coverage?.kind === "as-of"
7
+ ? occurrence.coverage.on
8
+ : occurrence.coverage?.endsOn;
9
+ const exceptionCoversMember = exception?.scopeResourceIds?.includes(member.resourceId)
10
+ || exception?.scopeResourceIds?.includes(occurrence.obligationId);
11
+ const statusCoveredOccurrence = exception?.status === "approved"
12
+ || ["revoked", "closed"].includes(exception?.status)
13
+ && exception.resolution?.resolvedOn > occurrenceEnd;
14
+ return Boolean(
15
+ exception?.type === "exception"
16
+ && statusCoveredOccurrence
17
+ && exceptionCoversMember
18
+ && exception.approval?.approvedOn <= occurrenceStart
19
+ && exception.approval?.expiresOn >= occurrenceEnd
20
+ );
21
+ }
22
+
23
+ export function occurrenceMemberIsResolved(member, occurrence, byId, expectedCompletionIsValid = () => false) {
24
+ if (member.disposition === "expected") {
25
+ return member.result === "passed"
26
+ && (member.completionResourceIds || []).some((id) => expectedCompletionIsValid(id, member));
27
+ }
28
+ if (member.disposition === "exception") return occurrenceMemberExceptionIsValid(member, occurrence, byId);
29
+ if (member.disposition === "not-applicable") return Boolean(String(member.rationale || "").trim());
30
+ return false;
31
+ }
@@ -29,6 +29,7 @@ import { serializeWorkspaceMutation } from "./mutation.js";
29
29
  import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "./collection-review-integrity.js";
30
30
  import { bindAttestationReportingRouteSet, reportingRouteRevision } from "./reporting-route-integrity.js";
31
31
  import { selectScopedCollectionRecords } from "./collection-scope.js";
32
+ import { occurrenceMemberIsResolved } from "./obligation-members.js";
32
33
 
33
34
  const COMPLETION_DATE_FIELDS = [
34
35
  "completedOn",
@@ -214,8 +215,22 @@ export function planObligations(resources, options = {}) {
214
215
  const completedMemberIds = new Set((reconciliation?.members || [])
215
216
  .filter((member) => member.result === "passed" && member.disposition === "expected")
216
217
  .map((member) => member.resourceId));
218
+ const resolvedMemberIds = new Set((reconciliation?.members || [])
219
+ .filter((member) => occurrenceMemberIsResolved(member, reconciliation, byId, (completionId) => {
220
+ const completion = byId.get(completionId);
221
+ return completionTypeMatches(completion, expectedCompletionTypes)
222
+ && completionMatchesMember(completion, activity.aggregate?.completionMemberField, member.resourceId)
223
+ && completionFallsInWindow(completion, window, rule?.timezone || workspace?.timezone || "UTC")
224
+ && completionPassesActivity(completion, activity);
225
+ }))
226
+ .map((member) => member.resourceId));
227
+ const resolutionsComplete = membershipFinal
228
+ && expectedMemberIds.every((resourceId) => resolvedMemberIds.has(resourceId));
229
+ const conclusionValid = ["complete", "complete-with-exceptions", "zero-population"].includes(reconciliation?.conclusion)
230
+ && resolutionsComplete;
217
231
  const successful = reconciliation
218
- ? reconciliation.status === "reconciled" && ["complete", "complete-with-exceptions", "zero-population"].includes(reconciliation.conclusion)
232
+ ? reconciliation.status === "reconciled"
233
+ && conclusionValid
219
234
  : legacyCompletions.length > 0;
220
235
  const timingStatus = occurrenceStatus(window, asOf, successful);
221
236
  const status = timingStatus === "complete" || programStatus === "accepted" ? timingStatus : "proposed";
@@ -244,6 +259,9 @@ export function planObligations(resources, options = {}) {
244
259
  completionResourceIds,
245
260
  expectedMemberIds,
246
261
  completedMemberIds: [...completedMemberIds],
262
+ resolvedMemberIds: [...resolvedMemberIds],
263
+ resolutionsComplete,
264
+ conclusionValid,
247
265
  expectedCount: reconciliation?.status === "open" && !membershipFinal
248
266
  ? expectedMemberIds.length
249
267
  : reconciliation?.expectedCount ?? expectedMemberIds.length,
@@ -282,8 +300,20 @@ export function planObligations(resources, options = {}) {
282
300
  const completedMemberIds = (occurrence.members || [])
283
301
  .filter(({ disposition, result }) => disposition === "expected" && result === "passed")
284
302
  .map(({ resourceId }) => resourceId);
303
+ const resolvedMemberIds = (occurrence.members || [])
304
+ .filter((member) => occurrenceMemberIsResolved(member, occurrence, byId, (completionId) => {
305
+ const completion = byId.get(completionId);
306
+ return completionTypeMatches(completion, activity.completionResourceTypes)
307
+ && completionMatchesMember(completion, activity.aggregate?.completionMemberField, member.resourceId)
308
+ && completionFallsInWindow(completion, window, rule.timezone || workspace?.timezone || "UTC")
309
+ && completionPassesActivity(completion, activity);
310
+ }))
311
+ .map(({ resourceId }) => resourceId);
312
+ const resolutionsComplete = (occurrence.members || []).every(({ resourceId }) => resolvedMemberIds.includes(resourceId));
313
+ const conclusionValid = ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.conclusion)
314
+ && resolutionsComplete;
285
315
  const successful = occurrence.status === "reconciled"
286
- && ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.conclusion);
316
+ && conclusionValid;
287
317
  const timingStatus = occurrenceStatus(window, asOf, successful);
288
318
  if (timingStatus === "complete" && !options.includeComplete) continue;
289
319
  calendarItems.push({
@@ -307,6 +337,9 @@ export function planObligations(resources, options = {}) {
307
337
  completionResourceIds: [...new Set((occurrence.members || []).flatMap(({ completionResourceIds = [] }) => completionResourceIds))],
308
338
  expectedMemberIds: (occurrence.members || []).map(({ resourceId }) => resourceId),
309
339
  completedMemberIds,
340
+ resolvedMemberIds,
341
+ resolutionsComplete,
342
+ conclusionValid,
310
343
  expectedCount: occurrence.expectedCount,
311
344
  completedCount: occurrence.completedCount,
312
345
  membershipFinal: true,
@@ -1750,8 +1783,8 @@ function plannedCompletionWindow(window) {
1750
1783
  };
1751
1784
  }
1752
1785
 
1753
- function completionFallsInWindow(record, window) {
1754
- const date = completionDate(record);
1786
+ function completionFallsInWindow(record, window, timezone = "UTC") {
1787
+ const date = completionDate(record, timezone);
1755
1788
  return Boolean(date && date >= window.dueWindowStart && date <= window.dueWindowEnd);
1756
1789
  }
1757
1790
 
@@ -1765,11 +1798,11 @@ function completionTypeMatches(record, expectedTypes = []) {
1765
1798
  return Boolean(record && (expectedTypes.length === 0 || expectedTypes.includes(record.type)));
1766
1799
  }
1767
1800
 
1768
- function completionDate(record) {
1801
+ function completionDate(record, timezone = "UTC") {
1769
1802
  const coverageDate = coverageEnd(record.coverage);
1770
1803
  if (parseCalendarDate(coverageDate)) return coverageDate;
1771
1804
  for (const field of COMPLETION_TIMESTAMP_FIELDS) {
1772
- if (isRfc3339Timestamp(record[field])) return record[field].slice(0, 10);
1805
+ if (isRfc3339Timestamp(record[field])) return currentCalendarDate(timezone, new Date(record[field]));
1773
1806
  }
1774
1807
  for (const field of COMPLETION_DATE_FIELDS) {
1775
1808
  if (parseCalendarDate(record[field])) return record[field];
@@ -707,15 +707,34 @@ function reportingRouteItem(records, byId, asOf, timezone = "UTC") {
707
707
  );
708
708
  }
709
709
 
710
- function collectionReviewReadinessItem(assessment) {
710
+ export function collectionReviewReadinessItem(assessment) {
711
711
  const proposalUnits = (assessment.recordProposals || []).map((proposal) => ({
712
712
  id: `${assessment.resourceType}-proposal-${proposal.resourceId}`,
713
713
  status: proposal.complete ? "complete" : "action",
714
714
  title: `${proposal.title} proposal`
715
715
  }));
716
+ const proposalBatchComplete = proposalUnits.every(({ status }) => status === "complete");
717
+ const scheduleApprovalBlocked = assessment.resourceType === "retention-schedule-item"
718
+ && proposalUnits.some(({ status }) => status !== "complete");
719
+ const batchProgressUnits = assessment.resourceType !== "retention-schedule-item" && proposalUnits.length
720
+ ? [
721
+ {
722
+ id: `${assessment.resourceType}-proposal-batch`,
723
+ status: proposalBatchComplete ? "complete" : "action",
724
+ title: `${assessment.configuration.title} proposals`
725
+ },
726
+ {
727
+ id: `${assessment.resourceType}-collection-review`,
728
+ status: proposalBatchComplete
729
+ ? assessment.complete ? "complete" : "action"
730
+ : "blocked",
731
+ title: `${assessment.configuration.title} collection review`
732
+ }
733
+ ]
734
+ : null;
716
735
  return item(
717
736
  `collection-review-${assessment.resourceType}`,
718
- assessment.complete ? "complete" : "action",
737
+ assessment.complete ? "complete" : scheduleApprovalBlocked ? "blocked" : "action",
719
738
  assessment.status === "stale"
720
739
  ? `Review ${assessment.configuration.title.toLowerCase()} again`
721
740
  : `Review ${assessment.configuration.title.toLowerCase()}`,
@@ -724,18 +743,7 @@ function collectionReviewReadinessItem(assessment) {
724
743
  {
725
744
  resourceType: assessment.resourceType,
726
745
  reviewPoints: assessment.configuration.reviewPoints,
727
- ...(proposalUnits.length ? {
728
- progressUnits: [
729
- ...proposalUnits,
730
- {
731
- id: `${assessment.resourceType}-collection-review`,
732
- status: proposalUnits.every(({ status }) => status === "complete")
733
- ? assessment.complete ? "complete" : "action"
734
- : "blocked",
735
- title: `${assessment.configuration.title} collection review`
736
- }
737
- ]
738
- } : {}),
746
+ ...(batchProgressUnits ? { progressUnits: batchProgressUnits } : {}),
739
747
  commands: [
740
748
  `npx filegrc review-collection ${assessment.resourceType} --scaffold`,
741
749
  `npx filegrc review-collection ${assessment.resourceType} REVIEW.json --preview --json`
@@ -1646,6 +1654,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1646
1654
  : `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative ${componentSources ? "Component" : "System"} with the required source role, access owners, and extraction instructions.`,
1647
1655
  completeSources[0] || selectedSources[0] || { type: sourceType },
1648
1656
  {
1657
+ progressUnit: false,
1649
1658
  familyId: family.id,
1650
1659
  sourceKinds: family.sourceKinds,
1651
1660
  controlIds: family.controls.map((control) => control.id),
package/src/retention.js CHANGED
@@ -137,6 +137,7 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
137
137
  : `${duplicates.length} similar pair${duplicates.length === 1 ? " needs" : "s need"} management review. FileGRC will not merge records or rewrite relationships automatically.`,
138
138
  { type: "information-type" },
139
139
  {
140
+ progressUnit: false,
140
141
  candidates: duplicates,
141
142
  commands: [
142
143
  "npx filegrc list information-type --workflow --json",
package/src/validate.js CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  recordsAtRevision
45
45
  } from "./reporting-route-integrity.js";
46
46
  import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
47
+ import { occurrenceMemberExceptionIsValid } from "./obligation-members.js";
47
48
 
48
49
  let fingerprintFileReadObserver = null;
49
50
 
@@ -667,18 +668,7 @@ function validateObligationOccurrence(record, model, resources, entries, root, w
667
668
  ));
668
669
  }
669
670
  if (member.disposition === "exception") {
670
- const exception = byId.get(member.exceptionId);
671
- const occurrenceStart = record.coverage?.kind === "as-of" ? record.coverage.on : record.coverage?.startsOn;
672
- const occurrenceEnd = record.coverage?.kind === "as-of" ? record.coverage.on : record.coverage?.endsOn;
673
- const exceptionCoversMember = exception?.scopeResourceIds?.includes(member.resourceId)
674
- || exception?.scopeResourceIds?.includes(record.obligationId);
675
- if (
676
- exception?.type !== "exception"
677
- || exception.status !== "approved"
678
- || !exceptionCoversMember
679
- || exception.approval?.approvedOn > occurrenceStart
680
- || exception.approval?.expiresOn < occurrenceEnd
681
- ) {
671
+ if (!occurrenceMemberExceptionIsValid(member, record, byId)) {
682
672
  diagnostics.push(error(
683
673
  "invalid-member-exception",
684
674
  path,
package/src/web.js CHANGED
@@ -670,7 +670,8 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
670
670
  const guideTrigger = '<button class="guide-trigger" id="resource-guide-trigger" type="button" aria-label="About Data Retention Schedule" aria-haspopup="dialog" aria-controls="resource-guide" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8"></circle><path d="M7.8 7.5a2.4 2.4 0 1 1 3.25 2.25c-.7.31-1.05.72-1.05 1.5v.25M10 14.5v.1"></path></svg></button>';
671
671
  const initialFilter = params.get("q") || "";
672
672
  const showHistory = params.get("history") === "1";
673
- const tableTools = '<div class="retention-table-tools"><label><span class="sr-only">Filter schedule rows</span><input type="search" data-retention-search placeholder="Filter schedule rows" value="' + esc(initialFilter) + '"></label><label class="history-toggle"><input type="checkbox" data-retention-history ' + (showHistory ? "checked" : "") + '> Show retired</label></div>';
673
+ const retiredRows = rows.filter(({ record }) => ["superseded", "retired"].includes(record.status)).length;
674
+ const tableTools = '<div class="retention-table-tools"><label><span class="sr-only">Filter schedule rows</span><input type="search" data-retention-search placeholder="Filter schedule rows" value="' + esc(initialFilter) + '"></label>' + (retiredRows ? '<label class="history-toggle"><input type="checkbox" data-retention-history ' + (showHistory ? "checked" : "") + '> Show ' + retiredRows + ' retired ' + pluralize("row", retiredRows) + '</label>' : '<input type="checkbox" data-retention-history hidden>') + '</div>';
674
675
  main.innerHTML = '<div class="page retention-schedule-page"><nav class="breadcrumbs"><a href="#/stage/policies">Step 2</a><span>/</span><span>Data Retention Schedule</span></nav>' +
675
676
  '<div class="page-intro"><div><p class="kicker">Step 2</p><div class="page-title-line"><h2>Data Retention Schedule</h2>' + guideTrigger + '</div><p>Owners finish each proposed retention row first. A separate reviewer then approves the complete document and all proposed rows once.</p></div><div class="actions">' + documentAction + add + '</div></div>' + resourceGuide("retention-schedule-item") +
676
677
  '<div class="retention-summary"><div><span class="retention-summary-label">Governing document</span>' + documentSummary + '</div><div><span class="retention-summary-label">Schedule revision</span><span><strong>' + proposedRows + ' completed ' + pluralize("proposal", proposedRows) + '</strong><small>Final approval follows every row proposal</small></span><span class="badge ' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "good" : "warn") + '">' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "Approved" : "Approval needed") + '</span></div></div>' +
@@ -746,7 +747,13 @@ function wireRetentionScheduleTable(total, initialPage = 1) {
746
747
  noResults.hidden = filtered.length !== 0 || total === 0;
747
748
  const firstVisible = filtered.length ? start + 1 : 0;
748
749
  const lastVisible = Math.min(start + LIST_PAGE_SIZE, filtered.length);
749
- count.textContent = firstVisible + "–" + lastVisible + " of " + filtered.length + " " + pluralize("row", filtered.length) + (filtered.length === total ? "" : " · " + total + " total");
750
+ const hiddenByHistory = historyToggle.checked ? 0 : [...table.querySelectorAll('[data-retention-row][data-history="true"]')]
751
+ .filter((row) => !query || row.textContent.toLowerCase().includes(query)).length;
752
+ const matching = filtered.length + hiddenByHistory;
753
+ count.textContent = firstVisible + "–" + lastVisible + " of " + filtered.length + (historyToggle.checked ? " shown " : " active ") + pluralize("row", filtered.length)
754
+ + (hiddenByHistory ? " · " + hiddenByHistory + " retired hidden" : "")
755
+ + (query ? " · " + matching + " matching" : "")
756
+ + " · " + total + " total";
750
757
  pagination.hidden = totalPages === 1;
751
758
  pageStatus.textContent = "Page " + pageNumber + " of " + totalPages;
752
759
  previous.disabled = pageNumber === 1;