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.21.13",
"version": "0.21.14",
"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.21.13",
"version": "0.21.14",
"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 @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) {
async function writeMinimalLockfile(target, name, versionRange) {
const lock = {
name,
version: "0.21.13",
version: "0.21.14",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
version: "0.21.13",
version: "0.21.14",
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.21.13",
"version": "0.21.14",
"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.21.13",
"version": "0.21.14",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
18 changes: 13 additions & 5 deletions packages/filegrc/src/collection-review.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { firstOperatingTasks } from "./operating-preparation.js";
import { resourceIndex } from "./resource-index.js";
import { scheduleSetIssues, scheduleReviewerConflicts } from "./schedule-set.js";
import { calculationMemo } from "./calculation-cache.js";
import { calculationMemo, withAssessmentCache } from "./calculation-cache.js";
import { readFile } from "node:fs/promises";
import { modelSupports } from "../model/index.js";
import {
Expand Down Expand Up @@ -32,13 +32,13 @@ export function assessCollectionReviews(input, options = {}) {
? input
: null;
if (!loaded) throw new Error("Collection review assessment requires a loaded workspace.");
return Object.keys(loaded.model.collectionReviews || {})
.map((resourceType) => assessCollectionReview(loaded, resourceType, options));
return withAssessmentCache(() => Object.keys(loaded.model.collectionReviews || {})
.map((resourceType) => assessCollectionReview(loaded, resourceType, options)));
}

export function assessCollectionReview(loaded, resourceType, options = {}) {
return calculationMemo(loaded, "collection-review", [resourceType, options.programId], () =>
assessCollectionReviewUncached(loaded, resourceType, options));
return withAssessmentCache(() => calculationMemo(loaded, "collection-review", [resourceType, options.programId], () =>
assessCollectionReviewUncached(loaded, resourceType, options)));
}

function assessCollectionReviewUncached(loaded, resourceType, options) {
Expand Down Expand Up @@ -192,6 +192,10 @@ export function retentionScheduleIsAuthoritative(loaded, program) {
}

export async function scaffoldCollectionReview(input = process.cwd(), options = {}) {
return withAssessmentCache(() => scaffoldCollectionReviewUncached(input, options));
}

async function scaffoldCollectionReviewUncached(input, options) {
const loaded = await loadWorkspace(input);
const program = resolveProgram(loaded, options.programId);
const resourceType = requiredType(loaded, options.resourceType);
Expand Down Expand Up @@ -240,6 +244,10 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
}

export async function planCollectionReview(input = process.cwd(), options = {}) {
return withAssessmentCache(() => planCollectionReviewUncached(input, options));
}

async function planCollectionReviewUncached(input, options) {
const loaded = await loadWorkspace(input);
const program = resolveProgram(loaded, options.programId);
const resourceType = requiredType(loaded, options.resourceType);
Expand Down
9 changes: 9 additions & 0 deletions packages/filegrc/src/obligations.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withAssessmentCache } from "./calculation-cache.js";
import { registerGovernedLookup, governedContentBaseline, governedContentOperatingOrigin } from "./governed-content-baseline.js";
import { recordedCorrectionPopulation, currentSnapshotPopulation, samePopulation } from "./obligation-snapshot.js";
import { getGitSummary } from "./git.js";
Expand Down Expand Up @@ -73,6 +74,10 @@ const SCAFFOLDED_COMPLETION_TYPES = new Set([
]);

export function planObligations(resources, options = {}) {
return withAssessmentCache(() => planObligationsUncached(resources, options));
}

function planObligationsUncached(resources, options) {
const records = resources.map((item) => item?.record ?? item).filter(Boolean);
const workspace = records.find((record) => record.type === "workspace");
const programs = records.filter((record) => record.type === "program" && record.status !== "retired");
Expand Down Expand Up @@ -2191,6 +2196,10 @@ function completionFallsInWindow(record, window, timezone = "UTC", model) {
}

export function assessCompletionForObligation(record, obligation, loaded, options = {}) {
return withAssessmentCache(() => assessCompletionForObligationUncached(record, obligation, loaded, options));
}

function assessCompletionForObligationUncached(record, obligation, loaded, options) {
if (loaded.historicalCommit) obligation = loaded.resources.find(({ id }) => id === obligation.id) || obligation;
const activity = obligationActivity(loaded.model, obligation);
const byId = registerGovernedLookup(new Map(loaded.resources.map((current) => [current.id, current])), loaded);
Expand Down
9 changes: 9 additions & 0 deletions packages/filegrc/src/operating-preparation.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withAssessmentCache } from "./calculation-cache.js";
import { assessProgramBaseline, currentProcedureContent } from "./operating-baseline.js";
import { selectScopedCollectionRecords } from "./collection-scope.js";
import { readFile } from "node:fs/promises";
Expand Down Expand Up @@ -55,6 +56,10 @@ async function approvedProcedure(loaded, record, asOf, byId) {
}

export async function assessOperatingPreparation(loaded, program, selectedControls, asOf) {
return withAssessmentCache(() => assessOperatingPreparationUncached(loaded, program, selectedControls, asOf));
}

async function assessOperatingPreparationUncached(loaded, program, selectedControls, asOf) {
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
const controls = selectedControls.filter(({ status }) => !["not-applicable", "retired"].includes(status));
const sourceWork = new Map();
Expand Down Expand Up @@ -221,6 +226,10 @@ function schedulePreparationPurpose(model, activityType, declaredPurpose) {
}

export function firstOperatingTasks(loaded, program, options = {}) {
return withAssessmentCache(() => firstOperatingTasksUncached(loaded, program, options));
}

function firstOperatingTasksUncached(loaded, program, options) {
const timezone = loaded.workspace?.timezone || "UTC";
const asOf = options.effectiveOn || currentCalendarDate(timezone);
const selectedIds = options.resourceIds ? new Set(options.resourceIds) : null;
Expand Down
5 changes: 5 additions & 0 deletions packages/filegrc/src/reconciliation.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withAssessmentCache } from "./calculation-cache.js";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
Expand Down Expand Up @@ -122,6 +123,10 @@ function allowsHistoricalTypeTransition(parentModel, currentModel, beforeType, a
}

export async function planReconciliation(input = process.cwd(), options = {}) {
return withAssessmentCache(() => planReconciliationUncached(input, options));
}

async function planReconciliationUncached(input, options) {
const loaded = input?.entries && input?.resources && input?.model
? input
: await loadWorkspace(input);
Expand Down
72 changes: 72 additions & 0 deletions packages/filegrc/test/calculation-cache.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,75 @@ test("a completed app-state read cannot authorize a stale Markdown mutation", as
}), /Content at data\/.* changed after you opened it/);
assert.equal(await readFile(path, "utf8"), changed);
});

test("schedule commands and direct obligation planning bound shared reads and refresh source changes", async (context) => {
const { createRequire, syncBuiltinESMExports } = await import("node:module");
const { hasCalculationCache } = await import("../src/calculation-cache.js");
const { scaffoldCollectionReview, planCollectionReview, applyCollectionReview } = await import("../src/collection-review.js");
const { planObligations } = await import("../src/obligations.js");
const root = await mkdtemp(join(tmpdir(), "filegrc-schedule-cache-"));
context.after(() => rm(root, { recursive: true, force: true }));
await makeComprehensiveWorkspace(root, "13");
const initial = await loadWorkspace(root);
const obligation = initial.entries.find(({ record }) => record.type === "obligation");
const rule = initial.entries.find(({ record }) => record.type === "obligation-rule");
for (let index = 0; index < 20; index++) {
const id = `obligation-shared-${index}`, ruleId = `obligation-rule-shared-${index}`;
await writeFile(join(root, "data", dirname(obligation.relativePath), `${id}.json`), JSON.stringify({
...obligation.record, id, ruleIds: [ruleId], activeRuleId: ruleId
}));
await writeFile(join(root, "data", dirname(rule.relativePath), `${ruleId}.json`), JSON.stringify({
...rule.record, id: ruleId, obligationId: id
}));
}
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" });
git("init", "--initial-branch=main");
git("-c", "user.name=Test User", "-c", "user.email=test@example.test", "add", ".");
git("-c", "user.name=Test User", "-c", "user.email=test@example.test", "commit", "-m", "Create schedule review fixture");
const policy = initial.entries.find(({ record }) => record.type === "policy");
const path = join(initial.root, "data", policy.relativePath.replace(/\.json$/, ".md"));
const fs = createRequire(import.meta.url)("node:fs"), originalRead = fs.readFileSync;
let reads = 0;
fs.readFileSync = function (file, ...args) {
if (String(file) === path) {
reads++;
assert.equal(hasCalculationCache(), true, "shared source reads belong to this invocation");
}
return originalRead.call(this, file, ...args);
};
syncBuiltinESMExports();
try {
const scaffold = await scaffoldCollectionReview(root, { resourceType: "obligation" });
assert.equal(scaffold.scheduleDecisions.length, 21);
assert.ok(reads > 0 && reads <= 2, `schedule scaffold source reads: ${reads}`);
assert.equal(hasCalculationCache(), false);
const payload = { ...scaffold, rationale: "Reviewed every operating schedule and its allowed proof.",
reviewedByIds: ["person-independent-approver-example"],
reviewedOn: (await import("../src/time.js")).currentCalendarDate(initial.workspace.timezone),
scheduleDecisions: scaffold.scheduleDecisions.map((decision) => ({ ...decision, disposition: "adopted", rationale: "Reviewed the current operating rule." })) };
const beforePreview = reads;
const preview = await planCollectionReview(root, payload);
assert.equal(preview.resourceType, "obligation");
assert.ok(reads > beforePreview && reads - beforePreview <= 2, `schedule preview shared reads: ${reads - beforePreview}`);
const beforePlan = reads;
const loaded = await loadWorkspace(root);
planObligations(loaded.resources, { loaded, root, model: loaded.model, asOf: "2026-11-05" });
assert.ok(reads > beforePlan && reads - beforePlan <= 2, `planner shared reads: ${reads - beforePlan}`);
assert.equal(hasCalculationCache(), false);
// A new request must observe a Markdown edit even when handed the same loaded records.
const original = originalRead(path, "utf8");
await writeFile(path, `${original}\nChanged governing security procedure.\n`);
const beforeNext = reads;
planObligations(loaded.resources, { loaded, root, model: loaded.model, asOf: "2026-11-05" });
assert.ok(reads > beforeNext && reads - beforeNext <= 2);
const changed = await scaffoldCollectionReview(root, { resourceType: "obligation" });
assert.notEqual(changed.expectedCollectionRevision, scaffold.expectedCollectionRevision);
const stale = payload;
await assert.rejects(planCollectionReview(root, stale), /changed after it was displayed/);
await assert.rejects(applyCollectionReview(root, { ...stale, confirmed: true }), /changed after it was displayed/);
assert.equal(hasCalculationCache(), false, "failed approval must release the read cache");
} finally {
fs.readFileSync = originalRead;
syncBuiltinESMExports();
}
});
Loading