From d8dab7091b51872c66c7679997f028182ed83e2a Mon Sep 17 00:00:00 2001 From: AlonePenguin <187998801+AlonePenguin@users.noreply.github.com> Date: Mon, 1 Jun 2026 07:27:20 -0400 Subject: [PATCH] Add release note claim evidence guard --- repository-release-note-claim-guard/README.md | 36 ++ repository-release-note-claim-guard/demo.js | 50 ++ repository-release-note-claim-guard/index.js | 528 ++++++++++++++++++ .../make-demo-video.js | 91 +++ .../package.json | 21 + .../reports/clean-packet.json | 206 +++++++ .../reports/demo-script.txt | 10 + .../reports/demo.mp4 | Bin 0 -> 9247 bytes .../reports/release-note-claim-report.md | 47 ++ .../reports/risky-packet.json | 439 +++++++++++++++ .../reports/summary.svg | 20 + .../sample-data.js | 205 +++++++ repository-release-note-claim-guard/test.js | 54 ++ 13 files changed, 1707 insertions(+) create mode 100644 repository-release-note-claim-guard/README.md create mode 100644 repository-release-note-claim-guard/demo.js create mode 100644 repository-release-note-claim-guard/index.js create mode 100644 repository-release-note-claim-guard/make-demo-video.js create mode 100644 repository-release-note-claim-guard/package.json create mode 100644 repository-release-note-claim-guard/reports/clean-packet.json create mode 100644 repository-release-note-claim-guard/reports/demo-script.txt create mode 100644 repository-release-note-claim-guard/reports/demo.mp4 create mode 100644 repository-release-note-claim-guard/reports/release-note-claim-report.md create mode 100644 repository-release-note-claim-guard/reports/risky-packet.json create mode 100644 repository-release-note-claim-guard/reports/summary.svg create mode 100644 repository-release-note-claim-guard/sample-data.js create mode 100644 repository-release-note-claim-guard/test.js diff --git a/repository-release-note-claim-guard/README.md b/repository-release-note-claim-guard/README.md new file mode 100644 index 00000000..ba85795a --- /dev/null +++ b/repository-release-note-claim-guard/README.md @@ -0,0 +1,36 @@ +# Repository Release Note Claim Guard + +This module is a focused Project Repository & Version Control slice for SCIBASE issue #10. It reviews tagged repository release packets before public release notes, citation badges, API exports, or archive bundles are published. + +The guard checks that every public release-note claim is backed by: + +- a concrete changed repository artifact +- passed evidence of the right kind for the claim type +- path-specific evidence coverage +- fresh evidence within policy +- export-manifest parity for release-note ids and changed artifact digests +- explicit disclosure for breaking changes + +It is intentionally separate from broader repository ledgers, semantic version-tag governance, release signatures, merge queues, branch protection, component-owner approvals, external reference pinning, notebook diffs, fork provenance, restore rehearsals, compute sandbox policy, and review-decision provenance. This slice focuses only on whether public release notes overstate, omit, or misrepresent the actual release evidence. + +## Reviewer Path + +```bash +npm run check +npm test +npm run demo +npm run verify-video +``` + +Generated reviewer artifacts: + +- `reports/clean-packet.json` +- `reports/risky-packet.json` +- `reports/release-note-claim-report.md` +- `reports/summary.svg` +- `reports/demo-script.txt` +- `reports/demo.mp4` + +## Safety + +All fixtures are synthetic. The module does not call Git providers, CI systems, DOI registries, object stores, private repositories, payment processors, payout accounts, credential stores, or external APIs. diff --git a/repository-release-note-claim-guard/demo.js b/repository-release-note-claim-guard/demo.js new file mode 100644 index 00000000..04b9181e --- /dev/null +++ b/repository-release-note-claim-guard/demo.js @@ -0,0 +1,50 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { evaluateReleasePacket, renderMarkdownReport, renderSvgSummary } = require("./index"); +const { cleanReleasePacket, riskyReleasePacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const cleanEvaluation = evaluateReleasePacket(cleanReleasePacket); +const riskyEvaluation = evaluateReleasePacket(riskyReleasePacket); + +fs.writeFileSync( + path.join(reportsDir, "clean-packet.json"), + `${JSON.stringify({ input: cleanReleasePacket, evaluation: cleanEvaluation }, null, 2)}\n` +); +fs.writeFileSync( + path.join(reportsDir, "risky-packet.json"), + `${JSON.stringify({ input: riskyReleasePacket, evaluation: riskyEvaluation }, null, 2)}\n` +); +fs.writeFileSync( + path.join(reportsDir, "release-note-claim-report.md"), + renderMarkdownReport(riskyReleasePacket, riskyEvaluation) +); +fs.writeFileSync( + path.join(reportsDir, "summary.svg"), + renderSvgSummary(riskyEvaluation) +); +fs.writeFileSync( + path.join(reportsDir, "demo-script.txt"), + [ + "Repository release-note claim evidence guard demo", + "", + "1. Clean packet: all release-note claims bind to changed artifacts, passed evidence, and export-manifest digests.", + ` Decision: ${cleanEvaluation.summary.decision}`, + ` Digest: ${cleanEvaluation.summary.auditDigest}`, + "", + "2. Risky packet: public release notes overclaim reproducibility, hide a breaking API change, cite a non-existent public dataset path, and omit changed data from the export manifest.", + ` Decision: ${riskyEvaluation.summary.decision}`, + ` Findings: ${riskyEvaluation.summary.findingCount}`, + ` Digest: ${riskyEvaluation.summary.auditDigest}`, + "" + ].join("\n") +); + +console.log(JSON.stringify({ + cleanDecision: cleanEvaluation.summary.decision, + riskyDecision: riskyEvaluation.summary.decision, + riskyFindings: riskyEvaluation.summary.findingCount, + report: "reports/release-note-claim-report.md" +}, null, 2)); diff --git a/repository-release-note-claim-guard/index.js b/repository-release-note-claim-guard/index.js new file mode 100644 index 00000000..91a9ed8a --- /dev/null +++ b/repository-release-note-claim-guard/index.js @@ -0,0 +1,528 @@ +const crypto = require("node:crypto"); + +const DAY_MS = 24 * 60 * 60 * 1000; + +const REQUIRED_EVIDENCE_BY_CLAIM = { + analysis: ["test", "reproduction"], + api: ["test", "export"], + breaking_change: ["review", "export"], + citation: ["citation", "export"], + code: ["test"], + dataset: ["data-diff", "checksum"], + figure: ["reproduction", "checksum"], + metadata: ["metadata", "export"], + performance: ["benchmark"], + reproducibility: ["reproduction"] +}; + +const HIGH_RISK_WORDING = [ + { pattern: /\bfully reproducible\b/i, code: "REPRODUCIBILITY_OVERCLAIM", kind: "reproduction" }, + { pattern: /\ball results\b/i, code: "ALL_RESULTS_OVERCLAIM", kind: "reproduction" }, + { pattern: /\bvalidated\b/i, code: "VALIDATION_OVERCLAIM", kind: "test" }, + { pattern: /\bno breaking changes?\b/i, code: "NO_BREAKING_CHANGE_OVERCLAIM", kind: "review" } +]; + +function normalizePath(value) { + return String(value || "") + .replace(/\\/g, "/") + .replace(/\/+/g, "/") + .replace(/^\.\//, "") + .replace(/\/$/, ""); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function stableJson(value) { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function sha256(value) { + return crypto.createHash("sha256").update(stableJson(value)).digest("hex"); +} + +function dateAgeDays(reviewedAt, producedAt) { + const reviewed = new Date(reviewedAt); + const produced = new Date(producedAt); + if (Number.isNaN(reviewed.valueOf()) || Number.isNaN(produced.valueOf())) { + return Number.POSITIVE_INFINITY; + } + return Math.max(0, Math.ceil((reviewed.valueOf() - produced.valueOf()) / DAY_MS)); +} + +function pathMatches(path, coveredPath) { + const left = normalizePath(path); + const right = normalizePath(coveredPath); + if (!left || !right) { + return false; + } + return ( + left === right || + left.startsWith(`${right}/`) || + right.startsWith(`${left}/`) + ); +} + +function buildMaps(packet) { + const changes = asArray(packet.changeSet).map((change) => ({ + ...change, + path: normalizePath(change.path) + })); + const notes = asArray(packet.releaseNotes).map((note) => ({ + ...note, + componentPaths: asArray(note.componentPaths).map(normalizePath), + evidenceIds: asArray(note.evidenceIds) + })); + const evidence = asArray(packet.evidence).map((item) => ({ + ...item, + coversPaths: asArray(item.coversPaths).map(normalizePath) + })); + + return { + changes, + notes, + evidence, + noteById: new Map(notes.map((note) => [note.id, note])), + evidenceById: new Map(evidence.map((item) => [item.id, item])) + }; +} + +function evidenceCoversPath(evidence, path) { + return asArray(evidence.coversPaths).some((coveredPath) => pathMatches(path, coveredPath)); +} + +function noteMentionsChange(note, change) { + return ( + asArray(change.noteIds).includes(note.id) || + asArray(note.componentPaths).some((path) => pathMatches(change.path, path)) + ); +} + +function hasKind(noteEvidence, kind) { + return noteEvidence.some((item) => item && item.kind === kind && item.status === "passed"); +} + +function addFinding(findings, severity, code, message, refs, action) { + findings.push({ + severity, + code, + message, + refs: asArray(refs), + action + }); +} + +function severityRank(severity) { + return { critical: 4, high: 3, medium: 2, low: 1 }[severity] || 0; +} + +function evaluateReleasePacket(packet, options = {}) { + const reviewedAt = options.reviewedAt || packet.reviewedAt || new Date().toISOString(); + const policy = { + maxEvidenceAgeDays: 45, + requireExportManifest: true, + ...packet.policy, + ...options.policy + }; + const { changes, notes, evidence, noteById, evidenceById } = buildMaps(packet); + const findings = []; + const evidenceCoverage = []; + + for (const note of notes) { + if (!note.id) { + addFinding( + findings, + "high", + "RELEASE_NOTE_MISSING_ID", + "A public release-note claim is missing a stable note id.", + [], + "assign_release_note_id" + ); + continue; + } + + if (!String(note.text || "").trim()) { + addFinding( + findings, + "high", + "RELEASE_NOTE_EMPTY_TEXT", + `Release note ${note.id} has no reviewer-visible claim text.`, + [note.id], + "write_public_release_note_claim" + ); + } + + if (note.componentPaths.length === 0) { + addFinding( + findings, + "medium", + "RELEASE_NOTE_UNSCOPED", + `Release note ${note.id} is not scoped to repository components or paths.`, + [note.id], + "bind_claim_to_changed_components" + ); + } + + const noteChanges = changes.filter((change) => noteMentionsChange(note, change)); + for (const componentPath of note.componentPaths) { + if (!changes.some((change) => pathMatches(change.path, componentPath))) { + addFinding( + findings, + "high", + "CLAIMED_COMPONENT_WITHOUT_CHANGE", + `Release note ${note.id} names ${componentPath}, but the release packet has no matching changed artifact.`, + [note.id, componentPath], + "remove_claim_or_attach_changed_artifact" + ); + } + } + + const noteEvidence = note.evidenceIds.map((id) => evidenceById.get(id)); + for (const evidenceId of note.evidenceIds) { + const item = evidenceById.get(evidenceId); + if (!item) { + addFinding( + findings, + "high", + "CLAIM_EVIDENCE_MISSING", + `Release note ${note.id} references missing evidence ${evidenceId}.`, + [note.id, evidenceId], + "attach_evidence_or_remove_claim" + ); + continue; + } + + const age = dateAgeDays(reviewedAt, item.producedAt); + if (age > policy.maxEvidenceAgeDays) { + addFinding( + findings, + "medium", + "CLAIM_EVIDENCE_STALE", + `Evidence ${item.id} for release note ${note.id} is ${age} days old.`, + [note.id, item.id], + "refresh_release_evidence" + ); + } + + if (item.status !== "passed") { + addFinding( + findings, + "high", + "CLAIM_EVIDENCE_NOT_PASSED", + `Evidence ${item.id} for release note ${note.id} has status ${item.status || "unknown"}.`, + [note.id, item.id], + "replace_or_rerun_failed_evidence" + ); + } + } + + const requiredKinds = REQUIRED_EVIDENCE_BY_CLAIM[note.claimType] || ["review"]; + for (const kind of requiredKinds) { + if (!hasKind(noteEvidence, kind)) { + addFinding( + findings, + "high", + "CLAIM_KIND_EVIDENCE_MISSING", + `Release note ${note.id} is a ${note.claimType || "general"} claim but lacks passed ${kind} evidence.`, + [note.id, kind], + "attach_required_claim_evidence" + ); + } + } + + for (const componentPath of note.componentPaths) { + const covered = noteEvidence.some((item) => item && item.status === "passed" && evidenceCoversPath(item, componentPath)); + if (!covered) { + addFinding( + findings, + "medium", + "CLAIM_PATH_NOT_COVERED_BY_EVIDENCE", + `Release note ${note.id} cites ${componentPath}, but no passed evidence covers that path.`, + [note.id, componentPath], + "add_path_specific_release_evidence" + ); + } + } + + for (const wording of HIGH_RISK_WORDING) { + if (wording.pattern.test(note.text || "") && !hasKind(noteEvidence, wording.kind)) { + addFinding( + findings, + "high", + wording.code, + `Release note ${note.id} uses high-confidence wording without passed ${wording.kind} evidence.`, + [note.id], + "downgrade_claim_or_add_evidence" + ); + } + } + + if (/no breaking changes?/i.test(note.text || "")) { + const breakingChanges = changes.filter((change) => change.breaking === true); + if (breakingChanges.length > 0) { + addFinding( + findings, + "critical", + "NO_BREAKING_CHANGE_CONFLICT", + `Release note ${note.id} says there are no breaking changes, but ${breakingChanges.length} changed artifact(s) are marked breaking.`, + [note.id, ...breakingChanges.map((change) => change.id || change.path)], + "remove_no_breaking_claim_or_document_breaking_change" + ); + } + } + + evidenceCoverage.push({ + noteId: note.id, + claimType: note.claimType || "general", + changedArtifacts: noteChanges.length, + evidenceIds: note.evidenceIds, + passedEvidence: noteEvidence.filter((item) => item && item.status === "passed").length, + requiredKinds + }); + } + + for (const change of changes) { + const mentions = notes.filter((note) => noteMentionsChange(note, change)); + if (mentions.length === 0 && change.publicImpact !== false) { + addFinding( + findings, + change.breaking ? "high" : "medium", + "CHANGED_ARTIFACT_UNMENTIONED", + `Changed artifact ${change.path} is not represented in any public release note.`, + [change.id || change.path], + "add_release_note_or_mark_internal_only" + ); + } + + if (!change.digestAfter || change.digestBefore === change.digestAfter) { + addFinding( + findings, + "medium", + "CHANGE_DIGEST_NOT_UPDATED", + `Changed artifact ${change.path} does not carry a new post-release digest.`, + [change.id || change.path], + "record_post_release_digest" + ); + } + + if (change.breaking && !notes.some((note) => note.claimType === "breaking_change" && noteMentionsChange(note, change))) { + addFinding( + findings, + "high", + "BREAKING_CHANGE_NOT_DISCLOSED", + `Breaking artifact ${change.path} has no explicit breaking-change release note.`, + [change.id || change.path], + "document_breaking_change_in_release_notes" + ); + } + } + + const manifest = packet.exportManifest || {}; + if (policy.requireExportManifest && !packet.exportManifest) { + addFinding( + findings, + "high", + "EXPORT_MANIFEST_MISSING", + "The release packet has no export manifest for release-note, citation, and artifact parity checks.", + [], + "attach_export_manifest" + ); + } else if (packet.exportManifest) { + for (const note of notes) { + if (!asArray(manifest.releaseNoteIds).includes(note.id)) { + addFinding( + findings, + "medium", + "EXPORT_MANIFEST_MISSING_RELEASE_NOTE", + `Export manifest does not list public release note ${note.id}.`, + [note.id], + "add_release_note_to_export_manifest" + ); + } + } + + const manifestDigests = manifest.componentDigests || {}; + for (const change of changes) { + if (change.publicImpact === false) { + continue; + } + const expectedDigest = manifestDigests[change.path]; + if (expectedDigest !== change.digestAfter) { + addFinding( + findings, + "high", + "EXPORT_DIGEST_MISMATCH", + `Export manifest digest for ${change.path} does not match the changed artifact digest.`, + [change.path], + "update_export_manifest_digest" + ); + } + } + + if (manifest.versionTag && packet.versionTag && manifest.versionTag !== packet.versionTag) { + addFinding( + findings, + "medium", + "EXPORT_VERSION_TAG_MISMATCH", + `Export manifest tag ${manifest.versionTag} does not match release tag ${packet.versionTag}.`, + [manifest.versionTag, packet.versionTag], + "align_export_manifest_version_tag" + ); + } + } + + findings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.code.localeCompare(b.code)); + const topSeverity = findings[0] ? findings[0].severity : "none"; + const decision = findings.some((finding) => severityRank(finding.severity) >= 3) + ? "hold_repository_release" + : findings.some((finding) => finding.severity === "medium") + ? "revise_release_notes" + : "publish_release_notes"; + + const summary = { + repositoryId: packet.repositoryId, + versionTag: packet.versionTag, + reviewedAt, + decision, + topSeverity, + releaseNotesReviewed: notes.length, + changedArtifactsReviewed: changes.length, + evidenceItemsReviewed: evidence.length, + findingCount: findings.length, + highOrCriticalFindings: findings.filter((finding) => severityRank(finding.severity) >= 3).length + }; + + const auditDigest = `sha256:${sha256({ summary, findings, evidenceCoverage }).slice(0, 16)}`; + + return { + summary: { + ...summary, + auditDigest + }, + evidenceCoverage, + findings, + actions: buildActions(findings) + }; +} + +function buildActions(findings) { + const actions = []; + const seen = new Set(); + for (const finding of findings) { + if (!finding.action || seen.has(finding.action)) { + continue; + } + seen.add(finding.action); + actions.push({ + id: finding.action, + severity: finding.severity, + refs: finding.refs + }); + } + return actions; +} + +function renderMarkdownReport(packet, evaluation) { + const lines = []; + lines.push(`# Release Note Claim Evidence Review: ${packet.versionTag}`); + lines.push(""); + lines.push(`Decision: **${evaluation.summary.decision}**`); + lines.push(`Audit digest: \`${evaluation.summary.auditDigest}\``); + lines.push(""); + lines.push("## Summary"); + lines.push(""); + lines.push("| Metric | Value |"); + lines.push("| --- | ---: |"); + lines.push(`| Release notes reviewed | ${evaluation.summary.releaseNotesReviewed} |`); + lines.push(`| Changed artifacts reviewed | ${evaluation.summary.changedArtifactsReviewed} |`); + lines.push(`| Evidence items reviewed | ${evaluation.summary.evidenceItemsReviewed} |`); + lines.push(`| Findings | ${evaluation.summary.findingCount} |`); + lines.push(`| High or critical findings | ${evaluation.summary.highOrCriticalFindings} |`); + lines.push(""); + lines.push("## Findings"); + lines.push(""); + if (evaluation.findings.length === 0) { + lines.push("No release-blocking findings were detected."); + } else { + lines.push("| Severity | Code | Message | Action |"); + lines.push("| --- | --- | --- | --- |"); + for (const finding of evaluation.findings) { + lines.push( + `| ${finding.severity} | \`${finding.code}\` | ${escapeMarkdown(finding.message)} | \`${finding.action}\` |` + ); + } + } + lines.push(""); + lines.push("## Evidence Coverage"); + lines.push(""); + lines.push("| Release note | Claim type | Changed artifacts | Passed evidence | Required evidence |"); + lines.push("| --- | --- | ---: | ---: | --- |"); + for (const item of evaluation.evidenceCoverage) { + lines.push( + `| ${item.noteId} | ${item.claimType} | ${item.changedArtifacts} | ${item.passedEvidence} | ${item.requiredKinds.join(", ")} |` + ); + } + lines.push(""); + lines.push("Synthetic data only. No Git provider, DOI provider, CI system, private repository, payment account, credential, or external API is contacted."); + return `${lines.join("\n")}\n`; +} + +function escapeMarkdown(value) { + return String(value).replace(/\|/g, "\\|").replace(/\n/g, " "); +} + +function renderSvgSummary(evaluation) { + const isHold = evaluation.summary.decision === "hold_repository_release"; + const color = isHold ? "#b91c1c" : evaluation.summary.decision === "revise_release_notes" ? "#b45309" : "#047857"; + const safeDecision = escapeXml(evaluation.summary.decision); + const safeDigest = escapeXml(evaluation.summary.auditDigest); + const findings = evaluation.findings.slice(0, 5); + const rows = findings.map((finding, index) => { + const y = 205 + index * 42; + return `${escapeXml(finding.severity.toUpperCase())} ${escapeXml(finding.code)}`; + }).join("\n"); + + return ` + + + + Repository Release Note Claim Guard + + ${safeDecision} + Findings: ${evaluation.summary.findingCount} + High or critical: ${evaluation.summary.highOrCriticalFindings} + Audit digest: ${safeDigest} + + Top Findings + +${rows || 'No findings detected.'} + + +`; +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +module.exports = { + REQUIRED_EVIDENCE_BY_CLAIM, + evaluateReleasePacket, + renderMarkdownReport, + renderSvgSummary, + sha256 +}; diff --git a/repository-release-note-claim-guard/make-demo-video.js b/repository-release-note-claim-guard/make-demo-video.js new file mode 100644 index 00000000..a4e24c90 --- /dev/null +++ b/repository-release-note-claim-guard/make-demo-video.js @@ -0,0 +1,91 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { evaluateReleasePacket } = require("./index"); +const { cleanReleasePacket, riskyReleasePacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +const framesDir = path.join(reportsDir, "frames"); +fs.mkdirSync(framesDir, { recursive: true }); + +const clean = evaluateReleasePacket(cleanReleasePacket); +const risky = evaluateReleasePacket(riskyReleasePacket); +const width = 960; +const height = 540; +const frames = 72; +const fps = 18; + +function setPixel(buffer, x, y, r, g, b) { + if (x < 0 || y < 0 || x >= width || y >= height) { + return; + } + const offset = (y * width + x) * 3; + buffer[offset] = r; + buffer[offset + 1] = g; + buffer[offset + 2] = b; +} + +function fillRect(buffer, x, y, w, h, r, g, b) { + for (let row = y; row < y + h; row += 1) { + for (let col = x; col < x + w; col += 1) { + setPixel(buffer, col, row, r, g, b); + } + } +} + +function writeFrame(index, progress) { + const buffer = Buffer.alloc(width * height * 3, 248); + fillRect(buffer, 0, 0, width, height, 248, 250, 252); + fillRect(buffer, 48, 48, 864, 444, 255, 255, 255); + fillRect(buffer, 48, 48, 864, 8, 17, 24, 39); + + const leftBar = Math.floor(340 * Math.min(1, progress * 1.8)); + const rightBar = Math.floor(340 * Math.max(0, (progress - 0.35) * 1.55)); + + fillRect(buffer, 96, 118, 340, 84, 229, 231, 235); + fillRect(buffer, 96, 118, leftBar, 84, 5, 150, 105); + fillRect(buffer, 524, 118, 340, 84, 229, 231, 235); + fillRect(buffer, 524, 118, rightBar, 84, 185, 28, 28); + + const cleanFindings = Math.max(1, clean.summary.releaseNotesReviewed); + for (let i = 0; i < cleanFindings; i += 1) { + fillRect(buffer, 110 + i * 58, 242, 42, 150, 16, 185, 129); + } + + const riskyBars = Math.min(10, risky.summary.findingCount); + for (let i = 0; i < riskyBars; i += 1) { + const barHeight = 42 + (i % 5) * 24; + fillRect(buffer, 540 + i * 28, 392 - barHeight, 20, barHeight, 220, 38, 38); + } + + fillRect(buffer, 96, 430, Math.floor(768 * progress), 18, 37, 99, 235); + const header = Buffer.from(`P6\n${width} ${height}\n255\n`, "ascii"); + fs.writeFileSync(path.join(framesDir, `frame-${String(index).padStart(3, "0")}.ppm`), Buffer.concat([header, buffer])); +} + +for (let index = 0; index < frames; index += 1) { + writeFrame(index, index / (frames - 1)); +} + +const output = path.join(reportsDir, "demo.mp4"); +const ffmpeg = process.env.FFMPEG_PATH || "ffmpeg"; +const result = spawnSync(ffmpeg, [ + "-y", + "-framerate", + String(fps), + "-i", + path.join(framesDir, "frame-%03d.ppm"), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + output +], { stdio: "inherit" }); + +fs.rmSync(framesDir, { recursive: true, force: true }); + +if (result.status !== 0) { + process.exit(result.status || 1); +} + +console.log(`Wrote ${output}`); diff --git a/repository-release-note-claim-guard/package.json b/repository-release-note-claim-guard/package.json new file mode 100644 index 00000000..154a792d --- /dev/null +++ b/repository-release-note-claim-guard/package.json @@ -0,0 +1,21 @@ +{ + "name": "repository-release-note-claim-guard", + "version": "1.0.0", + "private": true, + "description": "Synthetic release-note claim evidence guard for SCIBASE project repositories.", + "main": "index.js", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js", + "test": "node test.js", + "demo": "node demo.js && node make-demo-video.js", + "verify-video": "ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,duration,avg_frame_rate -show_entries format=duration,size -of default=noprint_wrappers=1 reports/demo.mp4" + }, + "keywords": [ + "scibase", + "repository", + "version-control", + "release-notes", + "evidence" + ], + "license": "MIT" +} diff --git a/repository-release-note-claim-guard/reports/clean-packet.json b/repository-release-note-claim-guard/reports/clean-packet.json new file mode 100644 index 00000000..c1243abf --- /dev/null +++ b/repository-release-note-claim-guard/reports/clean-packet.json @@ -0,0 +1,206 @@ +{ + "input": { + "repositoryId": "scibase-demo-repo", + "versionTag": "preprint-v2.1.0", + "reviewedAt": "2026-06-01T12:00:00Z", + "policy": { + "maxEvidenceAgeDays": 30 + }, + "changeSet": [ + { + "id": "chg-analysis-script", + "path": "code/analysis/model-fit.py", + "component": "code", + "digestBefore": "sha256:1111", + "digestAfter": "sha256:2222", + "noteIds": [ + "rn-001" + ], + "publicImpact": true + }, + { + "id": "chg-results-figure", + "path": "results/figures/figure-2.svg", + "component": "results", + "digestBefore": "sha256:3333", + "digestAfter": "sha256:4444", + "noteIds": [ + "rn-002" + ], + "publicImpact": true + }, + { + "id": "chg-metadata", + "path": "metadata.json", + "component": "metadata", + "digestBefore": "sha256:5555", + "digestAfter": "sha256:6666", + "noteIds": [ + "rn-003" + ], + "publicImpact": true + } + ], + "releaseNotes": [ + { + "id": "rn-001", + "claimType": "code", + "text": "Updated the mixed-effects model fitting script and validated it with repository tests.", + "componentPaths": [ + "code/analysis/model-fit.py" + ], + "evidenceIds": [ + "ev-tests-model" + ] + }, + { + "id": "rn-002", + "claimType": "figure", + "text": "Regenerated Figure 2 from the refreshed analysis output with matching checksums.", + "componentPaths": [ + "results/figures/figure-2.svg" + ], + "evidenceIds": [ + "ev-repro-figure", + "ev-checksum-figure" + ] + }, + { + "id": "rn-003", + "claimType": "metadata", + "text": "Aligned repository metadata and export manifest with the preprint-v2.1.0 citation package.", + "componentPaths": [ + "metadata.json" + ], + "evidenceIds": [ + "ev-export-manifest", + "ev-metadata-review" + ] + } + ], + "evidence": [ + { + "id": "ev-tests-model", + "kind": "test", + "status": "passed", + "command": "npm run repository-tests", + "producedAt": "2026-05-31T15:00:00Z", + "coversPaths": [ + "code/analysis/model-fit.py" + ] + }, + { + "id": "ev-repro-figure", + "kind": "reproduction", + "status": "passed", + "command": "python code/analysis/model-fit.py --rebuild-figures", + "producedAt": "2026-05-31T15:20:00Z", + "coversPaths": [ + "code/analysis/model-fit.py", + "results/figures/figure-2.svg" + ] + }, + { + "id": "ev-checksum-figure", + "kind": "checksum", + "status": "passed", + "producedAt": "2026-05-31T15:25:00Z", + "coversPaths": [ + "results/figures/figure-2.svg" + ] + }, + { + "id": "ev-export-manifest", + "kind": "export", + "status": "passed", + "producedAt": "2026-05-31T16:00:00Z", + "coversPaths": [ + "metadata.json", + "code/analysis/model-fit.py", + "results/figures/figure-2.svg" + ] + }, + { + "id": "ev-metadata-review", + "kind": "metadata", + "status": "passed", + "producedAt": "2026-05-31T16:05:00Z", + "coversPaths": [ + "metadata.json" + ] + } + ], + "exportManifest": { + "versionTag": "preprint-v2.1.0", + "releaseNoteIds": [ + "rn-001", + "rn-002", + "rn-003" + ], + "componentDigests": { + "code/analysis/model-fit.py": "sha256:2222", + "results/figures/figure-2.svg": "sha256:4444", + "metadata.json": "sha256:6666" + } + } + }, + "evaluation": { + "summary": { + "repositoryId": "scibase-demo-repo", + "versionTag": "preprint-v2.1.0", + "reviewedAt": "2026-06-01T12:00:00Z", + "decision": "publish_release_notes", + "topSeverity": "none", + "releaseNotesReviewed": 3, + "changedArtifactsReviewed": 3, + "evidenceItemsReviewed": 5, + "findingCount": 0, + "highOrCriticalFindings": 0, + "auditDigest": "sha256:d845882abd6daba0" + }, + "evidenceCoverage": [ + { + "noteId": "rn-001", + "claimType": "code", + "changedArtifacts": 1, + "evidenceIds": [ + "ev-tests-model" + ], + "passedEvidence": 1, + "requiredKinds": [ + "test" + ] + }, + { + "noteId": "rn-002", + "claimType": "figure", + "changedArtifacts": 1, + "evidenceIds": [ + "ev-repro-figure", + "ev-checksum-figure" + ], + "passedEvidence": 2, + "requiredKinds": [ + "reproduction", + "checksum" + ] + }, + { + "noteId": "rn-003", + "claimType": "metadata", + "changedArtifacts": 1, + "evidenceIds": [ + "ev-export-manifest", + "ev-metadata-review" + ], + "passedEvidence": 2, + "requiredKinds": [ + "metadata", + "export" + ] + } + ], + "findings": [], + "actions": [] + } +} diff --git a/repository-release-note-claim-guard/reports/demo-script.txt b/repository-release-note-claim-guard/reports/demo-script.txt new file mode 100644 index 00000000..203da611 --- /dev/null +++ b/repository-release-note-claim-guard/reports/demo-script.txt @@ -0,0 +1,10 @@ +Repository release-note claim evidence guard demo + +1. Clean packet: all release-note claims bind to changed artifacts, passed evidence, and export-manifest digests. + Decision: publish_release_notes + Digest: sha256:d845882abd6daba0 + +2. Risky packet: public release notes overclaim reproducibility, hide a breaking API change, cite a non-existent public dataset path, and omit changed data from the export manifest. + Decision: hold_repository_release + Findings: 18 + Digest: sha256:8431cbaec2db5bd8 diff --git a/repository-release-note-claim-guard/reports/demo.mp4 b/repository-release-note-claim-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..2a69e8c2a71f1975f1cc83172eadf7adf489f633 GIT binary patch literal 9247 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 H{ptS$jx1Uf literal 0 HcmV?d00001 diff --git a/repository-release-note-claim-guard/reports/release-note-claim-report.md b/repository-release-note-claim-guard/reports/release-note-claim-report.md new file mode 100644 index 00000000..b903dde9 --- /dev/null +++ b/repository-release-note-claim-guard/reports/release-note-claim-report.md @@ -0,0 +1,47 @@ +# Release Note Claim Evidence Review: preprint-v2.2.0 + +Decision: **hold_repository_release** +Audit digest: `sha256:8431cbaec2db5bd8` + +## Summary + +| Metric | Value | +| --- | ---: | +| Release notes reviewed | 3 | +| Changed artifacts reviewed | 3 | +| Evidence items reviewed | 3 | +| Findings | 18 | +| High or critical findings | 12 | + +## Findings + +| Severity | Code | Message | Action | +| --- | --- | --- | --- | +| critical | `NO_BREAKING_CHANGE_CONFLICT` | Release note rn-101 says there are no breaking changes, but 1 changed artifact(s) are marked breaking. | `remove_no_breaking_claim_or_document_breaking_change` | +| high | `ALL_RESULTS_OVERCLAIM` | Release note rn-102 uses high-confidence wording without passed reproduction evidence. | `downgrade_claim_or_add_evidence` | +| high | `BREAKING_CHANGE_NOT_DISCLOSED` | Breaking artifact code/export/public-api.js has no explicit breaking-change release note. | `document_breaking_change_in_release_notes` | +| high | `CLAIM_EVIDENCE_NOT_PASSED` | Evidence ev-old-repro for release note rn-102 has status failed. | `replace_or_rerun_failed_evidence` | +| high | `CLAIM_KIND_EVIDENCE_MISSING` | Release note rn-101 is a api claim but lacks passed test evidence. | `attach_required_claim_evidence` | +| high | `CLAIM_KIND_EVIDENCE_MISSING` | Release note rn-101 is a api claim but lacks passed export evidence. | `attach_required_claim_evidence` | +| high | `CLAIM_KIND_EVIDENCE_MISSING` | Release note rn-102 is a reproducibility claim but lacks passed reproduction evidence. | `attach_required_claim_evidence` | +| high | `CLAIM_KIND_EVIDENCE_MISSING` | Release note rn-103 is a dataset claim but lacks passed checksum evidence. | `attach_required_claim_evidence` | +| high | `CLAIMED_COMPONENT_WITHOUT_CHANGE` | Release note rn-103 names data/public/cohort-clean.csv, but the release packet has no matching changed artifact. | `remove_claim_or_attach_changed_artifact` | +| high | `EXPORT_DIGEST_MISMATCH` | Export manifest digest for data/restricted/cohort-clean.csv does not match the changed artifact digest. | `update_export_manifest_digest` | +| high | `REPRODUCIBILITY_OVERCLAIM` | Release note rn-102 uses high-confidence wording without passed reproduction evidence. | `downgrade_claim_or_add_evidence` | +| high | `VALIDATION_OVERCLAIM` | Release note rn-102 uses high-confidence wording without passed test evidence. | `downgrade_claim_or_add_evidence` | +| medium | `CHANGE_DIGEST_NOT_UPDATED` | Changed artifact results/figures/figure-5.svg does not carry a new post-release digest. | `record_post_release_digest` | +| medium | `CHANGED_ARTIFACT_UNMENTIONED` | Changed artifact data/restricted/cohort-clean.csv is not represented in any public release note. | `add_release_note_or_mark_internal_only` | +| medium | `CLAIM_EVIDENCE_STALE` | Evidence ev-old-repro for release note rn-102 is 93 days old. | `refresh_release_evidence` | +| medium | `CLAIM_PATH_NOT_COVERED_BY_EVIDENCE` | Release note rn-102 cites results/figures/figure-5.svg, but no passed evidence covers that path. | `add_path_specific_release_evidence` | +| medium | `CLAIM_PATH_NOT_COVERED_BY_EVIDENCE` | Release note rn-103 cites data/public/cohort-clean.csv, but no passed evidence covers that path. | `add_path_specific_release_evidence` | +| medium | `EXPORT_MANIFEST_MISSING_RELEASE_NOTE` | Export manifest does not list public release note rn-103. | `add_release_note_to_export_manifest` | + +## Evidence Coverage + +| Release note | Claim type | Changed artifacts | Passed evidence | Required evidence | +| --- | --- | ---: | ---: | --- | +| rn-101 | api | 1 | 1 | test, export | +| rn-102 | reproducibility | 1 | 0 | reproduction | +| rn-103 | dataset | 0 | 1 | data-diff, checksum | + +Synthetic data only. No Git provider, DOI provider, CI system, private repository, payment account, credential, or external API is contacted. diff --git a/repository-release-note-claim-guard/reports/risky-packet.json b/repository-release-note-claim-guard/reports/risky-packet.json new file mode 100644 index 00000000..2699c40a --- /dev/null +++ b/repository-release-note-claim-guard/reports/risky-packet.json @@ -0,0 +1,439 @@ +{ + "input": { + "repositoryId": "scibase-demo-repo", + "versionTag": "preprint-v2.2.0", + "reviewedAt": "2026-06-01T12:00:00Z", + "policy": { + "maxEvidenceAgeDays": 30 + }, + "changeSet": [ + { + "id": "chg-api-export", + "path": "code/export/public-api.js", + "component": "code", + "digestBefore": "sha256:aaaa", + "digestAfter": "sha256:bbbb", + "noteIds": [ + "rn-101" + ], + "publicImpact": true, + "breaking": true + }, + { + "id": "chg-sensitive-data", + "path": "data/restricted/cohort-clean.csv", + "component": "data", + "digestBefore": "sha256:cccc", + "digestAfter": "sha256:dddd", + "publicImpact": true + }, + { + "id": "chg-figure", + "path": "results/figures/figure-5.svg", + "component": "results", + "digestBefore": "sha256:eeee", + "digestAfter": "sha256:eeee", + "noteIds": [ + "rn-102" + ], + "publicImpact": true + } + ], + "releaseNotes": [ + { + "id": "rn-101", + "claimType": "api", + "text": "Updated the public API export with no breaking changes.", + "componentPaths": [ + "code/export/public-api.js" + ], + "evidenceIds": [ + "ev-api-review" + ] + }, + { + "id": "rn-102", + "claimType": "reproducibility", + "text": "All results are now fully reproducible and validated.", + "componentPaths": [ + "results/figures/figure-5.svg" + ], + "evidenceIds": [ + "ev-old-repro" + ] + }, + { + "id": "rn-103", + "claimType": "dataset", + "text": "Released a cleaned cohort dataset for reviewers.", + "componentPaths": [ + "data/public/cohort-clean.csv" + ], + "evidenceIds": [ + "ev-dataset-diff" + ] + } + ], + "evidence": [ + { + "id": "ev-api-review", + "kind": "review", + "status": "passed", + "producedAt": "2026-05-31T10:00:00Z", + "coversPaths": [ + "code/export/public-api.js" + ] + }, + { + "id": "ev-old-repro", + "kind": "reproduction", + "status": "failed", + "producedAt": "2026-03-01T10:00:00Z", + "coversPaths": [ + "results/figures/figure-5.svg" + ] + }, + { + "id": "ev-dataset-diff", + "kind": "data-diff", + "status": "passed", + "producedAt": "2026-05-31T12:00:00Z", + "coversPaths": [ + "data/restricted/cohort-clean.csv" + ] + } + ], + "exportManifest": { + "versionTag": "preprint-v2.2.0", + "releaseNoteIds": [ + "rn-101", + "rn-102" + ], + "componentDigests": { + "code/export/public-api.js": "sha256:bbbb", + "results/figures/figure-5.svg": "sha256:eeee" + } + } + }, + "evaluation": { + "summary": { + "repositoryId": "scibase-demo-repo", + "versionTag": "preprint-v2.2.0", + "reviewedAt": "2026-06-01T12:00:00Z", + "decision": "hold_repository_release", + "topSeverity": "critical", + "releaseNotesReviewed": 3, + "changedArtifactsReviewed": 3, + "evidenceItemsReviewed": 3, + "findingCount": 18, + "highOrCriticalFindings": 12, + "auditDigest": "sha256:8431cbaec2db5bd8" + }, + "evidenceCoverage": [ + { + "noteId": "rn-101", + "claimType": "api", + "changedArtifacts": 1, + "evidenceIds": [ + "ev-api-review" + ], + "passedEvidence": 1, + "requiredKinds": [ + "test", + "export" + ] + }, + { + "noteId": "rn-102", + "claimType": "reproducibility", + "changedArtifacts": 1, + "evidenceIds": [ + "ev-old-repro" + ], + "passedEvidence": 0, + "requiredKinds": [ + "reproduction" + ] + }, + { + "noteId": "rn-103", + "claimType": "dataset", + "changedArtifacts": 0, + "evidenceIds": [ + "ev-dataset-diff" + ], + "passedEvidence": 1, + "requiredKinds": [ + "data-diff", + "checksum" + ] + } + ], + "findings": [ + { + "severity": "critical", + "code": "NO_BREAKING_CHANGE_CONFLICT", + "message": "Release note rn-101 says there are no breaking changes, but 1 changed artifact(s) are marked breaking.", + "refs": [ + "rn-101", + "chg-api-export" + ], + "action": "remove_no_breaking_claim_or_document_breaking_change" + }, + { + "severity": "high", + "code": "ALL_RESULTS_OVERCLAIM", + "message": "Release note rn-102 uses high-confidence wording without passed reproduction evidence.", + "refs": [ + "rn-102" + ], + "action": "downgrade_claim_or_add_evidence" + }, + { + "severity": "high", + "code": "BREAKING_CHANGE_NOT_DISCLOSED", + "message": "Breaking artifact code/export/public-api.js has no explicit breaking-change release note.", + "refs": [ + "chg-api-export" + ], + "action": "document_breaking_change_in_release_notes" + }, + { + "severity": "high", + "code": "CLAIM_EVIDENCE_NOT_PASSED", + "message": "Evidence ev-old-repro for release note rn-102 has status failed.", + "refs": [ + "rn-102", + "ev-old-repro" + ], + "action": "replace_or_rerun_failed_evidence" + }, + { + "severity": "high", + "code": "CLAIM_KIND_EVIDENCE_MISSING", + "message": "Release note rn-101 is a api claim but lacks passed test evidence.", + "refs": [ + "rn-101", + "test" + ], + "action": "attach_required_claim_evidence" + }, + { + "severity": "high", + "code": "CLAIM_KIND_EVIDENCE_MISSING", + "message": "Release note rn-101 is a api claim but lacks passed export evidence.", + "refs": [ + "rn-101", + "export" + ], + "action": "attach_required_claim_evidence" + }, + { + "severity": "high", + "code": "CLAIM_KIND_EVIDENCE_MISSING", + "message": "Release note rn-102 is a reproducibility claim but lacks passed reproduction evidence.", + "refs": [ + "rn-102", + "reproduction" + ], + "action": "attach_required_claim_evidence" + }, + { + "severity": "high", + "code": "CLAIM_KIND_EVIDENCE_MISSING", + "message": "Release note rn-103 is a dataset claim but lacks passed checksum evidence.", + "refs": [ + "rn-103", + "checksum" + ], + "action": "attach_required_claim_evidence" + }, + { + "severity": "high", + "code": "CLAIMED_COMPONENT_WITHOUT_CHANGE", + "message": "Release note rn-103 names data/public/cohort-clean.csv, but the release packet has no matching changed artifact.", + "refs": [ + "rn-103", + "data/public/cohort-clean.csv" + ], + "action": "remove_claim_or_attach_changed_artifact" + }, + { + "severity": "high", + "code": "EXPORT_DIGEST_MISMATCH", + "message": "Export manifest digest for data/restricted/cohort-clean.csv does not match the changed artifact digest.", + "refs": [ + "data/restricted/cohort-clean.csv" + ], + "action": "update_export_manifest_digest" + }, + { + "severity": "high", + "code": "REPRODUCIBILITY_OVERCLAIM", + "message": "Release note rn-102 uses high-confidence wording without passed reproduction evidence.", + "refs": [ + "rn-102" + ], + "action": "downgrade_claim_or_add_evidence" + }, + { + "severity": "high", + "code": "VALIDATION_OVERCLAIM", + "message": "Release note rn-102 uses high-confidence wording without passed test evidence.", + "refs": [ + "rn-102" + ], + "action": "downgrade_claim_or_add_evidence" + }, + { + "severity": "medium", + "code": "CHANGE_DIGEST_NOT_UPDATED", + "message": "Changed artifact results/figures/figure-5.svg does not carry a new post-release digest.", + "refs": [ + "chg-figure" + ], + "action": "record_post_release_digest" + }, + { + "severity": "medium", + "code": "CHANGED_ARTIFACT_UNMENTIONED", + "message": "Changed artifact data/restricted/cohort-clean.csv is not represented in any public release note.", + "refs": [ + "chg-sensitive-data" + ], + "action": "add_release_note_or_mark_internal_only" + }, + { + "severity": "medium", + "code": "CLAIM_EVIDENCE_STALE", + "message": "Evidence ev-old-repro for release note rn-102 is 93 days old.", + "refs": [ + "rn-102", + "ev-old-repro" + ], + "action": "refresh_release_evidence" + }, + { + "severity": "medium", + "code": "CLAIM_PATH_NOT_COVERED_BY_EVIDENCE", + "message": "Release note rn-102 cites results/figures/figure-5.svg, but no passed evidence covers that path.", + "refs": [ + "rn-102", + "results/figures/figure-5.svg" + ], + "action": "add_path_specific_release_evidence" + }, + { + "severity": "medium", + "code": "CLAIM_PATH_NOT_COVERED_BY_EVIDENCE", + "message": "Release note rn-103 cites data/public/cohort-clean.csv, but no passed evidence covers that path.", + "refs": [ + "rn-103", + "data/public/cohort-clean.csv" + ], + "action": "add_path_specific_release_evidence" + }, + { + "severity": "medium", + "code": "EXPORT_MANIFEST_MISSING_RELEASE_NOTE", + "message": "Export manifest does not list public release note rn-103.", + "refs": [ + "rn-103" + ], + "action": "add_release_note_to_export_manifest" + } + ], + "actions": [ + { + "id": "remove_no_breaking_claim_or_document_breaking_change", + "severity": "critical", + "refs": [ + "rn-101", + "chg-api-export" + ] + }, + { + "id": "downgrade_claim_or_add_evidence", + "severity": "high", + "refs": [ + "rn-102" + ] + }, + { + "id": "document_breaking_change_in_release_notes", + "severity": "high", + "refs": [ + "chg-api-export" + ] + }, + { + "id": "replace_or_rerun_failed_evidence", + "severity": "high", + "refs": [ + "rn-102", + "ev-old-repro" + ] + }, + { + "id": "attach_required_claim_evidence", + "severity": "high", + "refs": [ + "rn-101", + "test" + ] + }, + { + "id": "remove_claim_or_attach_changed_artifact", + "severity": "high", + "refs": [ + "rn-103", + "data/public/cohort-clean.csv" + ] + }, + { + "id": "update_export_manifest_digest", + "severity": "high", + "refs": [ + "data/restricted/cohort-clean.csv" + ] + }, + { + "id": "record_post_release_digest", + "severity": "medium", + "refs": [ + "chg-figure" + ] + }, + { + "id": "add_release_note_or_mark_internal_only", + "severity": "medium", + "refs": [ + "chg-sensitive-data" + ] + }, + { + "id": "refresh_release_evidence", + "severity": "medium", + "refs": [ + "rn-102", + "ev-old-repro" + ] + }, + { + "id": "add_path_specific_release_evidence", + "severity": "medium", + "refs": [ + "rn-102", + "results/figures/figure-5.svg" + ] + }, + { + "id": "add_release_note_to_export_manifest", + "severity": "medium", + "refs": [ + "rn-103" + ] + } + ] + } +} diff --git a/repository-release-note-claim-guard/reports/summary.svg b/repository-release-note-claim-guard/reports/summary.svg new file mode 100644 index 00000000..8a889f36 --- /dev/null +++ b/repository-release-note-claim-guard/reports/summary.svg @@ -0,0 +1,20 @@ + + + + + Repository Release Note Claim Guard + + hold_repository_release + Findings: 18 + High or critical: 12 + Audit digest: sha256:8431cbaec2db5bd8 + + Top Findings + +CRITICAL NO_BREAKING_CHANGE_CONFLICT +HIGH ALL_RESULTS_OVERCLAIM +HIGH BREAKING_CHANGE_NOT_DISCLOSED +HIGH CLAIM_EVIDENCE_NOT_PASSED +HIGH CLAIM_KIND_EVIDENCE_MISSING + + diff --git a/repository-release-note-claim-guard/sample-data.js b/repository-release-note-claim-guard/sample-data.js new file mode 100644 index 00000000..c2e9fd00 --- /dev/null +++ b/repository-release-note-claim-guard/sample-data.js @@ -0,0 +1,205 @@ +const cleanReleasePacket = { + repositoryId: "scibase-demo-repo", + versionTag: "preprint-v2.1.0", + reviewedAt: "2026-06-01T12:00:00Z", + policy: { + maxEvidenceAgeDays: 30 + }, + changeSet: [ + { + id: "chg-analysis-script", + path: "code/analysis/model-fit.py", + component: "code", + digestBefore: "sha256:1111", + digestAfter: "sha256:2222", + noteIds: ["rn-001"], + publicImpact: true + }, + { + id: "chg-results-figure", + path: "results/figures/figure-2.svg", + component: "results", + digestBefore: "sha256:3333", + digestAfter: "sha256:4444", + noteIds: ["rn-002"], + publicImpact: true + }, + { + id: "chg-metadata", + path: "metadata.json", + component: "metadata", + digestBefore: "sha256:5555", + digestAfter: "sha256:6666", + noteIds: ["rn-003"], + publicImpact: true + } + ], + releaseNotes: [ + { + id: "rn-001", + claimType: "code", + text: "Updated the mixed-effects model fitting script and validated it with repository tests.", + componentPaths: ["code/analysis/model-fit.py"], + evidenceIds: ["ev-tests-model"] + }, + { + id: "rn-002", + claimType: "figure", + text: "Regenerated Figure 2 from the refreshed analysis output with matching checksums.", + componentPaths: ["results/figures/figure-2.svg"], + evidenceIds: ["ev-repro-figure", "ev-checksum-figure"] + }, + { + id: "rn-003", + claimType: "metadata", + text: "Aligned repository metadata and export manifest with the preprint-v2.1.0 citation package.", + componentPaths: ["metadata.json"], + evidenceIds: ["ev-export-manifest", "ev-metadata-review"] + } + ], + evidence: [ + { + id: "ev-tests-model", + kind: "test", + status: "passed", + command: "npm run repository-tests", + producedAt: "2026-05-31T15:00:00Z", + coversPaths: ["code/analysis/model-fit.py"] + }, + { + id: "ev-repro-figure", + kind: "reproduction", + status: "passed", + command: "python code/analysis/model-fit.py --rebuild-figures", + producedAt: "2026-05-31T15:20:00Z", + coversPaths: ["code/analysis/model-fit.py", "results/figures/figure-2.svg"] + }, + { + id: "ev-checksum-figure", + kind: "checksum", + status: "passed", + producedAt: "2026-05-31T15:25:00Z", + coversPaths: ["results/figures/figure-2.svg"] + }, + { + id: "ev-export-manifest", + kind: "export", + status: "passed", + producedAt: "2026-05-31T16:00:00Z", + coversPaths: ["metadata.json", "code/analysis/model-fit.py", "results/figures/figure-2.svg"] + }, + { + id: "ev-metadata-review", + kind: "metadata", + status: "passed", + producedAt: "2026-05-31T16:05:00Z", + coversPaths: ["metadata.json"] + } + ], + exportManifest: { + versionTag: "preprint-v2.1.0", + releaseNoteIds: ["rn-001", "rn-002", "rn-003"], + componentDigests: { + "code/analysis/model-fit.py": "sha256:2222", + "results/figures/figure-2.svg": "sha256:4444", + "metadata.json": "sha256:6666" + } + } +}; + +const riskyReleasePacket = { + repositoryId: "scibase-demo-repo", + versionTag: "preprint-v2.2.0", + reviewedAt: "2026-06-01T12:00:00Z", + policy: { + maxEvidenceAgeDays: 30 + }, + changeSet: [ + { + id: "chg-api-export", + path: "code/export/public-api.js", + component: "code", + digestBefore: "sha256:aaaa", + digestAfter: "sha256:bbbb", + noteIds: ["rn-101"], + publicImpact: true, + breaking: true + }, + { + id: "chg-sensitive-data", + path: "data/restricted/cohort-clean.csv", + component: "data", + digestBefore: "sha256:cccc", + digestAfter: "sha256:dddd", + publicImpact: true + }, + { + id: "chg-figure", + path: "results/figures/figure-5.svg", + component: "results", + digestBefore: "sha256:eeee", + digestAfter: "sha256:eeee", + noteIds: ["rn-102"], + publicImpact: true + } + ], + releaseNotes: [ + { + id: "rn-101", + claimType: "api", + text: "Updated the public API export with no breaking changes.", + componentPaths: ["code/export/public-api.js"], + evidenceIds: ["ev-api-review"] + }, + { + id: "rn-102", + claimType: "reproducibility", + text: "All results are now fully reproducible and validated.", + componentPaths: ["results/figures/figure-5.svg"], + evidenceIds: ["ev-old-repro"] + }, + { + id: "rn-103", + claimType: "dataset", + text: "Released a cleaned cohort dataset for reviewers.", + componentPaths: ["data/public/cohort-clean.csv"], + evidenceIds: ["ev-dataset-diff"] + } + ], + evidence: [ + { + id: "ev-api-review", + kind: "review", + status: "passed", + producedAt: "2026-05-31T10:00:00Z", + coversPaths: ["code/export/public-api.js"] + }, + { + id: "ev-old-repro", + kind: "reproduction", + status: "failed", + producedAt: "2026-03-01T10:00:00Z", + coversPaths: ["results/figures/figure-5.svg"] + }, + { + id: "ev-dataset-diff", + kind: "data-diff", + status: "passed", + producedAt: "2026-05-31T12:00:00Z", + coversPaths: ["data/restricted/cohort-clean.csv"] + } + ], + exportManifest: { + versionTag: "preprint-v2.2.0", + releaseNoteIds: ["rn-101", "rn-102"], + componentDigests: { + "code/export/public-api.js": "sha256:bbbb", + "results/figures/figure-5.svg": "sha256:eeee" + } + } +}; + +module.exports = { + cleanReleasePacket, + riskyReleasePacket +}; diff --git a/repository-release-note-claim-guard/test.js b/repository-release-note-claim-guard/test.js new file mode 100644 index 00000000..bd6473bb --- /dev/null +++ b/repository-release-note-claim-guard/test.js @@ -0,0 +1,54 @@ +const assert = require("node:assert/strict"); +const { evaluateReleasePacket, renderMarkdownReport, renderSvgSummary } = require("./index"); +const { cleanReleasePacket, riskyReleasePacket } = require("./sample-data"); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function codes(evaluation) { + return new Set(evaluation.findings.map((finding) => finding.code)); +} + +const clean = evaluateReleasePacket(cleanReleasePacket); +assert.equal(clean.summary.decision, "publish_release_notes"); +assert.equal(clean.findings.length, 0); +assert.equal(clean.summary.highOrCriticalFindings, 0); + +const risky = evaluateReleasePacket(riskyReleasePacket); +assert.equal(risky.summary.decision, "hold_repository_release"); +assert.equal(risky.summary.highOrCriticalFindings > 0, true); +assert.equal(codes(risky).has("NO_BREAKING_CHANGE_CONFLICT"), true); +assert.equal(codes(risky).has("BREAKING_CHANGE_NOT_DISCLOSED"), true); +assert.equal(codes(risky).has("CLAIM_EVIDENCE_NOT_PASSED"), true); +assert.equal(codes(risky).has("CLAIMED_COMPONENT_WITHOUT_CHANGE"), true); +assert.equal(codes(risky).has("CHANGED_ARTIFACT_UNMENTIONED"), true); +assert.equal(codes(risky).has("EXPORT_DIGEST_MISMATCH"), true); + +const stale = clone(cleanReleasePacket); +stale.evidence[0].producedAt = "2025-12-01T00:00:00Z"; +const staleResult = evaluateReleasePacket(stale); +assert.equal(staleResult.summary.decision, "revise_release_notes"); +assert.equal(codes(staleResult).has("CLAIM_EVIDENCE_STALE"), true); + +const missingRequiredKind = clone(cleanReleasePacket); +missingRequiredKind.releaseNotes[0].claimType = "performance"; +const missingRequiredKindResult = evaluateReleasePacket(missingRequiredKind); +assert.equal(missingRequiredKindResult.summary.decision, "hold_repository_release"); +assert.equal(codes(missingRequiredKindResult).has("CLAIM_KIND_EVIDENCE_MISSING"), true); + +const missingExport = clone(cleanReleasePacket); +delete missingExport.exportManifest; +const missingExportResult = evaluateReleasePacket(missingExport); +assert.equal(missingExportResult.summary.decision, "hold_repository_release"); +assert.equal(codes(missingExportResult).has("EXPORT_MANIFEST_MISSING"), true); + +const markdown = renderMarkdownReport(riskyReleasePacket, risky); +assert.match(markdown, /Release Note Claim Evidence Review/); +assert.match(markdown, /hold_repository_release/); + +const svg = renderSvgSummary(risky); +assert.match(svg, /