Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit ec6eeadbc88f01c3fd43d861865d7a874fd0dc03
parent c407383f03aa83336de341c60ccc399bc5a4ed57
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 15:33:23 -0400

reports: per-report revision history — a bare git store beside report.json, a revision per changed export

reports export commits a revision (report.json, report.md, exports.json) to
sites/<site>/reports/<id>/history-git/ when report.json's sha256 changed since
the newest revision; the message is `Revision N` and a change summary. The
site is author and committer, dated in UTC, with git run in a minimal
environment. The footer names the revision and the report sha256; export.json
records the commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/lib/report/revisions.test.ts | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/report/revisions.ts | 334+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/publish/reportExportFiles.ts | 10++++++++++
Mcommon/publish/reportExports.ts | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Acommon/publish/reportHistory.test.ts | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/reportHistory.ts | 473+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1285 insertions(+), 14 deletions(-)

diff --git a/common/lib/report/revisions.test.ts b/common/lib/report/revisions.test.ts @@ -0,0 +1,144 @@ +// The revision diff and change summary (lib/report/revisions.ts), over two +// versions of a small fact-check. +// +// Run with: node_modules/.bin/tsx --test lib/report/revisions.test.ts + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { Report } from "./schema"; +import { + claimChanges, + reportChangeSummary, + revisionCommitMessage, + revisionSummaryOf, + wordDiff, + type DiffSegment, +} from "./revisions"; + +const side = (d: DiffSegment[], op: "old" | "new") => + d.filter((s) => s.op === "eq" || s.op === (op === "old" ? "del" : "ins")).map((s) => s.text).join(""); + +function v1(): Report { + return { + format: "archilyzer-report", + version: 1, + id: "demo", + kind: "factcheck", + title: "Checking a demo article", + subtitle: "Three claims", + published: "2026-03-01", + citations: { + c01: { kind: "video", channel: "demo-channel", id: "abc", start: 10, end: 20, quote: "The bridge opened in spring." }, + c02: { kind: "video", channel: "demo-channel", id: "abc", start: 30, end: 40, quote: "I was there." }, + c03: { kind: "page", url: "https://example.org/a", quote: "A page." }, + }, + sections: [ + { + id: "s1", + title: "The bridge", + claims: [ + { id: "k1", title: "When it opened", text: "The bridge opened in 2018.", verdict: "PARTLY", findings: "He says [spring](cite:c01).", citations: ["c01"] }, + { id: "k2", text: "He was not there.", verdict: "CONTRADICTED", findings: "He [was](cite:c02).", citations: ["c02"] }, + { id: "k3", text: "A third claim.", verdict: "UNTESTABLE", citations: ["c03"] }, + ], + }, + ], + }; +} + +function v2(): Report { + const r = v1(); + r.title = "Checking the demo article"; + r.series = "Demo checks"; + delete r.subtitle; + const claims = r.sections[0].claims!; + claims[0] = { ...claims[0], text: "The bridge opened in 2019.", verdict: "CORROBORATED" }; + claims[1] = { ...claims[1], findings: "He says he [was there](cite:c02)." }; + claims.splice(2, 1); + claims.push({ id: "k4", title: "The mail", text: "The mail came late.", verdict: "NOT_FOUND", citations: ["c04"] }); + r.citations!.c02 = { ...r.citations!.c02, quote: "I was there for it." }; + delete r.citations!.c03; + r.citations!.c04 = { kind: "page", url: "https://example.org/b", quote: "Another page." }; + return r; +} + +test("wordDiff: equal text is one run; a replaced word reads old then new; each side reassembles", () => { + assert.deepEqual(wordDiff("same words", "same words"), [{ op: "eq", text: "same words" }]); + const d = wordDiff("The bridge opened in 2018.", "The bridge opened in 2019."); + assert.deepEqual(d, [ + { op: "eq", text: "The bridge opened in " }, + { op: "del", text: "2018." }, + { op: "ins", text: "2019." }, + ]); + const a = "one two three four five six"; + const b = "one 2 three four six seven"; + const d2 = wordDiff(a, b); + assert.equal(side(d2, "old"), a); + assert.equal(side(d2, "new"), b); + assert.deepEqual(wordDiff("", "new text"), [{ op: "ins", text: "new text" }]); + assert.deepEqual(wordDiff("old text", ""), [{ op: "del", text: "old text" }]); +}); + +test("claimChanges: edited fields with word diffs, added and removed claims", () => { + const changes = claimChanges(v1(), v2()); + assert.deepEqual( + changes.map((c) => [c.id, c.status, c.fields.map((f) => f.field)]), + [ + ["k1", "edited", ["text", "verdict"]], + ["k2", "edited", ["findings"]], + ["k4", "added", ["title", "text", "verdict"]], + ["k3", "removed", ["text", "verdict"]], + ], + ); + const verdict = changes[0].fields.find((f) => f.field === "verdict")!; + assert.deepEqual(verdict.diff, [ + { op: "del", text: "PARTLY" }, + { op: "ins", text: "CORROBORATED" }, + ]); + assert.equal(changes[0].section, "The bridge"); + assert.ok(changes[2].fields.every((f) => f.diff.every((s) => s.op === "ins"))); + assert.ok(changes[3].fields.every((f) => f.diff.every((s) => s.op === "del"))); + assert.deepEqual(claimChanges(v1(), v1()), []); +}); + +test("reportChangeSummary: every kind of change, in a fixed order", () => { + assert.deepEqual(reportChangeSummary(v1(), v2()), [ + "Title changed: “Checking a demo article” → “Checking the demo article”", + "Series added: “Demo checks”", + "Subtitle removed (was “Three claims”)", + "Claim added: k4 “The mail”", + "Claim removed: k3 “A third claim.”", + "Verdict changed: k1 PARTLY → CORROBORATED", + "Claims edited: k1 (text), k2 (findings)", + "Citation added: c04", + "Citation removed: c03", + "Quote edited: c02", + ]); +}); + +test("reportChangeSummary: the first revision counts; an edit outside the list says so", () => { + assert.deepEqual(reportChangeSummary(null, v1()), ["First revision: 1 section, 3 claims, 3 citations."]); + const moved = v1(); + (moved.citations!.c01 as { start: number }).start = 11; + assert.deepEqual(reportChangeSummary(v1(), moved), ["Other edits: no title, claim, verdict, citation or quote changed"]); + const summary = v1(); + summary.summary = "A new summary."; + assert.deepEqual(reportChangeSummary(v1(), summary), ["Summary edited"]); +}); + +test("reportChangeSummary: long lists are cut, long labels shortened", () => { + const many = v1(); + for (let i = 0; i < 15; i++) { + many.sections[0].claims!.push({ id: `n${i}`, text: `${"x".repeat(100)} ${i}` }); + } + const [line] = reportChangeSummary(v1(), many); + assert.match(line, /^15 claims added: n0 “x{79}…”, /); + assert.match(line, / and 3 more$/); +}); + +test("the commit message carries the summary, and gives it back", () => { + const lines = reportChangeSummary(v1(), v2()); + const msg = revisionCommitMessage(2, lines); + assert.match(msg, /^Revision 2\n\n- Title changed/); + assert.deepEqual(revisionSummaryOf(msg), lines); +}); diff --git a/common/lib/report/revisions.ts b/common/lib/report/revisions.ts @@ -0,0 +1,334 @@ +// A REPORT'S REVISIONS — what changed between two versions of a report.json, +// as the revision history shows it (plans/report-sites.md, "History"). +// +// `archilyzer reports export` commits a revision to the report's own git +// history whenever its report.json changed (publish/reportHistory.ts); the +// commit message is `Revision N` and the lines of `reportChangeSummary`. The +// site's history page (`/reports/<id>/history/`) lists every revision with +// that summary and a claim-level word diff against the one before +// (`claimChanges`), both computed at compose into `history.json` +// (ReportHistoryView), so the page only renders. +// +// Pure, no imports but types: the export site's pages can use it. + +import type { Claim, Report } from "./schema"; + +export const REPORT_HISTORY_FORMAT = "archilyzer-report-history"; +export const REPORT_HISTORY_VERSION = 1; + +// The history's one branch, in the report's repository and its published clone. +export const REPORT_HISTORY_BRANCH = "main"; + +// ─── Where it is published (site-root paths) ─── + +export function reportHistoryPagePath(reportId: string): string { + return `/reports/${reportId}/history/`; +} + +export function reportHistoryViewPath(reportId: string): string { + return `/reports/${reportId}/history/history.json`; +} + +// The dumb-HTTP clone of the report's history: `git clone <site>/reports/<id>/history/repo`. +export function reportHistoryRepoPath(reportId: string): string { + return `/reports/${reportId}/history/repo`; +} + +// ─── Views ─── + +// One run of words in a diff: the same in both, inserted, or deleted. +export type DiffSegment = { op: "eq" | "ins" | "del"; text: string }; + +export const CLAIM_DIFF_FIELDS = ["title", "text", "verdict", "findings"] as const; +export type ClaimDiffField = (typeof CLAIM_DIFF_FIELDS)[number]; + +export type ClaimFieldDiff = { field: ClaimDiffField; diff: DiffSegment[] }; + +// A claim that changed between two revisions: added (its fields all +// inserted), removed (all deleted) or edited (each field that differs). +export type ClaimChange = { + id: string; + // The title of the section it is in (the newer revision's, for a removed + // claim the older's). + section: string; + status: "added" | "removed" | "edited"; + fields: ClaimFieldDiff[]; +}; + +export type ReportRevisionView = { + revision: number; + // The commit's date, ISO 8601 in UTC. + date: string; + // The commit's full hash. + commit: string; + // The sha256 of the revision's report.json — the hash every export's + // footer prints. + reportSha256: string; + // The change summary, one line each (the commit message's body). + summary: string[]; + // The claims that changed against the revision before; none for the first. + claims: ClaimChange[]; +}; + +export type ReportHistoryView = { + format: typeof REPORT_HISTORY_FORMAT; + version: typeof REPORT_HISTORY_VERSION; + reportId: string; + // The report's name as its newest revision has it. + series?: string; + title: string; + branch: string; + // What `git clone` takes: the repository's URL on the site, or its + // site-root path when the site has no `siteUrl`. + clone: string; + // Oldest first. + revisions: ReportRevisionView[]; +}; + +// What a report's page shows of its history: the newest revision, and +// whether the report as published is that revision (false when report.json +// changed after it and was not exported again). +export type ReportHistoryRef = { + revision: number; + date: string; + href: string; + current: boolean; +}; + +// ─── The word diff ─── + +// Words and the whitespace between them, each a token. +export function diffTokens(text: string): string[] { + return text.match(/\s+|[^\s]+/g) ?? []; +} + +// Above this many token pairs the diff gives up on alignment: the whole old +// text deleted, the whole new text inserted. +export const WORD_DIFF_MAX_CELLS = 4_000_000; + +function push(out: DiffSegment[], op: DiffSegment["op"], text: string): void { + if (!text) return; + const last = out[out.length - 1]; + if (last && last.op === op) last.text += text; + else out.push({ op, text }); +} + +// `a` → `b` as runs of words kept, deleted and inserted: a longest common +// subsequence over word and whitespace tokens, the common ends trimmed first. +// Adjacent runs of one kind are merged; an equal text is one `eq` run. +export function wordDiff(a: string, b: string): DiffSegment[] { + const out: DiffSegment[] = []; + if (a === b) { + push(out, "eq", a); + return out; + } + const x = diffTokens(a); + const y = diffTokens(b); + let pre = 0; + while (pre < x.length && pre < y.length && x[pre] === y[pre]) pre++; + let suf = 0; + while (suf < x.length - pre && suf < y.length - pre && x[x.length - 1 - suf] === y[y.length - 1 - suf]) suf++; + const xs = x.slice(pre, x.length - suf); + const ys = y.slice(pre, y.length - suf); + push(out, "eq", x.slice(0, pre).join("")); + const n = xs.length; + const m = ys.length; + if (n === 0 || m === 0 || n * m > WORD_DIFF_MAX_CELLS) { + push(out, "del", xs.join("")); + push(out, "ins", ys.join("")); + } else { + // lcs[i][j] = the LCS length of xs[i..] and ys[j..]. + const w = m + 1; + const lcs = new Uint32Array((n + 1) * w); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + lcs[i * w + j] = xs[i] === ys[j] ? lcs[(i + 1) * w + j + 1] + 1 : Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]); + } + } + // Deletions before insertions within a change, so a replaced word reads + // old-then-new. + let i = 0; + let j = 0; + let del = ""; + let ins = ""; + const flush = () => { + push(out, "del", del); + push(out, "ins", ins); + del = ""; + ins = ""; + }; + while (i < n || j < m) { + if (i < n && j < m && xs[i] === ys[j]) { + flush(); + push(out, "eq", xs[i]); + i++; + j++; + } else if (j < m && (i >= n || lcs[i * w + j + 1] >= lcs[(i + 1) * w + j])) { + ins += ys[j++]; + } else { + del += xs[i++]; + } + } + flush(); + } + push(out, "eq", x.slice(x.length - suf).join("")); + return out; +} + +// ─── Claims ─── + +type PlacedClaim = { claim: Claim; section: string }; + +function claimsById(report: Report): Map<string, PlacedClaim> { + const out = new Map<string, PlacedClaim>(); + for (const s of report.sections) for (const claim of s.claims ?? []) out.set(claim.id, { claim, section: s.title }); + return out; +} + +function claimField(c: Claim, f: ClaimDiffField): string { + return (c[f] ?? "").toString(); +} + +// Every claim that differs between `prev` and `next` in its title, text, +// verdict or findings, added or removed — in the newer revision's order, the +// removed ones after, in the older's. +export function claimChanges(prev: Report, next: Report): ClaimChange[] { + const before = claimsById(prev); + const after = claimsById(next); + const out: ClaimChange[] = []; + for (const [id, { claim, section }] of after) { + const old = before.get(id); + if (!old) { + out.push({ + id, + section, + status: "added", + fields: CLAIM_DIFF_FIELDS.filter((f) => claimField(claim, f)).map((f) => ({ field: f, diff: wordDiff("", claimField(claim, f)) })), + }); + continue; + } + const fields = CLAIM_DIFF_FIELDS.filter((f) => claimField(old.claim, f) !== claimField(claim, f)).map((f) => ({ + field: f, + diff: wordDiff(claimField(old.claim, f), claimField(claim, f)), + })); + if (fields.length > 0) out.push({ id, section, status: "edited", fields }); + } + for (const [id, { claim, section }] of before) { + if (after.has(id)) continue; + out.push({ + id, + section, + status: "removed", + fields: CLAIM_DIFF_FIELDS.filter((f) => claimField(claim, f)).map((f) => ({ field: f, diff: wordDiff(claimField(claim, f), "") })), + }); + } + return out; +} + +// ─── The change summary ─── + +// A claim as one line names it: its title, else its text, cut short. +export const SUMMARY_LABEL_MAX = 80; +// At most this many claims or citations are named in one line. +export const SUMMARY_LIST_MAX = 12; + +function short(s: string): string { + const one = s.replace(/\s+/g, " ").trim(); + return one.length > SUMMARY_LABEL_MAX ? `${one.slice(0, SUMMARY_LABEL_MAX - 1)}…` : one; +} + +function claimLabel(c: Claim): string { + return `${c.id} “${short(c.title || c.text)}”`; +} + +function list(items: string[]): string { + const shown = items.slice(0, SUMMARY_LIST_MAX).join(", "); + return items.length > SUMMARY_LIST_MAX ? `${shown} and ${items.length - SUMMARY_LIST_MAX} more` : shown; +} + +function countClaims(r: Report): number { + return r.sections.reduce((n, s) => n + (s.claims?.length ?? 0), 0); +} + +const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; + +function headerChange(name: string, a: string | undefined, b: string | undefined): string | null { + if ((a ?? "") === (b ?? "")) return null; + if (!a) return `${name} added: “${short(b!)}”`; + if (!b) return `${name} removed (was “${short(a)}”)`; + return `${name} changed: “${short(a)}” → “${short(b)}”`; +} + +// What changed from `prev` to `next`, one line each, in a fixed order: the +// title, series and subtitle; claims added and removed; verdicts changed; +// claims whose title, text or findings were edited; citations added and +// removed; quotes edited; the summary and method. The first revision +// (`prev` null) is one line counting what it holds. A change outside all of +// these (a citation's span, a source, formatting) is one line saying so. +export function reportChangeSummary(prev: Report | null, next: Report): string[] { + if (!prev) { + return [ + `First revision: ${plural(next.sections.length, "section")}, ${plural(countClaims(next), "claim")}, ` + + `${plural(Object.keys(next.citations ?? {}).length, "citation")}.`, + ]; + } + const out: string[] = []; + for (const line of [ + headerChange("Title", prev.title, next.title), + headerChange("Series", prev.series, next.series), + headerChange("Subtitle", prev.subtitle, next.subtitle), + ]) { + if (line) out.push(line); + } + + const before = claimsById(prev); + const after = claimsById(next); + const added = [...after.values()].filter((c) => !before.has(c.claim.id)).map((c) => claimLabel(c.claim)); + const removed = [...before.values()].filter((c) => !after.has(c.claim.id)).map((c) => claimLabel(c.claim)); + if (added.length) out.push(`${added.length === 1 ? "Claim" : `${added.length} claims`} added: ${list(added)}`); + if (removed.length) out.push(`${removed.length === 1 ? "Claim" : `${removed.length} claims`} removed: ${list(removed)}`); + const verdicts: string[] = []; + const edited: string[] = []; + for (const [id, { claim }] of after) { + const old = before.get(id)?.claim; + if (!old) continue; + if ((old.verdict ?? "") !== (claim.verdict ?? "")) { + verdicts.push(`${id} ${old.verdict ?? "none"} → ${claim.verdict ?? "none"}`); + } + const fields = (["title", "text", "findings"] as const).filter((f) => (old[f] ?? "") !== (claim[f] ?? "")); + if (fields.length) edited.push(`${id} (${fields.join(", ")})`); + } + if (verdicts.length) out.push(`Verdict${verdicts.length === 1 ? "" : "s"} changed: ${list(verdicts)}`); + if (edited.length) out.push(`Claim${edited.length === 1 ? "" : "s"} edited: ${list(edited)}`); + + const cBefore = prev.citations ?? {}; + const cAfter = next.citations ?? {}; + const cAdded = Object.keys(cAfter).filter((id) => !(id in cBefore)); + const cRemoved = Object.keys(cBefore).filter((id) => !(id in cAfter)); + const quotes = Object.keys(cAfter).filter((id) => id in cBefore && cBefore[id].quote !== cAfter[id].quote); + if (cAdded.length) out.push(`Citation${cAdded.length === 1 ? "" : "s"} added: ${list(cAdded)}`); + if (cRemoved.length) out.push(`Citation${cRemoved.length === 1 ? "" : "s"} removed: ${list(cRemoved)}`); + if (quotes.length) out.push(`Quote${quotes.length === 1 ? "" : "s"} edited: ${list(quotes)}`); + + if ((prev.summary ?? "") !== (next.summary ?? "")) out.push("Summary edited"); + if ((prev.method ?? "") !== (next.method ?? "")) out.push("Method edited"); + if (out.length === 0) out.push("Other edits: no title, claim, verdict, citation or quote changed"); + return out; +} + +// A revision's commit message: `Revision N`, a blank line, then the summary, +// one `- ` line each. +export function revisionCommitMessage(revision: number, summary: readonly string[]): string { + return `Revision ${revision}\n\n${summary.map((l) => `- ${l}`).join("\n")}\n`; +} + +// The summary lines back out of a commit message `revisionCommitMessage` +// wrote (anything else: its non-empty lines after the subject). +export function revisionSummaryOf(message: string): string[] { + return message + .split("\n") + .slice(1) + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => l.replace(/^- /, "")); +} diff --git a/common/publish/reportExportFiles.ts b/common/publish/reportExportFiles.ts @@ -58,6 +58,16 @@ export type ReportExportManifest = { reportSha256: string; exportedAt: string; footer: ReportExportFooter; + // The revision these exports belong to (publish/reportHistory.ts): its + // number, commit, date and change summary, and whether this export made + // it. Absent when the history could not be read or written. + revision?: { + revision: number; + commit: string; + date: string; + summary: string[]; + committed: boolean; + }; files: Partial<Record<ReportExportFormat, ReportExportFileEntry>>; // What was skipped, and why (a PDF with no browser, a pack with no `zip`). notes: string[]; diff --git a/common/publish/reportExports.ts b/common/publish/reportExports.ts @@ -40,9 +40,18 @@ // local). // // THE FOOTER (lib/report/exportHtml.ts ReportExportFooter) names which document -// this is: today the report's date and the sha256 of its report.json; -// `exportFooterFor` below is where the revision history (slice RH) fills in -// the revision number and the commit. +// this is: `Revision N`, the report's date and the sha256 of its report.json +// (`exportFooterFor`). It never names a commit: the revision's commit holds +// report.md, whose footer would have to name the commit holding it. The +// history page maps the sha256 to its commit (./reportHistory.ts). +// +// THE REVISION. Each report's export commits a revision to the report's own +// history (./reportHistory.ts, `sites/<siteId>/reports/<id>/history-git/`) +// when its report.json changed since the newest revision: report.json, +// report.md and exports.json (the sha256 of every file made and of the +// citations JSON and CSV), with the change summary as the message. An export +// of an unchanged report commits nothing and belongs to the newest revision. +// export.json records the revision and its commit. import { chmod, mkdir, mkdtemp, readdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; import os from "node:os"; @@ -79,12 +88,17 @@ import { REPORT_EXPORT_MANIFEST_VERSION, reportExportDir, reportExportsDir, - reportFileSha256, sha256Hex, type ReportExportFileEntry, type ReportExportManifest, } from "./reportExportFiles"; -import { siteReportDir, type ReportMediaIndex } from "./reportMedia"; +import { siteReportDir, siteReportFile, type ReportMediaIndex } from "./reportMedia"; +import { + commitReportRevision, + pendingRevision, + reportHistoryGitDir, + revisionExports, +} from "./reportHistory"; // A still or a screenshot in report.html: at most this wide, recompressed. export const EXPORT_IMAGE_MAX_WIDTH = 1200; @@ -150,13 +164,17 @@ export function parseReportExportFormats(v: string): ReportExportFormat[] | null return REPORT_EXPORT_FORMATS.filter((f) => parts.includes(f)); } -// THE FOOTER HOOK. Today: the report's own date and the sha256 of its -// report.json. The revision history (slice RH) adds `revision` and `commit` -// here — nothing else changes: the HTML, PDF and Markdown all print +// THE FOOTER: the revision this export belongs to (when the report has a +// history), the report's own date and the sha256 of its report.json — never +// the commit (see the header). The HTML, PDF and Markdown all print // reportExportFooterLine of what this returns, leaving out what is absent. -export function exportFooterFor(view: Pick<ReportPageView, "published" | "updated">, reportSha256: string): ReportExportFooter { +export function exportFooterFor( + view: Pick<ReportPageView, "published" | "updated">, + reportSha256: string, + revision?: number, +): ReportExportFooter { const date = view.updated ?? view.published; - return { ...(date ? { date } : {}), reportSha256 }; + return { ...(revision !== undefined ? { revision } : {}), ...(date ? { date } : {}), reportSha256 }; } // ─── PDF ─── @@ -393,14 +411,15 @@ async function exportOneReport(o: { signal?: AbortSignal; }): Promise<{ result?: ReportExportResult; problems: ReportExportProblem[] }> { const { paths, site, report, view } = o; - const sha = await reportFileSha256(paths, site.siteId, report.id); - if (!sha) return { problems: [{ report: report.id, message: "its report.json cannot be read" }] }; + const reportJson = await readFile(siteReportFile(paths, site.siteId, report.id)).catch(() => null); + if (!reportJson) return { problems: [{ report: report.id, message: "its report.json cannot be read" }] }; return writeReportExports({ ...o, dir: reportExportDir(paths, site.siteId, report.id), - reportSha256: sha, + reportSha256: sha256Hex(reportJson), files: reportFiles(paths, site.siteId, view, o.resolved), ffmpegBin: paths.ffmpegBin, + history: { gitDir: reportHistoryGitDir(paths, site.siteId, report.id), reportJson }, }); } @@ -409,6 +428,9 @@ async function exportOneReport(o: { // calls it for each report; the report-site e2e stage calls it for its // fixture. `report` is the document as resolved (its verification // computed): the evidence pack's citations.json is its citation set. +// `history` names the report's revision store and the report.json's bytes +// (their sha256 is `reportSha256`): the footer names the revision, and a +// changed report.json is committed as the next one. export async function writeReportExports(o: { dir: string; site: Pick<Site, "siteId" | "siteUrl" | "siteTitle">; @@ -423,11 +445,21 @@ export async function writeReportExports(o: { now: Date; log: (line: string) => void; signal?: AbortSignal; + history?: { gitDir: string; reportJson: Uint8Array }; }): Promise<{ result: ReportExportResult; problems: ReportExportProblem[] }> { const { site, report, view, files } = o; const problems: ReportExportProblem[] = []; const sha = o.reportSha256; - const footer = exportFooterFor(view, sha); + let pending: Awaited<ReturnType<typeof pendingRevision>> | null = null; + if (o.history) { + if (sha256Hex(o.history.reportJson) !== sha) throw new Error(`report ${report.id}: the history's report.json is not the one exported`); + try { + pending = await pendingRevision(o.history.gitDir, sha); + } catch (e) { + problems.push({ report: report.id, message: `its revision history cannot be read: ${firstLine(e)}` }); + } + } + const footer = exportFooterFor(view, sha, pending?.revision); const siteUrl = site.siteUrl || undefined; const siteTitle = site.siteTitle || undefined; @@ -517,6 +549,35 @@ export async function writeReportExports(o: { } } + // The revision: committed when report.json changed since the newest. + let revision: ReportExportManifest["revision"]; + if (o.history && pending) { + try { + const made: Record<string, Uint8Array | string> = {}; + for (const f of REPORT_EXPORT_FORMATS) { + if (entries[f]) made[REPORT_EXPORT_FILENAMES[f]] = await readFile(path.join(dir, REPORT_EXPORT_FILENAMES[f])); + } + made["citations.json"] = `${JSON.stringify(citationSet(report, view), null, 2)}\n`; + made["citations.csv"] = citationsCsv(view); + const r = await commitReportRevision({ + gitDir: o.history.gitDir, + site, + reportJson: o.history.reportJson, + markdown, + exports: revisionExports(report.id, sha, made), + now: o.now, + }); + revision = { revision: r.revision, commit: r.commit, date: r.date, summary: r.summary, committed: r.committed }; + o.log( + r.committed + ? ` + ${report.id}: revision ${r.revision} (${r.commit.slice(0, 12)}): ${r.summary.join("; ")}` + : ` = ${report.id}: unchanged since revision ${r.revision} (${r.commit.slice(0, 12)})`, + ); + } catch (e) { + problems.push({ report: report.id, message: `its revision could not be committed: ${firstLine(e)}` }); + } + } + const manifest: ReportExportManifest = { format: REPORT_EXPORT_MANIFEST_FORMAT, version: REPORT_EXPORT_MANIFEST_VERSION, @@ -525,6 +586,7 @@ export async function writeReportExports(o: { reportSha256: sha, exportedAt: o.now.toISOString(), footer, + ...(revision ? { revision } : {}), files: entries, notes, }; diff --git a/common/publish/reportHistory.test.ts b/common/publish/reportHistory.test.ts @@ -0,0 +1,248 @@ +// A report's revision history (publish/reportHistory.ts): commits only on a +// change, the site as author and committer in UTC with nothing of the +// operator's, the history view, and the published dumb-HTTP clone — served by +// the export's own static server and cloned with git. +// +// Run with: node_modules/.bin/tsx --test publish/reportHistory.test.ts + +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + REPORT_HISTORY_REPO_FILE_RE, + commitReportRevision, + historyGitEnv, + pendingRevision, + publishReportHistory, + readReportHistoryView, + readRevisionHead, + reportHistoryRef, + revisionExports, +} from "./reportHistory"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SERVE_OUT = path.resolve(HERE, "../../export/scripts/serve-out.mjs"); +const ROOT = mkdtempSync(path.join(os.tmpdir(), "report-history-")); +after(() => rmSync(ROOT, { recursive: true, force: true })); + +const SITE = { siteId: "demo-site", siteTitle: "Demo Reports" }; +const sha = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex"); + +function reportJson(over: { title?: string; text?: string; verdict?: string } = {}): Buffer { + return Buffer.from( + `${JSON.stringify( + { + format: "archilyzer-report", + version: 1, + id: "demo", + kind: "factcheck", + title: over.title ?? "Checking a demo article", + citations: { c01: { kind: "page", url: "https://example.org/a", quote: "A page." } }, + sections: [ + { + id: "s1", + title: "The bridge", + claims: [{ id: "k1", text: over.text ?? "The bridge opened in 2018.", verdict: over.verdict ?? "PARTLY", citations: ["c01"] }], + }, + ], + }, + null, + 2, + )}\n`, + ); +} + +const gitLog = (gitDir: string, format: string) => + execFileSync("git", ["--git-dir", gitDir, "log", `--format=${format}`, "refs/heads/main"], { + env: historyGitEnv(), + encoding: "utf8", + }).trim(); + +async function commit(gitDir: string, data: Buffer, now: string) { + return commitReportRevision({ + gitDir, + site: SITE, + reportJson: data, + markdown: `# ${JSON.parse(data.toString()).title}\n`, + exports: revisionExports("demo", sha(data), { "report.md": "# md\n", "citations.csv": "a,b\n" }), + now: new Date(now), + }); +} + +// The operator's identity as git and the environment would give it, when +// there is one: none of it may reach a commit. +function operatorStrings(): string[] { + const out = new Set<string>(["Leaky Operator", "leaky@operator.example", "Leaky Committer"]); + for (const key of ["user.name", "user.email"]) { + try { + const v = execFileSync("git", ["config", "--global", key], { encoding: "utf8" }).trim(); + if (v) out.add(v); + } catch { + // unset + } + } + const user = os.userInfo().username; + if (user && user.length > 2) out.add(user); + return [...out]; +} + +test("a revision is committed only when report.json changed", async () => { + const gitDir = path.join(ROOT, "only-on-change", "history-git"); + assert.deepEqual(await pendingRevision(gitDir, sha(reportJson())), { revision: 1, changed: true, head: null }); + const r1 = await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z"); + assert.equal(r1.revision, 1); + assert.equal(r1.committed, true); + assert.deepEqual(r1.summary, ["First revision: 1 section, 1 claim, 1 citation."]); + const again = await commit(gitDir, reportJson(), "2026-03-02T12:00:00Z"); + assert.equal(again.committed, false); + assert.equal(again.revision, 1); + assert.equal(again.commit, r1.commit); + assert.equal(gitLog(gitDir, "%H").split("\n").length, 1, "no second commit"); + const r2 = await commit(gitDir, reportJson({ text: "The bridge opened in 2019.", verdict: "CORROBORATED" }), "2026-03-08T12:00:00Z"); + assert.equal(r2.revision, 2); + assert.deepEqual(r2.summary, ["Verdict changed: k1 PARTLY → CORROBORATED", "Claim edited: k1 (text)"]); + const head = await readRevisionHead(gitDir); + assert.equal(head?.commit, r2.commit); + assert.equal(head?.date, "2026-03-08T12:00:00Z"); + assert.deepEqual(gitLog(gitDir, "%s").split("\n"), ["Revision 2", "Revision 1"]); + // A revision holds report.json byte for byte, report.md and exports.json. + const tree = execFileSync("git", ["--git-dir", gitDir, "ls-tree", "--name-only", "refs/heads/main"], { + env: historyGitEnv(), + encoding: "utf8", + }); + assert.deepEqual(tree.trim().split("\n"), ["exports.json", "report.json", "report.md"]); + const blob = execFileSync("git", ["--git-dir", gitDir, "cat-file", "blob", "refs/heads/main:report.json"], { env: historyGitEnv() }); + assert.equal(sha(blob), r2.reportSha256); + const exportsJson = JSON.parse( + execFileSync("git", ["--git-dir", gitDir, "cat-file", "blob", "refs/heads/main:exports.json"], { env: historyGitEnv(), encoding: "utf8" }), + ); + assert.equal(exportsJson.format, "archilyzer-report-revision-exports"); + assert.equal(exportsJson.reportSha256, r2.reportSha256); + assert.deepEqual(exportsJson.files["citations.csv"], { bytes: 4, sha256: sha("a,b\n") }); +}); + +test("the site is author and committer, dated in UTC; nothing of the operator's is in a commit", async () => { + const saved = { ...process.env }; + // What a careless git invocation would pick up from this process. + Object.assign(process.env, { + GIT_AUTHOR_NAME: "Leaky Operator", + GIT_AUTHOR_EMAIL: "leaky@operator.example", + GIT_COMMITTER_NAME: "Leaky Committer", + GIT_COMMITTER_EMAIL: "leaky@operator.example", + EMAIL: "leaky@operator.example", + TZ: "America/Chicago", + GIT_AUTHOR_DATE: "2001-01-01T00:00:00-0600", + }); + const gitDir = path.join(ROOT, "identity", "history-git"); + try { + await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z"); + await commit(gitDir, reportJson({ title: "Checking the demo article" }), "2026-03-08T18:30:05Z"); + } finally { + for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k]; + Object.assign(process.env, saved); + } + assert.equal( + gitLog(gitDir, "%an|%ae|%cn|%ce|%ad|%cd").replace(/\n/g, "\n"), + [ + `Demo Reports|noreply@demo-site.invalid|Demo Reports|noreply@demo-site.invalid|Sun Mar 8 18:30:05 2026 +0000|Sun Mar 8 18:30:05 2026 +0000`, + `Demo Reports|noreply@demo-site.invalid|Demo Reports|noreply@demo-site.invalid|Sun Mar 1 12:00:00 2026 +0000|Sun Mar 1 12:00:00 2026 +0000`, + ].join("\n"), + ); + const raw = execFileSync("git", ["--git-dir", gitDir, "cat-file", "--batch-all-objects", "--batch"], { env: historyGitEnv() }).toString(); + for (const s of operatorStrings()) assert.ok(!raw.includes(s), `a commit names the operator (${s.length} chars)`); + assert.doesNotMatch(raw, /gpgsig/); + assert.doesNotMatch(raw, /[+-](?!0000)\d{4}\n/, "every date is +0000"); +}); + +test("the history view: each revision's hashes, summary and claim diff; the page's ref", async () => { + const gitDir = path.join(ROOT, "view", "history-git"); + const r1 = await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z"); + const r2 = await commit(gitDir, reportJson({ text: "The bridge opened in 2019." }), "2026-03-08T12:00:00Z"); + const view = await readReportHistoryView(gitDir, { reportId: "demo", siteUrl: "https://reports.example.org/" }); + assert.ok(view); + assert.equal(view.clone, "https://reports.example.org/reports/demo/history/repo"); + assert.deepEqual( + view.revisions.map((r) => [r.revision, r.commit, r.reportSha256, r.date]), + [ + [1, r1.commit, r1.reportSha256, "2026-03-01T12:00:00Z"], + [2, r2.commit, r2.reportSha256, "2026-03-08T12:00:00Z"], + ], + ); + assert.deepEqual(view.revisions[0].claims, []); + assert.deepEqual(view.revisions[1].summary, ["Claim edited: k1 (text)"]); + assert.deepEqual(view.revisions[1].claims[0].fields[0].diff, [ + { op: "eq", text: "The bridge opened in " }, + { op: "del", text: "2018." }, + { op: "ins", text: "2019." }, + ]); + assert.deepEqual(reportHistoryRef(view, r2.reportSha256), { + revision: 2, + date: "2026-03-08T12:00:00Z", + href: "/reports/demo/history/", + current: true, + }); + assert.equal(reportHistoryRef(view, sha("edited")).current, false); + const relative = await readReportHistoryView(gitDir, { reportId: "demo" }); + assert.equal(relative?.clone, "/reports/demo/history/repo"); + assert.equal(await readReportHistoryView(path.join(ROOT, "none"), { reportId: "demo" }), null); +}); + +async function freePort(): Promise<number> { + return new Promise((resolve, reject) => { + const s = net.createServer(); + s.once("error", reject); + s.listen(0, "127.0.0.1", () => { + const { port } = s.address() as net.AddressInfo; + s.close(() => resolve(port)); + }); + }); +} + +test("the published clone: an allowlisted dumb-HTTP repository that clones over HTTP to the same report.json", async () => { + const gitDir = path.join(ROOT, "clone", "history-git"); + await commit(gitDir, reportJson(), "2026-03-01T12:00:00Z"); + await commit(gitDir, reportJson({ verdict: "CONTRADICTED" }), "2026-03-08T12:00:00Z"); + const view = await readReportHistoryView(gitDir, { reportId: "demo" }); + assert.ok(view); + const publicDir = path.join(ROOT, "clone", "public"); + const files = await publishReportHistory({ gitDir, publicDir, view }); + for (const f of files) assert.match(f, REPORT_HISTORY_REPO_FILE_RE); + assert.ok(files.includes("info/refs") && files.includes("objects/info/packs")); + assert.ok(files.some((f) => f.endsWith(".pack"))); + const published = JSON.parse(readFileSync(path.join(publicDir, "reports", "demo", "history", "history.json"), "utf8")); + assert.deepEqual(published, view); + + const port = await freePort(); + const server = spawn(process.execPath, [SERVE_OUT, publicDir, String(port)], { + env: { ...process.env, HOST: "127.0.0.1" }, + stdio: ["ignore", "pipe", "pipe"], + }); + try { + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.once("exit", (code) => reject(new Error(`serve-out exited ${code}`))); + server.stdout.on("data", (c: Buffer) => { + if (c.toString().includes("serving")) resolve(); + }); + }); + const dest = path.join(ROOT, "clone", "cloned"); + execFileSync("git", ["clone", "--quiet", `http://127.0.0.1:${port}/reports/demo/history/repo`, dest], { + env: historyGitEnv(), + stdio: "pipe", + }); + const last = view.revisions[view.revisions.length - 1]; + assert.equal(sha(readFileSync(path.join(dest, "report.json"))), last.reportSha256); + const head = execFileSync("git", ["-C", dest, "rev-parse", "HEAD"], { env: historyGitEnv(), encoding: "utf8" }).trim(); + assert.equal(head, last.commit); + const first = execFileSync("git", ["-C", dest, "show", `${view.revisions[0].commit}:report.json`], { env: historyGitEnv() }); + assert.equal(sha(first), view.revisions[0].reportSha256); + } finally { + server.kill(); + } +}); diff --git a/common/publish/reportHistory.ts b/common/publish/reportHistory.ts @@ -0,0 +1,473 @@ +// A REPORT'S REVISION HISTORY — per report, never site-wide (plans/report-sites.md, +// "History"). Readers can check that a report is the one published, and see +// every edit with its hashes. +// +// THE STORE. A bare git repository beside the report's report.json: +// `sites/<siteId>/reports/<reportId>/history-git/` (never a path segment named +// `.git` — wrangler drops one). It sits inside the corpus's own tree; the +// operator adds `history-git/` to that tree's .gitignore (nothing here writes +// it). One branch, `main`; one commit per revision, holding +// +// report.json the report.json as it was on disk, byte for byte +// report.md the Markdown export of that revision (lib/report/exportMarkdown.ts) +// exports.json the sha256 and size of every export file made for that +// revision and of its citations JSON and CSV +// +// with the message `Revision N` and the change summary against the revision +// before (lib/report/revisions.ts reportChangeSummary). +// +// WHEN A REVISION IS MADE. `reports export` (./reportExports.ts) commits one +// when the sha256 of report.json differs from the newest revision's; an export +// of an unchanged report commits nothing (its files may still differ — a PDF +// is not byte-stable — and exports.json keeps the hashes of the revision's own +// export). +// +// WHO AND WHEN. Every commit's author and committer is the SITE — its title, +// `noreply@<siteId>.invalid` — dated in UTC (`+0000`). git runs with a minimal +// environment: no system or global config, HOME pointing nowhere, none of the +// operator's GIT_* variables (cleanGitEnv) — so no operator name, email, time +// zone or signing key reaches a commit. +// +// THE FOOTER SCHEME. An export's footer (`exportFooterFor`) prints `Revision N` +// and the sha256 of the report.json it was made from — never the commit: a +// commit holds report.md, whose footer would have to name the commit holding +// it. The history page and history.json map each revision's report sha256 to +// its commit, so a reader goes footer → sha256 → commit. export.json (the +// local manifest) records the commit once it is made. +// +// PUBLISHING. Compose (./composeReports.ts) reads the revisions into +// `history.json` (ReportHistoryView) and stages a dumb-HTTP clone at +// `reports/<id>/history/repo/`, built as the source mirror is +// (./source.ts): a fresh bare clone, repacked, `update-server-info`, then an +// ALLOWLISTED copy — HEAD, packed-refs, info/refs, objects/info/packs, the +// packs, refs/heads/main — so no config, hook or log of the store is +// published. + +import { createHash } from "node:crypto"; +import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execa } from "execa"; +import { publishFileSizeProblem } from "../lib/builtExport"; +import type { Paths } from "../lib/paths"; +import type { Site } from "../lib/site"; +import type { Report } from "../lib/report/schema"; +import { + REPORT_HISTORY_BRANCH, + REPORT_HISTORY_FORMAT, + REPORT_HISTORY_VERSION, + claimChanges, + reportChangeSummary, + reportHistoryPagePath, + reportHistoryRepoPath, + reportHistoryViewPath, + revisionCommitMessage, + revisionSummaryOf, + type ReportHistoryRef, + type ReportHistoryView, + type ReportRevisionView, +} from "../lib/report/revisions"; +import { cleanGitEnv } from "./sourceAudit"; +import { siteReportDir } from "./reportMedia"; + +export const REPORT_HISTORY_GIT_DIRNAME = "history-git"; +export const REVISION_EXPORTS_FILENAME = "exports.json"; +export const REVISION_EXPORTS_FORMAT = "archilyzer-report-revision-exports"; +export const REVISION_EXPORTS_VERSION = 1; + +// The published clone's packs are split below this (the publish limit is 24 MiB). +const PACK_SIZE = "20m"; +const HEAD_REF = `refs/heads/${REPORT_HISTORY_BRANCH}`; +const ZERO_OID = "0".repeat(40); + +// `sites/<siteId>/reports/<reportId>/history-git/`. +export function reportHistoryGitDir(paths: Paths, siteId: string, reportId: string): string { + return path.join(siteReportDir(paths, siteId, reportId), REPORT_HISTORY_GIT_DIRNAME); +} + +// ─── git, with nothing of the operator's ─── + +export type HistoryIdentity = { name: string; email: string }; + +// The site, as the author and committer of its reports' revisions. +export function historyIdentity(site: Pick<Site, "siteId" | "siteTitle">): HistoryIdentity { + return { name: site.siteTitle?.trim() || site.siteId, email: `noreply@${site.siteId}.invalid` }; +} + +// `@<seconds> +0000`: git's own date form, in UTC. +export function gitDateUtc(d: Date): string { + return `@${Math.floor(d.getTime() / 1000)} +0000`; +} + +// The whole environment git runs with: PATH, and nothing else of this +// process's — no HOME (so no ~/.gitconfig), no system or global config, no +// GIT_* variable, no EMAIL — then the identity and date when committing. +export function historyGitEnv(identity?: HistoryIdentity, date?: Date): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = cleanGitEnv({ + PATH: process.env.PATH, + HOME: path.join(os.tmpdir(), "archilyzer-report-history-nohome"), + XDG_CONFIG_HOME: path.join(os.tmpdir(), "archilyzer-report-history-nohome"), + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: os.devNull, + GIT_TERMINAL_PROMPT: "0", + TZ: "UTC", + LC_ALL: "C", + LANG: "C", + }); + if (identity) { + env.GIT_AUTHOR_NAME = identity.name; + env.GIT_AUTHOR_EMAIL = identity.email; + env.GIT_COMMITTER_NAME = identity.name; + env.GIT_COMMITTER_EMAIL = identity.email; + } + if (date) { + env.GIT_AUTHOR_DATE = gitDateUtc(date); + env.GIT_COMMITTER_DATE = gitDateUtc(date); + } + return env; +} + +// Config every invocation carries on its command line, over whatever a +// repository's own config could say. +const GIT_FLAGS = ["-c", "commit.gpgSign=false", "-c", "core.hooksPath=/dev/null", "-c", "core.logAllRefUpdates=false"]; + +type GitOpts = { input?: string | Uint8Array; env?: NodeJS.ProcessEnv; cwd?: string }; + +async function gitRaw(args: string[], opts: GitOpts = {}): Promise<Buffer> { + const r = await execa("git", [...GIT_FLAGS, ...args], { + env: opts.env ?? historyGitEnv(), + extendEnv: false, + input: opts.input, + cwd: opts.cwd, + encoding: "buffer", + // A blob's bytes are its bytes: its final newline included. + stripFinalNewline: false, + reject: false, + timeout: 120_000, + }); + if (r.exitCode !== 0) { + const err = Buffer.from(r.stderr as Uint8Array).toString("utf8").trim().split("\n")[0]; + throw new Error(`git ${args.find((a) => !a.startsWith("-") && !a.includes("/")) ?? args[0]} failed (exit ${r.exitCode}): ${err}`); + } + return Buffer.from(r.stdout as Uint8Array); +} + +async function git(gitDir: string, args: string[], opts: GitOpts = {}): Promise<string> { + return (await gitRaw(["--git-dir", gitDir, ...args], opts)).toString("utf8").trim(); +} + +const sha256 = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex"); + +async function isRepo(gitDir: string): Promise<boolean> { + return (await stat(path.join(gitDir, "HEAD")).catch(() => null))?.isFile() ?? false; +} + +async function headCommit(gitDir: string): Promise<string | null> { + if (!(await isRepo(gitDir))) return null; + try { + return await git(gitDir, ["rev-parse", "--verify", "--quiet", `${HEAD_REF}^{commit}`]); + } catch { + return null; + } +} + +// ─── Reading ─── + +export type StoredRevision = { + revision: number; + commit: string; + // ISO 8601, UTC, to the second. + date: string; + reportSha256: string; + // The report.json's bytes. + reportJson: Buffer; + message: string; +}; + +const isoSeconds = (secs: number) => new Date(secs * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"); + +// Every revision in the store, oldest first; [] when there is no store. +export async function readStoredRevisions(gitDir: string): Promise<StoredRevision[]> { + if (!(await headCommit(gitDir))) return []; + const log = await gitRaw(["--git-dir", gitDir, "log", "--reverse", "--format=%H%x1f%ct%x1f%B%x1e", HEAD_REF]); + const entries = log + .toString("utf8") + .split("\x1e") + .map((e) => e.replace(/^\n/, "")) + .filter((e) => e.trim()); + const out: StoredRevision[] = []; + for (const [i, e] of entries.entries()) { + const [commit, ct, message] = e.split("\x1f"); + const reportJson = await gitRaw(["--git-dir", gitDir, "cat-file", "blob", `${commit}:report.json`]); + out.push({ + revision: i + 1, + commit, + date: isoSeconds(Number(ct)), + reportSha256: sha256(reportJson), + reportJson, + message: message.trimEnd(), + }); + } + return out; +} + +export type RevisionHead = { revision: number; commit: string; date: string; reportSha256: string; summary: string[] }; + +// The newest revision, or null when there is none. +export async function readRevisionHead(gitDir: string): Promise<RevisionHead | null> { + const head = await headCommit(gitDir); + if (!head) return null; + const count = Number(await git(gitDir, ["rev-list", "--count", HEAD_REF])); + const [ct, ...msg] = (await git(gitDir, ["log", "-1", "--format=%ct%n%B", HEAD_REF])).split("\n"); + const reportJson = await gitRaw(["--git-dir", gitDir, "cat-file", "blob", `${head}:report.json`]); + return { + revision: count, + commit: head, + date: isoSeconds(Number(ct)), + reportSha256: sha256(reportJson), + summary: revisionSummaryOf(msg.join("\n")), + }; +} + +// The revision a report.json of `reportSha256` is: the newest when it is that +// report.json (`changed` false), else the next one to be committed. +export async function pendingRevision( + gitDir: string, + reportSha256: string, +): Promise<{ revision: number; changed: boolean; head: RevisionHead | null }> { + const head = await readRevisionHead(gitDir); + if (!head) return { revision: 1, changed: true, head }; + if (head.reportSha256 === reportSha256) return { revision: head.revision, changed: false, head }; + return { revision: head.revision + 1, changed: true, head }; +} + +// ─── Committing ─── + +export type RevisionExports = { + format: typeof REVISION_EXPORTS_FORMAT; + version: typeof REVISION_EXPORTS_VERSION; + reportId: string; + reportSha256: string; + // By file name: report.html, report.pdf, report.md, evidence-pack.zip (each + // that was made), citations.json, citations.csv. + files: Record<string, { bytes: number; sha256: string }>; +}; + +export function revisionExports( + reportId: string, + reportSha256: string, + files: Record<string, Uint8Array | string>, +): RevisionExports { + const out: RevisionExports["files"] = {}; + for (const name of Object.keys(files).sort()) { + const data = typeof files[name] === "string" ? Buffer.from(files[name] as string) : (files[name] as Uint8Array); + out[name] = { bytes: data.length, sha256: sha256(data) }; + } + return { format: REVISION_EXPORTS_FORMAT, version: REVISION_EXPORTS_VERSION, reportId, reportSha256, files: out }; +} + +export type CommittedRevision = RevisionHead & { + // Whether this call made the commit (false: report.json had not changed). + committed: boolean; +}; + +function parseReport(data: Buffer): Report | null { + try { + const v = JSON.parse(data.toString("utf8")) as Report; + return v && typeof v === "object" && Array.isArray(v.sections) ? v : null; + } catch { + return null; + } +} + +async function ensureRepo(gitDir: string): Promise<void> { + if (await isRepo(gitDir)) return; + await mkdir(path.dirname(gitDir), { recursive: true }); + // No template: no hooks, no description, no sample files. + await gitRaw(["init", "--bare", "--quiet", `--initial-branch=${REPORT_HISTORY_BRANCH}`, "--template=", gitDir]); +} + +// Commit `reportJson` (with its Markdown export and exports.json) as the next +// revision — when it is not the newest revision's report.json already, in +// which case nothing is written and the newest is answered. +export async function commitReportRevision(o: { + gitDir: string; + site: Pick<Site, "siteId" | "siteTitle">; + reportJson: Uint8Array; + markdown: string; + exports: RevisionExports; + now: Date; +}): Promise<CommittedRevision> { + const reportJson = Buffer.from(o.reportJson); + const sha = sha256(reportJson); + const pending = await pendingRevision(o.gitDir, sha); + if (!pending.changed && pending.head) return { ...pending.head, committed: false }; + await ensureRepo(o.gitDir); + const g = o.gitDir; + const prev = pending.head + ? parseReport(await gitRaw(["--git-dir", g, "cat-file", "blob", `${pending.head.commit}:report.json`])) + : null; + const next = parseReport(reportJson); + const summary = next + ? reportChangeSummary(pending.head ? prev ?? emptyReport(next) : null, next) + : ["report.json is not a readable report"]; + const blob = (data: string | Uint8Array) => git(g, ["hash-object", "-w", "--stdin"], { input: data }); + const entries = [ + [REVISION_EXPORTS_FILENAME, await blob(`${JSON.stringify(o.exports, null, 2)}\n`)], + ["report.json", await blob(reportJson)], + ["report.md", await blob(o.markdown)], + ]; + const tree = await git(g, ["mktree"], { input: entries.map(([name, id]) => `100644 blob ${id}\t${name}\n`).join("") }); + const env = historyGitEnv(historyIdentity(o.site), o.now); + const commit = await git( + g, + ["commit-tree", "--no-gpg-sign", tree, ...(pending.head ? ["-p", pending.head.commit] : []), "-F", "-"], + { input: revisionCommitMessage(pending.revision, summary), env }, + ); + await git(g, ["update-ref", "-m", `revision ${pending.revision}`, HEAD_REF, commit, pending.head?.commit ?? ZERO_OID]); + return { + revision: pending.revision, + commit, + date: isoSeconds(Math.floor(o.now.getTime() / 1000)), + reportSha256: sha, + summary, + committed: true, + }; +} + +// A report with nothing in it: what an unreadable earlier revision is +// compared as. +function emptyReport(like: Report): Report { + return { ...like, title: "", series: undefined, subtitle: undefined, summary: undefined, method: undefined, citations: {}, sections: [] }; +} + +// ─── The history page's data ─── + +// Every revision with its summary and claim diff, or null when the report has +// no revisions. `clone` is what `git clone` takes. +export async function readReportHistoryView( + gitDir: string, + o: { reportId: string; siteUrl?: string }, +): Promise<ReportHistoryView | null> { + const stored = await readStoredRevisions(gitDir); + if (stored.length === 0) return null; + const revisions: ReportRevisionView[] = []; + let prev: Report | null = null; + let newest: Report | null = null; + for (const s of stored) { + const report = parseReport(s.reportJson); + revisions.push({ + revision: s.revision, + date: s.date, + commit: s.commit, + reportSha256: s.reportSha256, + summary: revisionSummaryOf(s.message), + claims: prev && report ? claimChanges(prev, report) : [], + }); + if (report) newest = report; + prev = report; + } + const base = o.siteUrl?.trim().replace(/\/+$/, ""); + const repo = reportHistoryRepoPath(o.reportId); + return { + format: REPORT_HISTORY_FORMAT, + version: REPORT_HISTORY_VERSION, + reportId: o.reportId, + ...(newest?.series ? { series: newest.series } : {}), + title: newest?.title ?? o.reportId, + branch: REPORT_HISTORY_BRANCH, + clone: base ? `${base}${repo}` : repo, + revisions, + }; +} + +// What the report's page shows: the newest revision, current when the +// report.json now is that revision's. +export function reportHistoryRef(view: ReportHistoryView, currentSha256: string | null): ReportHistoryRef { + const last = view.revisions[view.revisions.length - 1]; + return { + revision: last.revision, + date: last.date, + href: reportHistoryPagePath(view.reportId), + current: currentSha256 === last.reportSha256, + }; +} + +// ─── The published clone ─── + +// What the published clone holds, by path relative to it: nothing else is +// copied out of the clone, and the audit (lib/builtExport.ts) refuses +// anything else under a `reports/<id>/history/repo/`. +export const REPORT_HISTORY_REPO_FILE_RE = + /^(HEAD|packed-refs|info\/refs|objects\/info\/packs|refs\/heads\/main|objects\/pack\/pack-[0-9a-f]+\.(pack|idx))$/; + +// Stage a dumb-HTTP clone of the store at `destDir` (replaced whole): a fresh +// bare clone of the one branch, repacked, its server info written, copied +// from an allowlist. Throws when a file would be over the publish limit. +// Answers the files written, relative to `destDir`. +export async function stageReportHistoryRepo(gitDir: string, destDir: string): Promise<string[]> { + const tmp = await mkdtemp(path.join(os.tmpdir(), "report-history-stage-")); + try { + const clone = path.join(tmp, "clone"); + await gitRaw( + ["clone", "--bare", "--quiet", "--no-local", "--single-branch", "--branch", REPORT_HISTORY_BRANCH, "--template=", gitDir, clone], + { cwd: tmp }, + ); + await git(clone, ["repack", "-a", "-d", "-q", `--max-pack-size=${PACK_SIZE}`]); + await git(clone, ["prune-packed"]); + await git(clone, ["pack-refs", "--all"]); + await git(clone, ["update-server-info"]); + const refs = (await git(clone, ["for-each-ref", "--format=%(refname)"])).split("\n").filter(Boolean); + if (refs.length !== 1 || refs[0] !== HEAD_REF) { + throw new Error(`the history clone must hold ${HEAD_REF} alone, and holds ${refs.join(", ") || "no refs"}`); + } + const head = await git(clone, ["rev-parse", HEAD_REF]); + + await rm(destDir, { recursive: true, force: true }); + for (const d of ["info", "objects/info", "objects/pack", "refs/heads"]) { + await mkdir(path.join(destDir, d), { recursive: true }); + } + const files: string[] = []; + for (const f of ["HEAD", "packed-refs", "info/refs", "objects/info/packs"]) { + await cp(path.join(clone, f), path.join(destDir, f)); + files.push(f); + } + await writeFile(path.join(destDir, "refs", "heads", REPORT_HISTORY_BRANCH), `${head}\n`); + files.push(`refs/heads/${REPORT_HISTORY_BRANCH}`); + for (const f of (await readdir(path.join(clone, "objects", "pack"))).sort()) { + if (!/^pack-[0-9a-f]+\.(pack|idx)$/.test(f)) continue; + await cp(path.join(clone, "objects", "pack", f), path.join(destDir, "objects", "pack", f)); + files.push(`objects/pack/${f}`); + } + for (const f of files) { + const problem = publishFileSizeProblem(f, (await stat(path.join(destDir, f))).size); + if (problem) throw new Error(`the history clone's ${problem}`); + } + return files; + } finally { + await rm(tmp, { recursive: true, force: true }); + } +} + +// Publish a report's history under `publicDir`: history.json and the clone, +// at `reports/<id>/history/`. Answers the clone's files. +export async function publishReportHistory(o: { + gitDir: string; + publicDir: string; + view: ReportHistoryView; +}): Promise<string[]> { + const viewFile = path.join(o.publicDir, ...reportHistoryViewPath(o.view.reportId).split("/").filter(Boolean)); + await mkdir(path.dirname(viewFile), { recursive: true }); + await writeFile(viewFile, `${JSON.stringify(o.view, null, 2)}\n`); + const repoDir = path.join(o.publicDir, ...reportHistoryRepoPath(o.view.reportId).split("/").filter(Boolean)); + return stageReportHistoryRepo(o.gitDir, repoDir); +} + +// The sha256 of a file, or null when it cannot be read. +export async function fileSha256(file: string): Promise<string | null> { + try { + return sha256(await readFile(file)); + } catch { + return null; + } +}