From 15f3709ba2f3a7dfe3f0baf1f58c16cd33123d0d Mon Sep 17 00:00:00 2001 From: tuanadr Date: Wed, 20 May 2026 17:55:54 +0700 Subject: [PATCH] Add collaborative review decision ledger --- collab-review-decision-ledger/README.md | 44 ++++ .../acceptance-notes.md | 27 +++ .../demo-output/demo.mp4 | Bin 0 -> 36987 bytes .../demo-output/demo.svg | 57 ++++++ .../demo-output/review-decision-ledger.json | 92 +++++++++ collab-review-decision-ledger/demo.js | 80 ++++++++ collab-review-decision-ledger/index.js | 192 ++++++++++++++++++ .../requirements-map.md | 16 ++ collab-review-decision-ledger/sample-data.js | 70 +++++++ collab-review-decision-ledger/test.js | 125 ++++++++++++ 10 files changed, 703 insertions(+) create mode 100644 collab-review-decision-ledger/README.md create mode 100644 collab-review-decision-ledger/acceptance-notes.md create mode 100644 collab-review-decision-ledger/demo-output/demo.mp4 create mode 100644 collab-review-decision-ledger/demo-output/demo.svg create mode 100644 collab-review-decision-ledger/demo-output/review-decision-ledger.json create mode 100644 collab-review-decision-ledger/demo.js create mode 100644 collab-review-decision-ledger/index.js create mode 100644 collab-review-decision-ledger/requirements-map.md create mode 100644 collab-review-decision-ledger/sample-data.js create mode 100644 collab-review-decision-ledger/test.js diff --git a/collab-review-decision-ledger/README.md b/collab-review-decision-ledger/README.md new file mode 100644 index 0000000..cca302a --- /dev/null +++ b/collab-review-decision-ledger/README.md @@ -0,0 +1,44 @@ +# Collaborative Review Decision Ledger + +This is a focused Real-Time Collaborative Research Editor slice for SCIBASE issue #12. It evaluates whether a collaborative manuscript is ready to freeze or publish by checking unresolved review blockers across comments, suggestions, section locks, approvals, notebook freshness, decision records, and restore-safe snapshots. + +## Scope + +- Detects unresolved blocking comments. +- Detects open suggestions that must be accepted or rejected. +- Flags stale notebook outputs before release. +- Checks required role approvals for each section. +- Requires freeze-window section locks. +- Requires recorded sidebar decision records. +- Requires restore-ready snapshots before publish. +- Emits deterministic section decision digests and document-level priority actions. + +It intentionally does not duplicate broad editor foundations, operation replay, offline conflict rebasing, notebook workbenches, reference formatting, authorship governance, lock/checkpoint recovery, freeze lanes, figure/table review lanes, discussion-sidebar audit, autosave recovery, or round-trip fidelity checks. + +## Run + +```powershell +node collab-review-decision-ledger/test.js +node collab-review-decision-ledger/demo.js +``` + +The demo writes: + +- `collab-review-decision-ledger/demo-output/review-decision-ledger.json` +- `collab-review-decision-ledger/demo-output/demo.svg` + +This PR also includes the required short MP4 demo artifact: + +- `collab-review-decision-ledger/demo-output/demo.mp4` + +## API + +```js +const { + evaluateReviewDecision, + buildReleaseSummary, + createDecisionDigest, +} = require("./collab-review-decision-ledger"); + +const audit = evaluateReviewDecision({ manuscript, generatedAt }); +``` diff --git a/collab-review-decision-ledger/acceptance-notes.md b/collab-review-decision-ledger/acceptance-notes.md new file mode 100644 index 0000000..75d7810 --- /dev/null +++ b/collab-review-decision-ledger/acceptance-notes.md @@ -0,0 +1,27 @@ +# Acceptance Notes + +## What This Adds + +- Dependency-free Node.js module under `collab-review-decision-ledger/`. +- Deterministic section-level publish/freeze decisions for collaborative research manuscripts. +- Tests for unresolved release blockers, clean section readiness, document release summaries, and stable decision digests. +- Demo JSON, SVG, and MP4 artifacts for bounty review. + +## Verification + +Use these commands from the repository root: + +```powershell +node collab-review-decision-ledger/test.js +node collab-review-decision-ledger/demo.js +node --check collab-review-decision-ledger/index.js +node --check collab-review-decision-ledger/test.js +node --check collab-review-decision-ledger/demo.js +node --check collab-review-decision-ledger/sample-data.js +ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 collab-review-decision-ledger/demo-output/demo.mp4 +git diff --check +``` + +## AI Assistance Disclosure + +This contribution was prepared with AI assistance from OpenAI Codex and reviewed through local deterministic tests and artifact checks before submission. diff --git a/collab-review-decision-ledger/demo-output/demo.mp4 b/collab-review-decision-ledger/demo-output/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1222a17ec46a5f3cdc51cb0c8194cb3edb7cb163 GIT binary patch literal 36987 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 ZEfCMx;8*6?LIFV2!W3v)u)bOr{||0!sqg>* literal 0 HcmV?d00001 diff --git a/collab-review-decision-ledger/demo-output/demo.svg b/collab-review-decision-ledger/demo-output/demo.svg new file mode 100644 index 0000000..3cc9819 --- /dev/null +++ b/collab-review-decision-ledger/demo-output/demo.svg @@ -0,0 +1,57 @@ + + + + Collaborative Review Decision Ledger + Quantum catalyst manuscript - 2026-05-20T13:00:00.000Z + + + 3 + Sections + + + + 2 + Ready + + + + 1 + Hold + + + + 1 + Comments + + + + + Introduction + Ready To Freeze - risk 0 + No blockers + crdl_bf687c130eb59eeabd2c84e3 + + + + Results + Hold Publish - risk 20 + BLOCKING_COMMENT_OPEN | OPEN_SUGGESTION | NOTEBOOK_OUTPUT_STALE | APPROVAL_MISSING | SECTION_UNLOCKED | DECISION_RECORD_MISSING | RESTORE_SNAPSHOT_UNSAFE + crdl_58fa90d9db1ded935bd59ca8 + + + + Methods + Ready To Freeze - risk 0 + No blockers + crdl_ed49826af04eabfc3f989582 + + Hold publish: 1 section has unresolved release blockers. Resolve 1 blocking comment before freeze. Refresh 1 stale notebook output before release. Collect 1 missing role approval before publish. + \ No newline at end of file diff --git a/collab-review-decision-ledger/demo-output/review-decision-ledger.json b/collab-review-decision-ledger/demo-output/review-decision-ledger.json new file mode 100644 index 0000000..62f4dd7 --- /dev/null +++ b/collab-review-decision-ledger/demo-output/review-decision-ledger.json @@ -0,0 +1,92 @@ +{ + "generatedAt": "2026-05-20T13:00:00.000Z", + "documentId": "doc-quantum-12", + "title": "Quantum catalyst manuscript", + "sectionDecisions": [ + { + "sectionId": "intro", + "sectionTitle": "Introduction", + "generatedAt": "2026-05-20T13:00:00.000Z", + "decision": "ready_to_freeze", + "flags": [], + "riskScore": 0, + "blockingComments": [], + "openSuggestions": [], + "staleNotebooks": [], + "missingApprovals": [], + "releaseActions": [ + "Keep section frozen for publish review" + ], + "decisionDigest": "crdl_bf687c130eb59eeabd2c84e3" + }, + { + "sectionId": "results", + "sectionTitle": "Results", + "generatedAt": "2026-05-20T13:00:00.000Z", + "decision": "hold_publish", + "flags": [ + "BLOCKING_COMMENT_OPEN", + "OPEN_SUGGESTION", + "NOTEBOOK_OUTPUT_STALE", + "APPROVAL_MISSING", + "SECTION_UNLOCKED", + "DECISION_RECORD_MISSING", + "RESTORE_SNAPSHOT_UNSAFE" + ], + "riskScore": 20, + "blockingComments": [ + "c-7" + ], + "openSuggestions": [ + "sg-7" + ], + "staleNotebooks": [ + "nb-7" + ], + "missingApprovals": [ + "stat-reviewer" + ], + "releaseActions": [ + "Resolve 1 blocking comment in Results", + "Accept or reject 1 open suggestion in Results", + "Refresh 1 stale notebook output before freeze", + "Collect approval from stat-reviewer", + "Move Results into freeze-window lock mode", + "Create restore-ready snapshot for Results" + ], + "decisionDigest": "crdl_58fa90d9db1ded935bd59ca8" + }, + { + "sectionId": "methods", + "sectionTitle": "Methods", + "generatedAt": "2026-05-20T13:00:00.000Z", + "decision": "ready_to_freeze", + "flags": [], + "riskScore": 0, + "blockingComments": [], + "openSuggestions": [], + "staleNotebooks": [], + "missingApprovals": [], + "releaseActions": [ + "Keep section frozen for publish review" + ], + "decisionDigest": "crdl_ed49826af04eabfc3f989582" + } + ], + "releaseSummary": { + "counts": { + "sections": 3, + "readyToFreeze": 2, + "holdPublish": 1, + "blockingComments": 1, + "staleNotebooks": 1, + "missingApprovals": 1 + }, + "priorityActions": [ + "Hold publish: 1 section has unresolved release blockers.", + "Resolve 1 blocking comment before freeze.", + "Refresh 1 stale notebook output before release.", + "Collect 1 missing role approval before publish." + ] + } +} diff --git a/collab-review-decision-ledger/demo.js b/collab-review-decision-ledger/demo.js new file mode 100644 index 0000000..ad6d842 --- /dev/null +++ b/collab-review-decision-ledger/demo.js @@ -0,0 +1,80 @@ +const fs = require("fs"); +const path = require("path"); + +const { evaluateReviewDecision } = require("./index"); +const { manuscript } = require("./sample-data"); + +const generatedAt = "2026-05-20T13:00:00.000Z"; +const outputDir = path.join(__dirname, "demo-output"); + +fs.mkdirSync(outputDir, { recursive: true }); + +const audit = evaluateReviewDecision({ manuscript, generatedAt }); +fs.writeFileSync(path.join(outputDir, "review-decision-ledger.json"), `${JSON.stringify(audit, null, 2)}\n`); +fs.writeFileSync(path.join(outputDir, "demo.svg"), buildSvg(audit)); + +console.log("Collaborative review decision ledger demo"); +console.log(`Document: ${audit.title}`); +console.log(`Sections: ${audit.releaseSummary.counts.sections}`); +console.log(`Ready to freeze: ${audit.releaseSummary.counts.readyToFreeze}`); +console.log(`Hold publish: ${audit.releaseSummary.counts.holdPublish}`); +console.log(`Blocking comments: ${audit.releaseSummary.counts.blockingComments}`); +console.log(`Wrote ${path.join(outputDir, "review-decision-ledger.json")}`); +console.log(`Wrote ${path.join(outputDir, "demo.svg")}`); + +function buildSvg(audit) { + const rows = audit.sectionDecisions.map((section, index) => { + const y = 196 + index * 82; + const color = section.decision === "ready_to_freeze" ? "#1f8a5b" : "#b42318"; + const flags = section.flags.length === 0 ? "No blockers" : section.flags.join(" | "); + return ` + + + ${escapeXml(section.sectionTitle)} + ${escapeXml(formatDecision(section.decision))} - risk ${section.riskScore} + ${escapeXml(flags)} + ${escapeXml(section.decisionDigest)} + `; + }).join(""); + + return ` + + + Collaborative Review Decision Ledger + ${escapeXml(audit.title)} - ${escapeXml(audit.generatedAt)} + ${metricCard(64, 112, "Sections", audit.releaseSummary.counts.sections, "#0b5fff")} + ${metricCard(252, 112, "Ready", audit.releaseSummary.counts.readyToFreeze, "#1f8a5b")} + ${metricCard(440, 112, "Hold", audit.releaseSummary.counts.holdPublish, "#b42318")} + ${metricCard(628, 112, "Comments", audit.releaseSummary.counts.blockingComments, "#ad6f00")} + ${rows} + ${escapeXml(audit.releaseSummary.priorityActions.join(" "))} +`; +} + +function metricCard(x, y, label, value, color) { + return ` + + ${value} + ${escapeXml(label)} + `; +} + +function formatDecision(decision) { + return decision.split("_").map((part) => part[0].toUpperCase() + part.slice(1)).join(" "); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/collab-review-decision-ledger/index.js b/collab-review-decision-ledger/index.js new file mode 100644 index 0000000..0965cb3 --- /dev/null +++ b/collab-review-decision-ledger/index.js @@ -0,0 +1,192 @@ +const crypto = require("crypto"); + +const FLAG_WEIGHTS = { + BLOCKING_COMMENT_OPEN: 4, + OPEN_SUGGESTION: 2, + NOTEBOOK_OUTPUT_STALE: 4, + APPROVAL_MISSING: 3, + SECTION_UNLOCKED: 2, + DECISION_RECORD_MISSING: 2, + RESTORE_SNAPSHOT_UNSAFE: 3, +}; + +function evaluateReviewDecision({ manuscript, generatedAt = new Date().toISOString() }) { + if (!manuscript || !Array.isArray(manuscript.sections)) { + throw new Error("manuscript.sections is required"); + } + + const sectionDecisions = manuscript.sections.map((section) => + evaluateSection(section, manuscript.requiredApprovers || [], generatedAt) + ); + + const audit = { + generatedAt, + documentId: manuscript.documentId, + title: manuscript.title, + sectionDecisions, + }; + audit.releaseSummary = buildReleaseSummary(audit); + return audit; +} + +function evaluateSection(section, requiredApprovers, generatedAt) { + const flags = []; + const blockingComments = (section.comments || []).filter( + (comment) => comment.severity === "blocking" && comment.resolved !== true + ); + if (blockingComments.length > 0) { + flags.push("BLOCKING_COMMENT_OPEN"); + } + + const openSuggestions = (section.suggestions || []).filter( + (suggestion) => !["accepted", "rejected"].includes(suggestion.status) + ); + if (openSuggestions.length > 0) { + flags.push("OPEN_SUGGESTION"); + } + + const staleNotebooks = (section.notebookOutputs || []).filter( + (output) => output.status === "stale" || output.sourceHash !== output.currentSourceHash + ); + if (staleNotebooks.length > 0) { + flags.push("NOTEBOOK_OUTPUT_STALE"); + } + + const approvedRoles = new Set((section.approvals || []) + .filter((approval) => approval.status === "approved") + .map((approval) => approval.role)); + const missingApprovals = requiredApprovers.filter((role) => !approvedRoles.has(role)); + if (missingApprovals.length > 0) { + flags.push("APPROVAL_MISSING"); + } + + if (!section.lock || section.lock.mode !== "freeze-window") { + flags.push("SECTION_UNLOCKED"); + } + + const recordedDecisions = (section.decisionRecords || []).filter( + (decision) => decision.status === "recorded" && String(decision.text || "").trim() + ); + if (recordedDecisions.length === 0) { + flags.push("DECISION_RECORD_MISSING"); + } + + if (!section.snapshot || section.snapshot.restoreReady !== true) { + flags.push("RESTORE_SNAPSHOT_UNSAFE"); + } + + const riskScore = flags.reduce((total, flag) => total + FLAG_WEIGHTS[flag], 0); + const decision = riskScore === 0 ? "ready_to_freeze" : "hold_publish"; + const sectionDecision = { + sectionId: section.id, + sectionTitle: section.title, + generatedAt, + decision, + flags, + riskScore, + blockingComments: blockingComments.map((comment) => comment.id), + openSuggestions: openSuggestions.map((suggestion) => suggestion.id), + staleNotebooks: staleNotebooks.map((output) => output.id), + missingApprovals, + releaseActions: buildReleaseActions({ + decision, + blockingComments, + openSuggestions, + staleNotebooks, + missingApprovals, + section, + }), + }; + sectionDecision.decisionDigest = createDecisionDigest(sectionDecision); + return sectionDecision; +} + +function buildReleaseActions({ + decision, + blockingComments, + openSuggestions, + staleNotebooks, + missingApprovals, + section, +}) { + if (decision === "ready_to_freeze") { + return ["Keep section frozen for publish review"]; + } + + const actions = []; + if (blockingComments.length > 0) { + actions.push(`Resolve ${formatCount(blockingComments.length, "blocking comment")} in ${section.title}`); + } + if (openSuggestions.length > 0) { + actions.push(`Accept or reject ${formatCount(openSuggestions.length, "open suggestion")} in ${section.title}`); + } + if (staleNotebooks.length > 0) { + actions.push(`Refresh ${formatCount(staleNotebooks.length, "stale notebook output")} before freeze`); + } + if (missingApprovals.length > 0) { + actions.push(`Collect approval from ${missingApprovals.join(", ")}`); + } + if (!section.lock || section.lock.mode !== "freeze-window") { + actions.push(`Move ${section.title} into freeze-window lock mode`); + } + if (!section.snapshot || section.snapshot.restoreReady !== true) { + actions.push(`Create restore-ready snapshot for ${section.title}`); + } + return actions; +} + +function buildReleaseSummary(audit) { + const sections = audit.sectionDecisions || []; + const counts = { + sections: sections.length, + readyToFreeze: sections.filter((section) => section.decision === "ready_to_freeze").length, + holdPublish: sections.filter((section) => section.decision === "hold_publish").length, + blockingComments: sum(sections, "blockingComments"), + staleNotebooks: sum(sections, "staleNotebooks"), + missingApprovals: sum(sections, "missingApprovals"), + }; + + const priorityActions = []; + if (counts.holdPublish > 0) { + priorityActions.push(`Hold publish: ${formatCount(counts.holdPublish, "section")} has unresolved release blockers.`); + } + if (counts.blockingComments > 0) { + priorityActions.push(`Resolve ${formatCount(counts.blockingComments, "blocking comment")} before freeze.`); + } + if (counts.staleNotebooks > 0) { + priorityActions.push(`Refresh ${formatCount(counts.staleNotebooks, "stale notebook output")} before release.`); + } + if (counts.missingApprovals > 0) { + priorityActions.push(`Collect ${formatCount(counts.missingApprovals, "missing role approval")} before publish.`); + } + + return { counts, priorityActions }; +} + +function createDecisionDigest(sectionDecision) { + const stableFacts = { + sectionId: sectionDecision.sectionId, + decision: sectionDecision.decision, + flags: [...(sectionDecision.flags || [])].sort(), + blockingComments: [...(sectionDecision.blockingComments || [])].sort(), + openSuggestions: [...(sectionDecision.openSuggestions || [])].sort(), + staleNotebooks: [...(sectionDecision.staleNotebooks || [])].sort(), + missingApprovals: [...(sectionDecision.missingApprovals || [])].sort(), + riskScore: sectionDecision.riskScore, + }; + return `crdl_${crypto.createHash("sha256").update(JSON.stringify(stableFacts)).digest("hex").slice(0, 24)}`; +} + +function sum(items, key) { + return items.reduce((total, item) => total + ((item[key] || []).length), 0); +} + +function formatCount(count, label) { + return `${count} ${label}${count === 1 ? "" : "s"}`; +} + +module.exports = { + evaluateReviewDecision, + buildReleaseSummary, + createDecisionDigest, +}; diff --git a/collab-review-decision-ledger/requirements-map.md b/collab-review-decision-ledger/requirements-map.md new file mode 100644 index 0000000..fed39cf --- /dev/null +++ b/collab-review-decision-ledger/requirements-map.md @@ -0,0 +1,16 @@ +# Requirements Map + +| Issue #12 requirement | Implementation coverage | +| --- | --- | +| Inline comments, suggestions, and change tracking | Blocks publish when comments or suggestions remain unresolved. | +| Locking/unlock modes for controlled sections | Requires `freeze-window` locks before publish review. | +| Document chat or discussion sidebar per section | Requires recorded decision records for each section. | +| Version history and autosave | Requires restore-ready snapshots before release. | +| Jupyter notebook integration | Checks notebook output freshness and source hash drift. | +| Multi-user collaborative workflow | Requires role approvals from author, statistician, and data-owner roles. | +| Review and publication workflow | Emits per-section release decisions and document-level priority actions. | +| Auditability | Emits deterministic `crdl_` decision digests from section facts. | + +## Non-Overlap Statement + +This slice focuses on final collaborative review decisions before freeze/publish. It does not duplicate broad editor models, operation replay, offline conflict rebasing, notebook collaboration, reference formatting, authorship governance, lock recovery, freeze lanes, figure/table review, discussion sidebar audit, autosave recovery, or round-trip fidelity checks. diff --git a/collab-review-decision-ledger/sample-data.js b/collab-review-decision-ledger/sample-data.js new file mode 100644 index 0000000..b6cef01 --- /dev/null +++ b/collab-review-decision-ledger/sample-data.js @@ -0,0 +1,70 @@ +const manuscript = { + documentId: "doc-quantum-12", + title: "Quantum catalyst manuscript", + requiredApprovers: ["lead-author", "stat-reviewer", "data-owner"], + sections: [ + { + id: "intro", + title: "Introduction", + lock: { mode: "freeze-window", lockedAt: "2026-05-20T10:00:00.000Z" }, + comments: [], + suggestions: [{ id: "sg-1", status: "accepted" }], + notebookOutputs: [], + approvals: [ + { role: "lead-author", status: "approved" }, + { role: "stat-reviewer", status: "approved" }, + { role: "data-owner", status: "approved" }, + ], + decisionRecords: [{ id: "dec-1", status: "recorded", text: "Intro accepted." }], + snapshot: { id: "snap-intro", restoreReady: true }, + }, + { + id: "results", + title: "Results", + lock: { mode: "editable", lockedAt: "2026-05-17T08:00:00.000Z" }, + comments: [{ id: "c-7", severity: "blocking", resolved: false }], + suggestions: [{ id: "sg-7", status: "open" }], + notebookOutputs: [ + { + id: "nb-7", + status: "stale", + lastExecutedAt: "2026-05-16T05:00:00.000Z", + sourceHash: "sha256:old", + currentSourceHash: "sha256:new", + }, + ], + approvals: [ + { role: "lead-author", status: "approved" }, + { role: "stat-reviewer", status: "pending" }, + { role: "data-owner", status: "approved" }, + ], + decisionRecords: [{ id: "dec-7", status: "missing", text: "" }], + snapshot: { id: "snap-results", restoreReady: false }, + }, + { + id: "methods", + title: "Methods", + lock: { mode: "freeze-window", lockedAt: "2026-05-20T11:00:00.000Z" }, + comments: [], + suggestions: [{ id: "sg-3", status: "rejected" }], + notebookOutputs: [ + { + id: "nb-3", + status: "fresh", + lastExecutedAt: "2026-05-20T09:00:00.000Z", + sourceHash: "sha256:methods", + currentSourceHash: "sha256:methods", + }, + ], + approvals: [ + { role: "lead-author", status: "approved" }, + { role: "stat-reviewer", status: "approved" }, + { role: "data-owner", status: "approved" }, + ], + decisionRecords: [{ id: "dec-3", status: "recorded", text: "Methods frozen." }], + snapshot: { id: "snap-methods", restoreReady: true }, + }, + ], +}; + +module.exports = { manuscript }; diff --git a/collab-review-decision-ledger/test.js b/collab-review-decision-ledger/test.js new file mode 100644 index 0000000..c37f7b5 --- /dev/null +++ b/collab-review-decision-ledger/test.js @@ -0,0 +1,125 @@ +const assert = require("assert"); + +const { + evaluateReviewDecision, + buildReleaseSummary, + createDecisionDigest, +} = require("./index"); + +const generatedAt = "2026-05-20T13:00:00.000Z"; + +const manuscript = { + documentId: "doc-quantum-12", + title: "Quantum catalyst manuscript", + requiredApprovers: ["lead-author", "stat-reviewer", "data-owner"], + sections: [ + { + id: "intro", + title: "Introduction", + lock: { mode: "freeze-window", lockedAt: "2026-05-20T10:00:00.000Z" }, + comments: [], + suggestions: [{ id: "sg-1", status: "accepted" }], + notebookOutputs: [], + approvals: [ + { role: "lead-author", status: "approved" }, + { role: "stat-reviewer", status: "approved" }, + { role: "data-owner", status: "approved" }, + ], + decisionRecords: [{ id: "dec-1", status: "recorded", text: "Intro accepted." }], + snapshot: { id: "snap-intro", restoreReady: true }, + }, + { + id: "results", + title: "Results", + lock: { mode: "editable", lockedAt: "2026-05-17T08:00:00.000Z" }, + comments: [{ id: "c-7", severity: "blocking", resolved: false }], + suggestions: [{ id: "sg-7", status: "open" }], + notebookOutputs: [ + { + id: "nb-7", + status: "stale", + lastExecutedAt: "2026-05-16T05:00:00.000Z", + sourceHash: "sha256:old", + currentSourceHash: "sha256:new", + }, + ], + approvals: [ + { role: "lead-author", status: "approved" }, + { role: "stat-reviewer", status: "pending" }, + { role: "data-owner", status: "approved" }, + ], + decisionRecords: [{ id: "dec-7", status: "missing", text: "" }], + snapshot: { id: "snap-results", restoreReady: false }, + }, + ], +}; + +function test(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + console.error(error); + process.exitCode = 1; + } +} + +test("holds publish when collaborative review gates are unresolved", () => { + const audit = evaluateReviewDecision({ manuscript, generatedAt }); + const results = audit.sectionDecisions.find((section) => section.sectionId === "results"); + + assert(results, "expected results section decision"); + assert.strictEqual(results.decision, "hold_publish"); + assert(results.flags.includes("BLOCKING_COMMENT_OPEN")); + assert(results.flags.includes("OPEN_SUGGESTION")); + assert(results.flags.includes("NOTEBOOK_OUTPUT_STALE")); + assert(results.flags.includes("APPROVAL_MISSING")); + assert(results.flags.includes("SECTION_UNLOCKED")); + assert(results.flags.includes("DECISION_RECORD_MISSING")); + assert(results.flags.includes("RESTORE_SNAPSHOT_UNSAFE")); + assert(results.releaseActions.some((action) => action.includes("Resolve 1 blocking comment"))); + assert(results.releaseActions.some((action) => action.includes("stat-reviewer"))); +}); + +test("marks clean sections ready to freeze", () => { + const audit = evaluateReviewDecision({ manuscript, generatedAt }); + const intro = audit.sectionDecisions.find((section) => section.sectionId === "intro"); + + assert(intro, "expected intro section decision"); + assert.strictEqual(intro.decision, "ready_to_freeze"); + assert.deepStrictEqual(intro.flags, []); + assert.strictEqual(intro.riskScore, 0); + assert(intro.releaseActions.includes("Keep section frozen for publish review")); +}); + +test("builds document release summary", () => { + const audit = evaluateReviewDecision({ manuscript, generatedAt }); + const summary = buildReleaseSummary(audit); + + assert.deepStrictEqual(summary.counts, { + sections: 2, + readyToFreeze: 1, + holdPublish: 1, + blockingComments: 1, + staleNotebooks: 1, + missingApprovals: 1, + }); + assert.deepStrictEqual(summary.priorityActions, [ + "Hold publish: 1 section has unresolved release blockers.", + "Resolve 1 blocking comment before freeze.", + "Refresh 1 stale notebook output before release.", + "Collect 1 missing role approval before publish.", + ]); +}); + +test("creates stable decision digests from section facts", () => { + const audit = evaluateReviewDecision({ manuscript, generatedAt }); + const section = audit.sectionDecisions.find((item) => item.sectionId === "results"); + + const first = createDecisionDigest(section); + const second = createDecisionDigest({ ...section, releaseActions: [...section.releaseActions] }); + + assert.strictEqual(first, second); + assert.match(first, /^crdl_[a-f0-9]{24}$/); +});