diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index cc98dbe..50f8045 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -1082,7 +1082,9 @@ export async function runCli(argv = process.argv.slice(2)) { record: mutation.record, programId: flags.program || mutation.record.programId, content: mutation.content, - expectedRevision: expectedRevision(flags, mutation, `obligation-occurrence/${mutation.record.supersedesId || mutation.record.id}`) + expectedRevision: existing || mutation.record.supersedesId + ? expectedRevision(flags, mutation, `obligation-occurrence/${mutation.record.supersedesId || mutation.record.id}`) + : undefined })); if (flags.json) console.log(JSON.stringify(result, null, 2)); else console.log(`${existing ? "Updated" : "Created"} obligation-occurrence/${mutation.record.id}`); diff --git a/packages/filegrc/src/mutation.js b/packages/filegrc/src/mutation.js index 10902de..16a317a 100644 --- a/packages/filegrc/src/mutation.js +++ b/packages/filegrc/src/mutation.js @@ -41,7 +41,7 @@ export function normalizeResourceMutation(value, options = {}) { if (value.content !== undefined && (Array.isArray(value.content) || typeof value.content !== "object" || value.content === null)) { throw new Error("Mutation content must be an object keyed by Markdown slot."); } - if (value.revision !== undefined && typeof value.revision !== "string") { + if (value.revision != null && typeof value.revision !== "string") { throw new Error("Mutation revision must be a string."); } if (options.requireRevision && (typeof value.revision !== "string" || value.revision.length === 0)) { @@ -56,7 +56,7 @@ export function normalizeResourceMutation(value, options = {}) { return { record: value.record, content: value.content, - revision: value.revision, + revision: value.revision ?? undefined, contentRevisions: value.contentRevisions, completionContext: value.completionContext }; diff --git a/packages/filegrc/test/completion-reconciliation.test.js b/packages/filegrc/test/completion-reconciliation.test.js index 1006823..3507a5c 100644 --- a/packages/filegrc/test/completion-reconciliation.test.js +++ b/packages/filegrc/test/completion-reconciliation.test.js @@ -298,3 +298,35 @@ test("completion preserves exclusions and the authoritative expected-member coun assert.equal(result.linked.members.find(({ resourceId }) => resourceId === "asset-example").disposition, "not-applicable"); assert.equal(result.linked.reviewedByIds, undefined); }); + +test("CLI reconciliation consumes create, update, and supersession scaffolds with revision protection", async (context) => { + const f = await fixture(context); + const proof = join(f.root, "data", "control-activities", `${f.record.id}.json`); + await writeJson(proof, f.record); + const args = [new URL("../bin/filegrc.js", import.meta.url).pathname]; + const cli = async (...command) => executeCli(runCli, process.execPath, [...args, ...command, "--root", f.root, "--json"]); + const payload = join(f.root, "reconciliation.json"); + const scaffold = JSON.parse((await cli("reconcile-obligation", f.obligation.id, "--scaffold", "--window-start", "2026-07-05", "--as-of", "2026-07-07")).stdout); + assert.equal(scaffold.operation, "create"); + assert.equal(scaffold.revision, null); + await writeJson(payload, scaffold); + await cli("reconcile-obligation", f.obligation.id, payload); + const update = JSON.parse((await cli("reconcile-obligation", f.obligation.id, "--scaffold", "--window-start", "2026-07-05")).stdout); + assert.equal(update.operation, "update"); + assert.equal(typeof update.revision, "string"); + await writeJson(payload, { ...update, revision: null }); + await assert.rejects(cli("reconcile-obligation", f.obligation.id, payload), /revision.*required/); + await writeJson(payload, { ...update, revision: "stale" }); + await assert.rejects(cli("reconcile-obligation", f.obligation.id, payload), /changed/); + update.record = { ...update.record, status: "reconciled", conclusion: "complete", reviewedByIds: ["person-example"], reconciledAt: "2026-07-07T16:30:00Z" }; + await writeJson(payload, update); + await cli("reconcile-obligation", f.obligation.id, payload); + const correction = JSON.parse((await cli("reconcile-obligation", f.obligation.id, "--scaffold", "--window-start", "2026-07-05", "--correct-finalized")).stdout); + assert.equal(correction.operation, "supersede"); + await writeJson(payload, { ...correction, revision: null }); + await assert.rejects(cli("reconcile-obligation", f.obligation.id, payload), /revision.*required/); + await writeJson(payload, { ...correction, revision: "stale" }); + await assert.rejects(cli("reconcile-obligation", f.obligation.id, payload), /changed/); + await writeJson(payload, correction); + await cli("reconcile-obligation", f.obligation.id, payload); +}); diff --git a/packages/filegrc/test/mutation.test.js b/packages/filegrc/test/mutation.test.js new file mode 100644 index 0000000..8c7cca2 --- /dev/null +++ b/packages/filegrc/test/mutation.test.js @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeResourceMutation } from "../src/mutation.js"; + +test("new resource mutations accept absent or null revisions without weakening updates", () => { + const record = { id: "example", type: "risk" }; + for (const revision of [undefined, null]) { + assert.equal(normalizeResourceMutation({ record, revision }).revision, undefined); + assert.throws(() => normalizeResourceMutation({ record, revision }, { requireRevision: true }), /revision is required/); + } + for (const revision of [false, 1, {}, []]) { + assert.throws(() => normalizeResourceMutation({ record, revision }), /revision must be a string/); + } + assert.throws(() => normalizeResourceMutation({ record, revision: "" }, { requireRevision: true }), /revision is required/); + assert.equal(normalizeResourceMutation({ record, revision: "current" }, { requireRevision: true }).revision, "current"); +});