filegrc 0.16.2 → 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.2",
3
+ "version": "0.16.3",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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,
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"];
@@ -3088,7 +3089,6 @@ function renderList(main, type, params = new URLSearchParams()) {
3088
3089
  const fields = [...new Set([
3089
3090
  "title",
3090
3091
  ...(definition.listFields || []),
3091
- ...(type === "control" ? ["$operationTracking"] : []),
3092
3092
  ...(type === "obligation" ? ["$workQueueStatus"] : [])
3093
3093
  ])].filter((name) => name !== "title");
3094
3094
  const modelFields = { ...state.model.commonFields, ...definition.fields };
@@ -3131,7 +3131,7 @@ function renderList(main, type, params = new URLSearchParams()) {
3131
3131
  const start = (pageNumber - 1) * LIST_PAGE_SIZE;
3132
3132
  const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
3133
3133
  main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
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 === "$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>';
3135
3135
  pagination.hidden = totalPages === 1;
3136
3136
  previous.disabled = pageNumber === 1;
3137
3137
  next.disabled = pageNumber === totalPages;
@@ -5972,7 +5972,6 @@ function setNavigationGroupOpen(group, open) {
5972
5972
  function groupTitle(id) { return state.model.groups.find((group) => group.id === id)?.title || "Program"; }
5973
5973
  function fieldDefinition(type, name) { return state.model.resources[type]?.fields?.[name] || state.model.commonFields[name]; }
5974
5974
  function fieldLabel(type, name) {
5975
- if (name === "$operationTracking") return "Operation tracking";
5976
5975
  if (name === "$workQueueStatus") return "Work Queue";
5977
5976
  if (type === "obligation" && name === "status") return "Configuration";
5978
5977
  if (name === "title") return state.model.resources[type]?.titleLabel || state.model.commonFields.title.label;
@@ -6083,58 +6082,49 @@ function formatObjectArray(items, objectType, compact = false) {
6083
6082
  return '<div class="object-value"><span class="object-value-facts">' + facts.join("") + '</span>' + (!compact && notes.length ? '<small>' + esc(notes.join(" ")) + '</small>' : "") + '</div>';
6084
6083
  }).join("") + '</div>';
6085
6084
  }
6086
- function controlOperationTracking(control) {
6087
- const obligations = resourcesOfType("obligation")
6088
- .map(({ record }) => record)
6089
- .filter((record) => record.status !== "retired" && (record.controlIds || []).includes(control.id));
6090
- if (!obligations.length) {
6091
- return '<span class="operation-tracking evidence"><strong>Not scheduled in Work Queue</strong><small>Show operation with evidence records</small></span>';
6092
- }
6093
- const counts = obligations.reduce((result, obligation) => {
6094
- const status = workQueueScheduleStatus(obligation, control);
6095
- result[status] = (result[status] || 0) + 1;
6096
- return result;
6097
- }, {});
6098
- const scheduleCount = obligations.length + " " + pluralize("schedule", obligations.length);
6099
- if (counts.running === obligations.length) {
6100
- return '<a class="operation-tracking running" href="#/stage/run"><strong>Running in Work Queue</strong><small>' + esc(scheduleCount) + '</small></a>';
6101
- }
6102
- if (counts["waiting-policy"] === obligations.length) {
6103
- return '<a class="operation-tracking waiting" href="#/stage/run"><strong>Waiting for policy approval</strong><small>' + esc(scheduleCount) + ' enabled</small></a>';
6104
- }
6105
- if (counts["waiting-control"] === obligations.length) {
6106
- return '<a class="operation-tracking waiting" href="#/stage/run"><strong>Ready when implemented</strong><small>' + esc(scheduleCount) + ' enabled</small></a>';
6107
- }
6108
- if (counts.paused === obligations.length) {
6109
- return '<a class="operation-tracking paused" href="#/stage/run"><strong>Work Queue paused</strong><small>' + esc(scheduleCount) + '</small></a>';
6110
- }
6111
- const summary = [
6112
- counts.running ? counts.running + " running" : "",
6113
- counts["waiting-policy"] ? counts["waiting-policy"] + " waiting for policy" : "",
6114
- counts["waiting-control"] ? counts["waiting-control"] + " waiting for implementation" : "",
6115
- counts.paused ? counts.paused + " paused" : ""
6116
- ].filter(Boolean).join(" · ");
6117
- return '<a class="operation-tracking mixed" href="#/stage/run"><strong>Work Queue needs attention</strong><small>' + esc(summary) + '</small></a>';
6118
- }
6119
- 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
+ }
6120
6092
  if (obligation.status === "paused") return "paused";
6121
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
+ }
6122
6106
  const asOf = state.obligations?.asOf || new Date().toISOString().slice(0, 10);
6123
6107
  const policies = (obligation.policyIds || []).map((id) => state.resources.find(({ record }) => record.id === id)?.record);
6124
- if (!policies.every((policy) => policy?.type === "policy" && policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf)) {
6125
- return "waiting-policy";
6126
- }
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";
6127
6111
  const linkedControls = (obligation.controlIds || []).map((id) => state.resources.find(({ record }) => record.id === id)?.record);
6128
- const implemented = control
6129
- ? control.status === "implemented"
6130
- : !linkedControls.length || linkedControls.some((record) => record?.type === "control" && record.status === "implemented");
6131
- 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";
6132
6115
  }
6133
6116
  function obligationWorkQueueStatus(obligation) {
6134
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>';
6135
6120
  if (status === "running") return '<span class="operation-tracking running"><strong>Running</strong><small>Enabled and policy effective</small></span>';
6136
- 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>';
6137
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>';
6138
6128
  if (status === "paused") return '<span class="operation-tracking paused"><strong>Paused</strong><small>Schedule disabled</small></span>';
6139
6129
  return '<span class="operation-tracking"><strong>' + esc(properCase(status)) + '</strong></span>';
6140
6130
  }