From 2019a7e420330d37847cc00d9b3deea43c49c881 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Wed, 23 Sep 2026 19:27:43 -0500 Subject: [PATCH] Release 0.16.2 with dependency-aware program actions --- package-lock.json | 8 +- package.json | 2 +- packages/create-filegrc/package.json | 2 +- packages/create-filegrc/src/index.js | 4 +- packages/create-filegrc/template/package.json | 2 +- packages/filegrc/package.json | 2 +- packages/filegrc/src/cli.js | 6 +- packages/filegrc/src/collection-scope.js | 18 ++++ packages/filegrc/src/program-readiness.js | 49 +++++++++- packages/filegrc/src/web.js | 7 ++ .../filegrc/test/program-readiness.test.js | 94 +++++++++++++++++++ 11 files changed, 182 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0ead1d9..d61c224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.16.1", + "version": "0.16.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.16.1", + "version": "0.16.2", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.16.1", + "version": "0.16.2", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.16.1", + "version": "0.16.2", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index bbf5511..9a7d1c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.16.1", + "version": "0.16.2", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 4927e2e..c7fefc2 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.16.1", + "version": "0.16.2", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index 4ada2cd..92995e1 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -523,13 +523,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.16.1", + version: "0.16.2", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.16.1", + version: "0.16.2", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index b116adc..d8db620 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.16.1", + "version": "0.16.2", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 0875c8f..de9d0d4 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.16.1", + "version": "0.16.2", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index 18c2e6e..60be027 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -1846,7 +1846,11 @@ function buildProgramPathResult(model, readiness, auditReadiness) { nextActions: (current?.items || []).filter((item) => item.status === "action") }; }); - const currentStep = stages.find((stage) => !["complete", "operating", "management-ready"].includes(stage.status)) || stages.at(-1); + const recommendedStage = readiness.stages.find((stage) => stage.items.includes(readiness.firstAction)); + const recommendedId = recommendedStage?.id === "operation" ? "run" : recommendedStage?.id; + const currentStep = stages.find(({ id }) => id === recommendedId) + || stages.find((stage) => !["complete", "operating", "management-ready"].includes(stage.status)) + || stages.at(-1); return { schemaVersion: 1, dataModelVersion: String(model.modelVersion), diff --git a/packages/filegrc/src/collection-scope.js b/packages/filegrc/src/collection-scope.js index 13f1366..e1d33eb 100644 --- a/packages/filegrc/src/collection-scope.js +++ b/packages/filegrc/src/collection-scope.js @@ -65,6 +65,24 @@ export function scopedCollectionRecords(loaded, resourceType, program) { )); } +// These records can change which members enter a collection even when they +// are not part of its current revision inputs yet. +const COLLECTION_MEMBERSHIP_SOURCES = { + person: ["workspace", "program", "appointment", "team", "system", "component", "vendor", "control", "policy", "document", "obligation"], + vendor: ["program", "system", "component", "audit"], + component: ["program", "system", "control"], + "complementary-control": ["program", "system", "component", "control"], + "information-type": ["program", "system", "component", "vendor"], + "retention-schedule-item": ["program", "system", "component", "vendor", "control", "information-type", "source-coverage"], + framework: ["program"], + system: ["program"], + control: ["program"] +}; + +export function collectionMembershipSourceTypes(resourceType) { + return COLLECTION_MEMBERSHIP_SOURCES[resourceType] || []; +} + export function retentionScheduleReviewScope(loaded) { const programs = loaded.resources.filter((record) => ( record.type === "program" && record.status !== "retired" diff --git a/packages/filegrc/src/program-readiness.js b/packages/filegrc/src/program-readiness.js index 2cabb0c..9c6103b 100644 --- a/packages/filegrc/src/program-readiness.js +++ b/packages/filegrc/src/program-readiness.js @@ -3,6 +3,7 @@ import { modelSupports } from "../model/index.js"; import { applicabilityReviewIsCurrent } from "./applicability-scope.js"; import { assessRequiredAppointments } from "./appointments.js"; import { assessCollectionReviews } from "./collection-review.js"; +import { collectionMembershipSourceTypes, collectionRevisionInputs, collectionScopeRevisionFacts } from "./collection-scope.js"; import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js"; import { coverageEnd, coverageStart } from "./coverage.js"; import { planObligations } from "./obligations.js"; @@ -233,6 +234,7 @@ export async function assessProgramReadiness(input, options = {}) { policyStage, controlStage ]; + const prioritizedSetupItems = prioritizeReviewDependencies(evidenceGateStages, loaded, program); for (const current of evidenceGateStages) finalizeStage(current); const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0); const stages = [ @@ -260,7 +262,8 @@ export async function assessProgramReadiness(input, options = {}) { const items = stages.flatMap((current) => current.items); const managedItems = items.filter((current) => !["info", "later"].includes(current.status)); const complete = managedItems.filter((current) => current.status === "complete").length; - const firstAction = items.find((current) => current.status === "action") || null; + const firstAction = [...prioritizedSetupItems, ...stages.at(-1).items] + .find((current) => current.status === "action") || null; const target = { programId: program?.id || null, goal: program?.assuranceGoal || "none", @@ -1966,6 +1969,50 @@ function finalizeStage(current) { current.status = current.counts.action ? "action" : current.counts.later ? "later" : "complete"; } +// A collection confirmation can be performed early, but an unfinished input +// would make it stale again. Derive the ordering from the same revision inputs +// used to decide whether that confirmation is current. +export function prioritizeReviewDependencies(stages, loaded, program) { + const items = stages.flatMap(({ items }) => items); + const edges = new Map(items.map((_, index) => [index, new Set()])); + const indegree = items.map(() => 0); + for (const [reviewIndex, review] of items.entries()) { + if (review.status !== "action" || !review.id.startsWith("collection-review-")) continue; + const resourceType = review.id.slice("collection-review-".length); + if (!loaded.model.collectionReviews?.[resourceType]) continue; + const inputTypes = new Set(collectionRevisionInputs(loaded, resourceType, program) + .map(({ record }) => record.type)); + for (const type of collectionMembershipSourceTypes(resourceType)) inputTypes.add(type); + const scopeFacts = collectionScopeRevisionFacts(loaded, resourceType, program); + if (Object.keys(scopeFacts).some((key) => key !== "programId")) inputTypes.add("program"); + for (const key of Object.keys(scopeFacts)) { + if (!key.endsWith("Ids")) continue; + const type = key.slice(0, -3).replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + if (loaded.model.resources?.[type]) inputTypes.add(type); + } + for (const [sourceIndex, source] of items.entries()) { + if (sourceIndex === reviewIndex || source.status !== "action" + || source.id.startsWith("collection-review-") + || !inputTypes.has(source.resourceType)) continue; + edges.get(sourceIndex).add(reviewIndex); + indegree[reviewIndex]++; + } + } + const remaining = new Set(items.map((_, index) => index)); + const ordered = []; + while (remaining.size) { + const next = [...remaining].find((index) => indegree[index] === 0); + if (next === undefined) break; + remaining.delete(next); + ordered.push(items[next]); + for (const dependent of edges.get(next)) indegree[dependent]--; + } + if (ordered.length !== items.length) return items; + const rank = new Map(ordered.map((item, index) => [item, index])); + for (const stage of stages) stage.items.sort((left, right) => rank.get(left) - rank.get(right)); + return ordered; +} + function item(id, status, title, message, resource = {}, details = {}) { return { id, diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index c5a38d4..f02c976 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -589,6 +589,13 @@ function readinessOverview() { } function nextProgramStageHref() { + const firstAction = state.programReadiness?.firstAction; + const recommendedStage = state.programReadiness?.stages?.find((stage) => stage.items.some((item) => ( + item.id === firstAction?.id && item.resourceId === firstAction?.resourceId + ))); + if (recommendedStage) { + return "#/stage/" + encodeURIComponent(recommendedStage.id === "operation" ? "run" : recommendedStage.id); + } const nextStage = READINESS_STAGES.find((stage) => { const progress = stageProgress(stage); return progress.complete < progress.total; diff --git a/packages/filegrc/test/program-readiness.test.js b/packages/filegrc/test/program-readiness.test.js index 0e44ef5..4580bf3 100644 --- a/packages/filegrc/test/program-readiness.test.js +++ b/packages/filegrc/test/program-readiness.test.js @@ -10,6 +10,7 @@ import { calculateProgramProgress, collectionReviewReadinessItem, controlOversightEligible, + prioritizeReviewDependencies, reportingRouteSetItem, selectedAuditWindow, selectedProgressWindow @@ -31,6 +32,99 @@ import { makeComprehensiveWorkspace } from "./fixtures.js"; const execute = (executable, args) => executeCli(runCli, executable, args); const cli = fileURLToPath(new URL("../bin/filegrc.js", import.meta.url)); +test("orders collection confirmations after unfinished revision inputs while leaving independent reviews actionable", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-review-order-")); + context.after(() => import("node:fs/promises").then(({ rm }) => rm(root, { recursive: true, force: true }))); + await makeComprehensiveWorkspace(root, "11"); + const loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ type }) => type === "program"); + const stage = { items: [ + { id: "collection-review-complementary-control", status: "action", resourceType: "complementary-control" }, + { id: "collection-review-classification", status: "action", resourceType: "classification" }, + { id: "collection-review-framework", status: "action", resourceType: "framework" }, + { id: "control-work", status: "action", resourceType: "control" }, + { id: "system-work", status: "action", resourceType: "system" }, + { id: "program-work", status: "action", resourceType: "program" } + ] }; + prioritizeReviewDependencies([stage], loaded, program); + assert.deepEqual(stage.items.map(({ id }) => id), [ + "collection-review-classification", + "control-work", + "system-work", + "program-work", + "collection-review-complementary-control", + "collection-review-framework" + ]); + assert.ok(stage.items.every(({ status }) => status === "action")); + + const crossStage = [ + { items: [ + { id: "collection-review-retention-schedule-item", status: "action", resourceType: "retention-schedule-item" }, + { id: "collection-review-classification", status: "action", resourceType: "classification" } + ] }, + { items: [{ id: "control-work", status: "action", resourceType: "control" }] } + ]; + const crossLoaded = { + ...loaded, + resources: [...loaded.resources, { + id: "retention-control-test", + type: "retention-schedule-item", + sourceResourceIds: [program.controlIds[0]] + }] + }; + const orderedAcrossStages = prioritizeReviewDependencies(crossStage, crossLoaded, program); + assert.deepEqual(orderedAcrossStages.map(({ id }) => id), [ + "collection-review-classification", + "control-work", + "collection-review-retention-schedule-item" + ]); + + const system = loaded.resources.find(({ type }) => type === "system"); + const changed = { + ...loaded, + resources: loaded.resources.map((record) => record.id === system.id + ? { ...record, status: "planned" } + : record) + }; + const readiness = await assessProgramReadiness(changed, { asOf: "2026-09-12" }); + const scopeActions = readiness.stages.find(({ id }) => id === "scope").items + .filter(({ status }) => status === "action").map(({ id }) => id); + assert.ok(scopeActions.indexOf("service-boundary") < scopeActions.indexOf("collection-review-framework")); + assert.ok(scopeActions.indexOf("service-boundary") < scopeActions.indexOf("collection-review-person")); + assert.ok(scopeActions.indexOf("collection-review-classification") < scopeActions.indexOf("service-boundary")); + + const unscopedProgram = { ...program, systemIds: [], controlIds: [] }; + const unscoped = await assessProgramReadiness({ + ...loaded, + resources: loaded.resources.map((record) => record.id === program.id ? unscopedProgram : record) + }, { asOf: "2026-09-12" }); + const unscopedActions = unscoped.stages.find(({ id }) => id === "scope").items + .filter(({ status }) => status === "action").map(({ id }) => id); + assert.ok(unscopedActions.indexOf("service-boundary") < unscopedActions.indexOf("collection-review-information-type")); +}); + +test("CLI and browser state recommend Control work before the dependent collection review", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-review-order-surfaces-")); + context.after(() => import("node:fs/promises").then(({ rm }) => rm(root, { recursive: true, force: true }))); + await makeComprehensiveWorkspace(root, "11"); + const cliReadiness = JSON.parse((await execute(process.execPath, [ + cli, "program-readiness", "--root", root, "--json" + ])).stdout); + const cliActions = cliReadiness.stages.find(({ id }) => id === "controls").items + .filter(({ status }) => status === "action").map(({ id }) => id); + assert.ok(cliActions.indexOf("control-control-example") < cliActions.indexOf("collection-review-complementary-control")); + const path = JSON.parse((await execute(process.execPath, [ + cli, "program-path", "--root", root, "--json" + ])).stdout); + assert.deepEqual(path.stages.find(({ id }) => id === "controls").nextActions.map(({ id }) => id), cliActions); + const running = await serveWorkspace(root, { port: 0 }); + context.after(() => new Promise((resolve) => running.server.close(resolve))); + const browserState = await fetch(`${running.url}/api/state`).then((response) => response.json()); + const browserActions = browserState.programReadiness.stages.find(({ id }) => id === "controls").items + .filter(({ status }) => status === "action").map(({ id }) => id); + assert.deepEqual(browserActions, cliActions); +}); + test("counts distinct readiness actions until the Type 2 window starts", () => { const input = { stages: [