diff --git a/package-lock.json b/package-lock.json index 8179554..47cb542 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.21.3", + "version": "0.21.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.21.3", + "version": "0.21.4", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.21.3", + "version": "0.21.4", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.21.3", + "version": "0.21.4", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 10416fd..e84fb64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.21.3", + "version": "0.21.4", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 4dc04f4..c66be8d 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.21.3", + "version": "0.21.4", "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 e57525f..b8913ac 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.21.3", + version: "0.21.4", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.21.3", + version: "0.21.4", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 3ff6007..10f62a2 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.21.3", + "version": "0.21.4", "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 3fdc338..0c73690 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.21.3", + "version": "0.21.4", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/workflow-history-integrity.js b/packages/filegrc/src/workflow-history-integrity.js index 1a78a84..6a04a71 100644 --- a/packages/filegrc/src/workflow-history-integrity.js +++ b/packages/filegrc/src/workflow-history-integrity.js @@ -842,7 +842,7 @@ function canonicalJson(value, field = null, revisionMap = false) { async function compareMarkdownAtRevision(loaded, entry, historical, diagnostics, occurrenceId = null) { for (const markdown of markdownEntries(loaded.model, entry.record)) { const historicalPath = markdownEntries(loaded.model, historical.record) - .find(({ slot }) => slot === markdown.slot)?.path; + .find(({ name }) => name === markdown.name)?.path; const committed = historicalPath ? getFileAtRevision(loaded.root, historical.commit, `data/${historicalPath}`) : null; diff --git a/packages/filegrc/test/workflow-proof.test.js b/packages/filegrc/test/workflow-proof.test.js index 0ca0fef..c5e52a9 100644 --- a/packages/filegrc/test/workflow-proof.test.js +++ b/packages/filegrc/test/workflow-proof.test.js @@ -5,6 +5,8 @@ import { join } from "node:path"; import test from "node:test"; import { loadWorkspace, updateResource, validateWorkspace } from "../src/index.js"; import { validateWorkflowHistoryIntegrity } from "../src/workflow-history-integrity.js"; +import { markdownEntries } from "../src/resource-markdown.js"; +import { loadModel, SUPPORTED_MODEL_VERSIONS } from "../model/index.js"; import { workflowProofIds } from "../src/workflow-proof.js"; import { makeComprehensiveWorkspace } from "./fixtures.js"; import { commitWorkspaceFiles, initializeGitWorkspace, writeJson } from "./helpers.js"; @@ -68,9 +70,87 @@ test("mutable source context evolves while finalized proof stays bound to Git", await writeJson(evidence.path, { ...evidence.record, sourceResourceIds: [] }); assert.ok((await historyDiagnostics()).some(({ message }) => message.includes(`Proof record "${evidence.record.id}" differs`))); await writeJson(evidence.path, evidence.record); + const companion = markdownEntries(loaded.model, completion.record)[0]; + const companionPath = join(root, "data", companion.path); + const originalCompanion = await readFile(companionPath, "utf8").catch(() => null); + await writeFile(companionPath, "# Changed single-slot proof\n"); + assert.ok((await historyDiagnostics()).some(({ code, path }) => code === "rewritten-finalized-content" && path === `data/${companion.path}`)); + if (originalCompanion === null) await rm(companionPath); + else await writeFile(companionPath, originalCompanion); const attachment = evidence.record.filePaths?.[0]; assert.ok(attachment); const original = await readFile(join(root, "data", attachment)); await writeFile(join(root, "data", attachment), Buffer.concat([original, Buffer.from("rewritten")])); assert.ok((await historyDiagnostics()).some(({ message }) => /attachment/i.test(message))); }); + + +test("supported models identify every meeting companion by name", () => { + for (const version of SUPPORTED_MODEL_VERSIONS) { + const model = loadModel(version); + const multiSlotTypes = Object.entries(model.resources) + .filter(([, definition]) => Object.keys(definition.markdown || {}).length > 1) + .map(([type]) => type); + assert.deepEqual(multiSlotTypes, ["meeting"], `model v${version}`); + const entries = markdownEntries(model, { type: "meeting", id: "meeting-example" }); + assert.deepEqual(entries.map(({ name }) => name), ["agenda", "minutes"]); + assert.equal(entries.find(({ primary }) => primary).name, "minutes"); + assert.match(entries[0].path, /-agenda\.md$/); + assert.match(entries[1].path, /meeting-example\.md$/); + } +}); + +for (const { version, extended = false } of [{ version: "9" }, { version: "13" }, { version: "13", extended: true }]) { + test(`committed meeting proof preserves each Markdown companion in model v${version}${extended ? " with extended slots" : ""}`, async (t) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-meeting-proof-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, version); + const extendModel = (loaded) => { + if (extended) loaded.model.resources.meeting.markdown = { + followUpPath: { label: "Follow-up", primary: true }, + minutes: { label: "Minutes" }, + agenda: { label: "Agenda" } + }; + return loaded; + }; + const loaded = extendModel(await loadWorkspace(root)); + const meeting = loaded.entries.find(({ record }) => record.type === "meeting"); + const occurrence = loaded.entries.find(({ record }) => record.type === "obligation-occurrence"); + const obligation = loaded.entries.find(({ record }) => record.type === "obligation"); + obligation.record.activityType = "meeting"; + await writeJson(obligation.path, obligation.record); + occurrence.record.members[0].resourceId = loaded.workspace.id; + occurrence.record.members[0].completionResourceIds = [meeting.record.id]; + await writeJson(occurrence.path, occurrence.record); + const companions = markdownEntries(loaded.model, meeting.record); + const originals = new Map(companions.map(({ path, name }) => [path, `# ${name}\nDistinct ${name} proof.\n`])); + for (const [path, content] of originals) await writeFile(join(root, "data", path), content); + await initializeGitWorkspace(root, { message: "Finalize meeting and occurrence with distinct companions" }); + const diagnostics = async () => { + const result = []; + await validateWorkflowHistoryIntegrity(extendModel(await loadWorkspace(root)), result); + return result; + }; + assert.deepEqual(await diagnostics(), []); + if (version === "9") assert.deepEqual((await validateWorkspace(root)).diagnostics, []); + for (const { path } of companions) { + const absolute = join(root, "data", path); + await writeFile(absolute, "# Changed proof\n"); + assert.ok((await diagnostics()).some((item) => item.code === "rewritten-finalized-content" && item.path === `data/${path}`)); + await writeFile(absolute, originals.get(path)); + await rm(absolute); + assert.ok((await diagnostics()).some((item) => item.code === "rewritten-finalized-content" && item.path === `data/${path}`)); + await writeFile(absolute, originals.get(path)); + } + assert.deepEqual(await diagnostics(), []); + // An optional companion absent at finalization cannot be added afterward. + const agenda = companions.find(({ name }) => name === "agenda"); + await rm(join(root, "data", agenda.path)); + // Start a separate history so the absence is part of the first final version. + await rm(join(root, ".git"), { recursive: true, force: true }); + await initializeGitWorkspace(root, { message: "Finalize meeting without optional agenda" }); + assert.deepEqual(await diagnostics(), []); + await writeFile(join(root, "data", agenda.path), originals.get(agenda.path)); + assert.ok((await diagnostics()).some((item) => item.code === "rewritten-finalized-content" && item.path === `data/${agenda.path}`)); + }); +}