Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc-monorepo",
"version": "0.16.1",
"version": "0.16.2",
"private": true,
"workspaces": [
"packages/filegrc",
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions packages/create-filegrc/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/template/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/filegrc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc",
"version": "0.16.1",
"version": "0.16.2",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
6 changes: 5 additions & 1 deletion packages/filegrc/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
18 changes: 18 additions & 0 deletions packages/filegrc/src/collection-scope.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 48 additions & 1 deletion packages/filegrc/src/program-readiness.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/filegrc/src/web.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
94 changes: 94 additions & 0 deletions packages/filegrc/test/program-readiness.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
calculateProgramProgress,
collectionReviewReadinessItem,
controlOversightEligible,
prioritizeReviewDependencies,
reportingRouteSetItem,
selectedAuditWindow,
selectedProgressWindow
Expand All @@ -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: [
Expand Down
Loading