filegrc 0.16.1 → 0.16.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/README.md CHANGED
@@ -4,7 +4,7 @@ filegrc is a zero-dependency Node.js engine for Git-native GRC workspaces. It va
4
4
 
5
5
  Program Readiness checks management-owned scope, policy adoption, control implementation, and authoritative evidence mapping without requiring an audit record. Audit Readiness starts after CPA engagement and checks the firm-agreed date or period, engagement-specific management documents, operating evidence, and Type 2 population completeness.
6
6
 
7
- Controls linked to filegrc Obligations show whether their scheduled work is waiting for policy approval, ready for implementation, running, paused, or mixed. Marking a fully configured Control implemented starts its enabled work when the governing content is effective.
7
+ The Controls list focuses on implementation and its next action. The Obligations list shows schedule state separately, including proposals, approved schedules, and Policies awaiting activation or their effective date. Enabled work starts when its governing content is effective and a linked Control is implemented.
8
8
 
9
9
  Most users should create a complete workspace:
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
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),
@@ -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"
@@ -101,6 +101,9 @@ export function planObligations(resources, options = {}) {
101
101
  && ["active", "proposed"].includes(record.status)
102
102
  && obligationBelongsToProgram(record, obligationProgram, model)
103
103
  ));
104
+ const programStatusByObligationId = new Map(obligations.map((obligation) => [
105
+ obligation.id, obligationProgramStatus(obligation, byId, asOf, model)
106
+ ]));
104
107
  const obligationIds = new Set(obligations.map(({ id }) => id));
105
108
  if (obligations.length > MAX_PLANNED_ITEMS) {
106
109
  throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} active obligations.`);
@@ -123,7 +126,7 @@ export function planObligations(resources, options = {}) {
123
126
  const schedule = rule || obligation;
124
127
  const activity = obligationActivity(model, obligation);
125
128
  const expectedCompletionTypes = activity.completionResourceTypes;
126
- const programStatus = obligationProgramStatus(obligation, byId, asOf, model);
129
+ const programStatus = programStatusByObligationId.get(obligation.id);
127
130
  const programBlocker = programStatus === "proposed" ? obligationProgramBlocker(obligation, byId, asOf) : null;
128
131
  if (schedule.recurrence?.mode === "event" && schedule.recurrence.eventType) {
129
132
  const eventType = schedule.recurrence.eventType;
@@ -294,7 +297,7 @@ export function planObligations(resources, options = {}) {
294
297
  if (!dueWindowStart || !dueWindowEnd || dueWindowStart > through) continue;
295
298
  const overdueOn = addCalendarDays(dueWindowEnd, 1);
296
299
  const window = { dueWindowStart, dueWindowEnd, overdueOn };
297
- const programStatus = obligationProgramStatus(obligation, byId, asOf, model);
300
+ const programStatus = programStatusByObligationId.get(obligation.id);
298
301
  const programBlocker = programStatus === "proposed" ? obligationProgramBlocker(obligation, byId, asOf) : null;
299
302
  const activity = obligationActivity(model, obligation);
300
303
  const completedMemberIds = (occurrence.members || [])
@@ -402,6 +405,7 @@ export function planObligations(resources, options = {}) {
402
405
  asOf,
403
406
  through,
404
407
  from: requestedFrom,
408
+ programStatuses: Object.fromEntries(programStatusByObligationId),
405
409
  counts,
406
410
  items,
407
411
  calendarItems,
@@ -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,
package/src/web.js CHANGED
@@ -246,6 +246,7 @@ function normalizeAppState(next) {
246
246
  function blockingStateSections(route) {
247
247
  if (route.name === "home") return ["program", "obligations", "workflow"];
248
248
  if (route.name === "repository") return ["repository"];
249
+ if (route.name === "list" && route.type === "obligation") return ["obligations"];
249
250
  if (route.name === "obligations" || route.name === "stage" && route.stageId === "run") return ["program", "obligations"];
250
251
  if (route.name === "audit-packet") return ["repository", "program", "obligations", "audits"];
251
252
  if (route.name === "evidence-sources") return ["program", "workflow"];
@@ -589,6 +590,13 @@ function readinessOverview() {
589
590
  }
590
591
 
591
592
  function nextProgramStageHref() {
593
+ const firstAction = state.programReadiness?.firstAction;
594
+ const recommendedStage = state.programReadiness?.stages?.find((stage) => stage.items.some((item) => (
595
+ item.id === firstAction?.id && item.resourceId === firstAction?.resourceId
596
+ )));
597
+ if (recommendedStage) {
598
+ return "#/stage/" + encodeURIComponent(recommendedStage.id === "operation" ? "run" : recommendedStage.id);
599
+ }
592
600
  const nextStage = READINESS_STAGES.find((stage) => {
593
601
  const progress = stageProgress(stage);
594
602
  return progress.complete < progress.total;
@@ -3081,7 +3089,6 @@ function renderList(main, type, params = new URLSearchParams()) {
3081
3089
  const fields = [...new Set([
3082
3090
  "title",
3083
3091
  ...(definition.listFields || []),
3084
- ...(type === "control" ? ["$operationTracking"] : []),
3085
3092
  ...(type === "obligation" ? ["$workQueueStatus"] : [])
3086
3093
  ])].filter((name) => name !== "title");
3087
3094
  const modelFields = { ...state.model.commonFields, ...definition.fields };
@@ -3124,7 +3131,7 @@ function renderList(main, type, params = new URLSearchParams()) {
3124
3131
  const start = (pageNumber - 1) * LIST_PAGE_SIZE;
3125
3132
  const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
3126
3133
  main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
3127
- main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + detailContext + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$operationTracking" ? controlOperationTracking(entry.record) : name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? displayStatus(entry.record) : entry.record[name], name, type, true)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : collectionEmptyState(type, definition)) + '</td></tr>';
3134
+ main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + detailContext + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? displayStatus(entry.record) : entry.record[name], name, type, true)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : collectionEmptyState(type, definition)) + '</td></tr>';
3128
3135
  pagination.hidden = totalPages === 1;
3129
3136
  previous.disabled = pageNumber === 1;
3130
3137
  next.disabled = pageNumber === totalPages;
@@ -5965,7 +5972,6 @@ function setNavigationGroupOpen(group, open) {
5965
5972
  function groupTitle(id) { return state.model.groups.find((group) => group.id === id)?.title || "Program"; }
5966
5973
  function fieldDefinition(type, name) { return state.model.resources[type]?.fields?.[name] || state.model.commonFields[name]; }
5967
5974
  function fieldLabel(type, name) {
5968
- if (name === "$operationTracking") return "Operation tracking";
5969
5975
  if (name === "$workQueueStatus") return "Work Queue";
5970
5976
  if (type === "obligation" && name === "status") return "Configuration";
5971
5977
  if (name === "title") return state.model.resources[type]?.titleLabel || state.model.commonFields.title.label;
@@ -6076,58 +6082,49 @@ function formatObjectArray(items, objectType, compact = false) {
6076
6082
  return '<div class="object-value"><span class="object-value-facts">' + facts.join("") + '</span>' + (!compact && notes.length ? '<small>' + esc(notes.join(" ")) + '</small>' : "") + '</div>';
6077
6083
  }).join("") + '</div>';
6078
6084
  }
6079
- function controlOperationTracking(control) {
6080
- const obligations = resourcesOfType("obligation")
6081
- .map(({ record }) => record)
6082
- .filter((record) => record.status !== "retired" && (record.controlIds || []).includes(control.id));
6083
- if (!obligations.length) {
6084
- return '<span class="operation-tracking evidence"><strong>Not scheduled in Work Queue</strong><small>Show operation with evidence records</small></span>';
6085
- }
6086
- const counts = obligations.reduce((result, obligation) => {
6087
- const status = workQueueScheduleStatus(obligation, control);
6088
- result[status] = (result[status] || 0) + 1;
6089
- return result;
6090
- }, {});
6091
- const scheduleCount = obligations.length + " " + pluralize("schedule", obligations.length);
6092
- if (counts.running === obligations.length) {
6093
- return '<a class="operation-tracking running" href="#/stage/run"><strong>Running in Work Queue</strong><small>' + esc(scheduleCount) + '</small></a>';
6094
- }
6095
- if (counts["waiting-policy"] === obligations.length) {
6096
- return '<a class="operation-tracking waiting" href="#/stage/run"><strong>Waiting for policy approval</strong><small>' + esc(scheduleCount) + ' enabled</small></a>';
6097
- }
6098
- if (counts["waiting-control"] === obligations.length) {
6099
- return '<a class="operation-tracking waiting" href="#/stage/run"><strong>Ready when implemented</strong><small>' + esc(scheduleCount) + ' enabled</small></a>';
6100
- }
6101
- if (counts.paused === obligations.length) {
6102
- return '<a class="operation-tracking paused" href="#/stage/run"><strong>Work Queue paused</strong><small>' + esc(scheduleCount) + '</small></a>';
6103
- }
6104
- const summary = [
6105
- counts.running ? counts.running + " running" : "",
6106
- counts["waiting-policy"] ? counts["waiting-policy"] + " waiting for policy" : "",
6107
- counts["waiting-control"] ? counts["waiting-control"] + " waiting for implementation" : "",
6108
- counts.paused ? counts.paused + " paused" : ""
6109
- ].filter(Boolean).join(" · ");
6110
- return '<a class="operation-tracking mixed" href="#/stage/run"><strong>Work Queue needs attention</strong><small>' + esc(summary) + '</small></a>';
6111
- }
6112
- function workQueueScheduleStatus(obligation, control = null) {
6085
+ function workQueueScheduleStatus(obligation) {
6086
+ if (obligation.status === "proposed") {
6087
+ const pendingRules = resourcesOfType("obligation-rule")
6088
+ .map(({ record }) => record)
6089
+ .filter((record) => record.obligationId === obligation.id && ["proposed", "approved"].includes(record.status));
6090
+ return pendingRules.length === 1 && pendingRules[0].status === "approved" ? "approved" : "proposed";
6091
+ }
6113
6092
  if (obligation.status === "paused") return "paused";
6114
6093
  if (obligation.status !== "active") return obligation.status;
6094
+ const programStatus = state.obligations?.programStatuses?.[obligation.id];
6095
+ if (!programStatus) return "not-evaluated";
6096
+ const rule = state.resources.find(({ record }) => record.id === obligation.activeRuleId)?.record;
6097
+ if (obligation.scheduleMode === "rule" && rule?.status !== "active") {
6098
+ return "proposed";
6099
+ }
6100
+ if (rule?.effectiveAt && new Date(rule.effectiveAt) > new Date()) {
6101
+ const prior = state.resources.find(({ record }) => record.id === rule.supersedesId)?.record;
6102
+ if (prior?.obligationId !== obligation.id || !["active", "retired"].includes(prior.status)) {
6103
+ return "waiting-schedule-effective";
6104
+ }
6105
+ }
6115
6106
  const asOf = state.obligations?.asOf || new Date().toISOString().slice(0, 10);
6116
6107
  const policies = (obligation.policyIds || []).map((id) => state.resources.find(({ record }) => record.id === id)?.record);
6117
- if (!policies.every((policy) => policy?.type === "policy" && policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf)) {
6118
- return "waiting-policy";
6119
- }
6108
+ if (policies.some((policy) => policy?.type !== "policy" || !["approved", "active"].includes(policy.status))) return "waiting-policy-approval";
6109
+ if (policies.some((policy) => policy.status !== "active")) return "waiting-policy-activation";
6110
+ if (policies.some((policy) => !policy.effectiveOn || policy.effectiveOn > asOf)) return "waiting-policy-effective";
6120
6111
  const linkedControls = (obligation.controlIds || []).map((id) => state.resources.find(({ record }) => record.id === id)?.record);
6121
- const implemented = control
6122
- ? control.status === "implemented"
6123
- : !linkedControls.length || linkedControls.some((record) => record?.type === "control" && record.status === "implemented");
6124
- return implemented ? "running" : "waiting-control";
6112
+ const implemented = !linkedControls.length || linkedControls.some((record) => record?.type === "control" && record.status === "implemented");
6113
+ if (!implemented) return "waiting-control";
6114
+ return programStatus === "accepted" ? "running" : "waiting-prerequisites";
6125
6115
  }
6126
6116
  function obligationWorkQueueStatus(obligation) {
6127
6117
  const status = workQueueScheduleStatus(obligation);
6118
+ if (status === "proposed") return '<span class="operation-tracking"><strong>Proposed</strong><small>Review schedule before activation</small></span>';
6119
+ if (status === "approved") return '<span class="operation-tracking"><strong>Approved</strong><small>Activate schedule before operation</small></span>';
6128
6120
  if (status === "running") return '<span class="operation-tracking running"><strong>Running</strong><small>Enabled and policy effective</small></span>';
6129
- if (status === "waiting-policy") return '<span class="operation-tracking waiting"><strong>Waiting for policy</strong><small>Schedule enabled</small></span>';
6121
+ if (status === "waiting-policy-approval") return '<span class="operation-tracking waiting"><strong>Waiting for policy approval</strong><small>Schedule enabled</small></span>';
6122
+ if (status === "waiting-policy-activation") return '<span class="operation-tracking waiting"><strong>Waiting for policy activation</strong><small>Policy approved</small></span>';
6123
+ if (status === "waiting-policy-effective") return '<span class="operation-tracking waiting"><strong>Waiting for effective date</strong><small>Policy active</small></span>';
6124
+ if (status === "waiting-schedule-effective") return '<span class="operation-tracking waiting"><strong>Waiting for schedule start</strong><small>Approved rule activates at its effective time</small></span>';
6130
6125
  if (status === "waiting-control") return '<span class="operation-tracking waiting"><strong>Waiting for control</strong><small>Implement a linked control</small></span>';
6126
+ if (status === "waiting-prerequisites") return '<span class="operation-tracking waiting"><strong>Waiting for prerequisites</strong><small>Check owner and governed content</small></span>';
6127
+ if (status === "not-evaluated") return '<span class="operation-tracking"><strong>Not assessed here</strong><small>Outside the selected program</small></span>';
6131
6128
  if (status === "paused") return '<span class="operation-tracking paused"><strong>Paused</strong><small>Schedule disabled</small></span>';
6132
6129
  return '<span class="operation-tracking"><strong>' + esc(properCase(status)) + '</strong></span>';
6133
6130
  }