Archilyzer · Source

archilyzer

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

commit 352728588306364befbb110e2d5c4919007b278c
parent d75d4da88abf351e00396bc3b8a0fd5034856715
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 15:49:43 -0400

Merge report/history (per-report revision history: a bare git repo per report, history page + history.json, dumb-HTTP clone, revision footer)

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

Diffstat:
MPUBLISH.md | 28++++++++++++++++++++++++----
Mcommon/lib/builtExport.test.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/builtExport.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Acommon/lib/report/revisions.test.ts | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/report/revisions.ts | 340+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/report/views.ts | 8++++++++
Mcommon/publish/composeReports.test.ts | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/publish/composeReports.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcommon/publish/reportExportFiles.ts | 10++++++++++
Mcommon/publish/reportExports.ts | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Acommon/publish/reportHistory.test.ts | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/reportHistory.ts | 473+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/sites/[siteId]/reports/page.tsx | 19+++++++++++++++++--
Mexport/CHANGELOG.md | 1+
Mexport/app/components/reports/ReportArticle.tsx | 13++++++++++++-
Aexport/app/components/reports/ReportHistory.tsx | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/lib/reports.test.ts | 2+-
Mexport/app/lib/reports.ts | 13+++++++++++++
Aexport/app/reports/[reportId]/history/page.tsx | 32++++++++++++++++++++++++++++++++
Mexport/e2e-report/audit.spec.ts | 6++++++
Mexport/e2e-report/contract.ts | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mexport/e2e-report/report-site.spec.ts | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mexport/fixtures/report-site/fixture.ts | 30++++++++++++++++++++++++++----
Mexport/fixtures/report-site/public/reports/demo-factcheck/page.json | 6++++++
Aexport/fixtures/report-site/source/demo-factcheck/report.v1.json | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/report-sites.md | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
27 files changed, 2171 insertions(+), 57 deletions(-)

diff --git a/PUBLISH.md b/PUBLISH.md @@ -85,8 +85,21 @@ Chromium; skipped with a note on a host without Playwright's browser), `report.m clips, stills and screenshots as files, so the clips play offline, plus the Markdown and the citations; packed by the system `zip`, which a host must have), with an `export.json` naming each file's size and checksum and the hash of the report.json it -was made from. Every export ends with a footer naming the report's date and the first -12 characters of that hash. +was made from. Every export ends with a footer naming the report's revision, its date +and the first 12 characters of that hash. + +Each export also keeps the report's **revision history** +(`common/publish/reportHistory.ts`): a bare git repository per report at +`sites/<id>/reports/<report>/history-git/` (add `history-git/` to the corpus +repository's `.gitignore`). When the report.json's sha256 differs from the newest +revision's, the export commits a new revision holding `report.json` (byte for byte), +`report.md` and `exports.json` (the sha256 of every export file and of the citations +JSON and CSV), with the message `Revision N` and a summary of what changed. A re-export +of an unchanged report commits nothing. Author and committer are the site (its title, +`noreply@<id>.invalid`), dated in UTC; git runs with no system or global config and none +of the operator's environment. A footer names the revision and the report's sha256, never +a commit (the commit holds the footer); the history page maps the sha256 to its commit, +and `export.json` records it. The build's compose then writes the reports from what prepare left (`common/publish/composeReports.ts`): each report's page, its citations as @@ -94,8 +107,15 @@ The build's compose then writes the reports from what prepare left `report.md`, `evidence-pack.zip` — only those made from the report.json as it is now, and only a file of at most 24 MiB: a larger pack stays on the host; the page's download line lists what was published), its cited stills (never a saved source copy), -one page per cited moment with the transcript lines around it, and the prepared clips -and captures — only those the published reports cite. Every quote is checked as it is +one page per cited moment with the transcript lines around it, the prepared clips +and captures — only those the published reports cite — and each report's revision +history: `reports/<id>/history/history.json` (every revision's number, date, commit, +report sha256, change summary and claim-level word diff, which `/reports/<id>/history/` +renders) and a dumb-HTTP clone at `reports/<id>/history/repo/`, staged as the source +mirror is (a fresh bare clone, `repack -a -d`, `update-server-info`, then only `HEAD`, +`packed-refs`, `info/refs`, `objects/info/packs`, the packs and `refs/heads/main`), so +`git clone <siteUrl>/reports/<id>/history/repo` works. The deploy audit refuses any other +file in a clone or one over 24 MiB, and counts the clone's files toward Pages' 20,000. Every quote is checked as it is composed: a span's against its cues within 5 s either side (a record whose `en` track has no cues is read from `en-orig`), a post's against its text, scored as the share of the quote's words found there; the score, the time and the method are written into the diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts @@ -17,6 +17,7 @@ import { PAGES_MAX_FILES, PUBLISH_MAX_FILE_BYTES, publishFileSizeProblem, + reportHistoryProblem, } from "./builtExport"; function tempOut(siteJson?: string): { dir: string; cleanup: () => void } { @@ -375,6 +376,63 @@ test("a cited build over the Pages file limit is refused", () => { } }); +// A report's published history clone (publish/reportHistory.ts). +const HISTORY_REPO_FILES = [ + "reports/demo-report/history/index.html", + "reports/demo-report/history/history.json", + "reports/demo-report/history/repo/HEAD", + "reports/demo-report/history/repo/packed-refs", + "reports/demo-report/history/repo/info/refs", + "reports/demo-report/history/repo/objects/info/packs", + "reports/demo-report/history/repo/refs/heads/main", + `reports/demo-report/history/repo/objects/pack/pack-${"a".repeat(40)}.pack`, + `reports/demo-report/history/repo/objects/pack/pack-${"a".repeat(40)}.idx`, +]; + +test("a report's history clone passes the audit; anything else in it is refused, full build or cited", () => { + const t = citedOut(HISTORY_REPO_FILES); + try { + assert.equal(reportHistoryProblem(t.dir), null); + assert.equal(builtBundleProblem(t.dir, "reports-site"), null); + assert.equal(builtSiteProblem(t.dir, "reports-site"), null); + for (const leak of ["config", "hooks/pre-commit", "logs/HEAD", "description", "FETCH_HEAD"]) { + const f = path.join(t.dir, "reports", "demo-report", "history", "repo", leak); + mkdirSync(path.dirname(f), { recursive: true }); + writeFileSync(f, "x"); + assert.match(reportHistoryProblem(t.dir)!, new RegExp(`history clone that are not published: reports/demo-report/history/repo/${leak}`)); + assert.match(builtBundleProblem(t.dir, "reports-site")!, /history clone/); + rmSync(f); + } + } finally { + t.cleanup(); + } + const full = bundle({ site: { siteId: "anilyzer" }, corpus: { spec: 5, site: { id: "anilyzer" } } }); + try { + const f = path.join(full.dir, "reports", "r1", "history", "repo", "config"); + mkdirSync(path.dirname(f), { recursive: true }); + writeFileSync(f, "x"); + assert.match(builtSiteProblem(full.dir, "anilyzer")!, /history clone/); + } finally { + full.cleanup(); + } +}); + +test("a history clone's pack over the publish limit is refused; its files count toward Pages' file limit", () => { + const t = citedOut(HISTORY_REPO_FILES); + try { + const pack = path.join(t.dir, HISTORY_REPO_FILES[7]); + writeFileSync(pack, Buffer.alloc(PUBLISH_MAX_FILE_BYTES + 1)); + assert.match(reportHistoryProblem(t.dir)!, /pack-a+\.pack is 24\.0 MiB, over the publish limit/); + writeFileSync(pack, "x"); + const dir = path.join(t.dir, "m", "many"); + mkdirSync(dir, { recursive: true }); + for (let i = 0; i <= PAGES_MAX_FILES; i++) writeFileSync(path.join(dir, String(i)), ""); + assert.match(citedBuildProblem(t.dir)!, /\(7 in report history clones\), over Pages' limit of 20000/); + } finally { + t.cleanup(); + } +}); + test("a site configured cited with a full build is refused at deploy; a cited build of it is not", () => { const full = bundle({ site: { siteId: "reports-site" }, corpus: { spec: 5, site: { id: "reports-site" } } }); const cited = citedOut(); diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts @@ -16,6 +16,7 @@ import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "node:fs"; import path from "node:path"; import { isCitedSite } from "./siteSchema"; +import { REPORT_HISTORY_REPO_FILE_RE } from "./report/revisions"; /** * The site id of the build sitting in `outDir`, or null when there is no @@ -65,7 +66,7 @@ export function builtSiteProblem(outDir: string, siteId: string): string | null if (corpusSiteIdIn(outDir) !== asked) { return `export/out holds an incomplete build of "${asked}" (its corpus.json does not name it) — build ${asked} first`; } - return citedBuildProblem(outDir); + return citedBuildProblem(outDir) ?? reportHistoryProblem(outDir); } /** @@ -96,7 +97,7 @@ export function builtBundleProblem(outDir: string, siteId: string): string | nul if (described !== asked) { return `${outDir} describes "${described}", not "${asked}" (corpus.json)`; } - return citedBuildProblem(outDir); + return citedBuildProblem(outDir) ?? reportHistoryProblem(outDir); } /** @@ -291,8 +292,10 @@ export function citedBuildProblem(outDir: string): string | null { `but it also holds ${shown} — compose the site again` ); } - // The Pages limits, which a cited build is small enough to walk for. + // The Pages limits, which a cited build is small enough to walk for — + // every file counted, the reports' history clones' included. let files = 0; + let historyFiles = 0; const oversize: string[] = []; const walk = (dir: string, rel: string): void => { for (const e of readdirSync(dir, { withFileTypes: true })) { @@ -301,6 +304,7 @@ export function citedBuildProblem(outDir: string): string | null { if (e.isDirectory()) walk(p, r); else { files++; + if (REPORT_HISTORY_REPO_DIR_RE.test(r)) historyFiles++; if (statSync(p).size > PAGES_MAX_FILE_BYTES) oversize.push(r); } } @@ -310,8 +314,60 @@ export function citedBuildProblem(outDir: string): string | null { return `${outDir} holds ${oversize.length} file(s) over Pages' 25 MiB limit: ${oversize.slice(0, 5).join(", ")}`; } if (files > PAGES_MAX_FILES) { - return `${outDir} holds ${files} files, over Pages' limit of ${PAGES_MAX_FILES}`; + return ( + `${outDir} holds ${files} files${historyFiles ? ` (${historyFiles} in report history clones)` : ""}, ` + + `over Pages' limit of ${PAGES_MAX_FILES}` + ); + } + return null; +} + +// A file inside a report's published history clone, by its out/-relative path. +const REPORT_HISTORY_REPO_DIR_RE = /^reports\/[^/]+\/history\/repo\//; + +/** + * Why the reports' history clones in `outDir` (`reports/<id>/history/repo/`, + * publish/reportHistory.ts) may not ship, as one sentence — or null. Any + * build, full or cited: a clone may hold only the dumb-HTTP files its stager + * copies (REPORT_HISTORY_REPO_FILE_RE — never a config, hook or log of the + * store), each within the publish limit (PUBLISH_MAX_FILE_BYTES). + */ +export function reportHistoryProblem(outDir: string): string | null { + const reportsDir = path.join(outDir, "reports"); + let reports: Dirent[]; + try { + reports = readdirSync(reportsDir, { withFileTypes: true }); + } catch { + return null; + } + const extra: string[] = []; + const oversize: string[] = []; + for (const r of reports) { + if (!r.isDirectory()) continue; + const repo = path.join(reportsDir, r.name, "history", "repo"); + if (!existsSync(repo)) continue; + const walk = (dir: string, rel: string): void => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + const rr = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) walk(p, rr); + else { + if (!REPORT_HISTORY_REPO_FILE_RE.test(rr)) extra.push(`reports/${r.name}/history/repo/${rr}`); + const problem = publishFileSizeProblem(`reports/${r.name}/history/repo/${rr}`, statSync(p).size); + if (problem) oversize.push(problem); + } + } + }; + walk(repo, ""); + } + if (extra.length > 0) { + extra.sort(); + return ( + `${outDir} holds files in a report's history clone that are not published: ${extra.slice(0, 8).join(", ")}` + + `${extra.length > 8 ? `, … (${extra.length} in all)` : ""} — compose the site again` + ); } + if (oversize.length > 0) return `${outDir}: ${oversize[0]}`; return null; } 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,340 @@ +// 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`; +} + +// What a published clone holds, by path relative to it (publish/reportHistory.ts +// copies nothing else; 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))$/; + +// ─── 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/lib/report/views.ts b/common/lib/report/views.ts @@ -6,6 +6,8 @@ // /reports/<reportId>/page.json ReportPageView one report, its citations resolved // /reports/<reportId>/citations.{json,csv} the report's citations, for download // /reports/<reportId>/stills/… a source citation's still, as the report names it +// /reports/<reportId>/history/history.json ReportHistoryView its revisions (lib/report/revisions.ts) +// /reports/<reportId>/history/repo/ a dumb-HTTP clone of its revision history // /m/index.json MomentIndexView every moment page to build // /m/<momentKey>/moment.json MomentPageView one moment: clip, cues, record, "cited in" // /media/clips/<channel>/<id>/<start>-<end>.mp4 a span's evidence clip @@ -52,6 +54,7 @@ import type { import { quoteTokens } from "../citations/verify"; import { formatTimestamp } from "../vtt"; import type { CitedIn } from "./citedIn"; +import type { ReportHistoryRef } from "./revisions"; import type { Claim, Report, ReportKind } from "./schema"; import { reportCitationNumbers } from "./uses"; import { resolveVerdicts, VERDICTS, type Verdict, type VerdictStyle } from "./verdicts"; @@ -288,6 +291,9 @@ export type ReportPageView = { // The report as files, and its citations as data, when compose published // them (each a site-root path). downloads?: ReportDownloads; + // Its newest revision and the history page (lib/report/revisions.ts), when + // compose published a history. + history?: ReportHistoryRef; }; // What a report page offers to download: its exports (html, pdf, md, the @@ -394,6 +400,7 @@ export type ReportViewResolver = { poster?: (c: SpanCitation) => string | undefined; post?: (c: PostCitation) => { author?: string; text?: string; shot?: string } | undefined; downloads?: ReportDownloads; + history?: ReportHistoryRef; }; function sourceView(id: string, s: Source): SourceView { @@ -549,6 +556,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver) }), ), downloads: resolve.downloads, + history: resolve.history, }); } diff --git a/common/publish/composeReports.test.ts b/common/publish/composeReports.test.ts @@ -47,7 +47,7 @@ const { REPORT_MEDIA_FORMAT, REPORT_MEDIA_VERSION, reportMediaDir, reportMediaIn const { QUOTE_CHECK_METHOD } = await import("../lib/citations/verify"); const { parseCitationSet } = await import("../lib/citations/validate"); const { CONTRACT } = await import("../lib/archive/contract"); -const { citedBuildProblem, builtBundleProblem } = await import("../lib/builtExport"); +const { citedBuildProblem, builtBundleProblem, reportHistoryProblem } = await import("../lib/builtExport"); const paths = getPaths(); const VIDEOS = "demo-channel"; @@ -640,6 +640,72 @@ test("compose publishes the exports beside the report and lists them as download assert.equal(citedBuildProblem(paths.exportPublicDir), null, "the exports are within reports/, which the audit allows"); }); +test("reports export commits a revision when report.json changed; compose publishes the history, the page names it", async () => { + const dir = reportExportDir(paths, "cited", REPORT); + const file = path.join(paths.sitesDir, "cited", "reports", REPORT, "report.json"); + const saved = readFileSync(file, "utf8"); + // The exports above made revision 1; exporting the same report.json again made none. + const first = readJson<{ revision: { revision: number; committed: boolean; commit: string }; footer: { revision?: number } }>( + path.join(dir, "export.json"), + ); + assert.equal(first.revision.revision, 1); + assert.equal(first.revision.committed, false); + assert.equal(first.footer.revision, 1); + assert.match(readFileSync(path.join(dir, "report.md"), "utf8"), /Revision 1 · 2026-10-01 · report sha256 [0-9a-f]{12}/); + + const edited = report(); + edited.sections[0].claims[0] = { ...edited.sections[0].claims[0], text: "He opened the old bridge himself.", verdict: "PARTLY" }; + writeJson(file, edited); + try { + const r = await exportSiteReports({ + siteId: "cited", + paths, + now: () => new Date("2026-10-06T08:00:00Z"), + openPdfPrinter: fakePrinter([]), + onLog: () => {}, + }); + assert.deepEqual(r.problems, []); + const m = r.exported[0].manifest; + assert.equal(m.revision?.revision, 2); + assert.equal(m.revision?.committed, true); + assert.deepEqual(m.revision?.summary, ["Verdict changed: claim-1 CONTRADICTED → PARTLY", "Claim edited: claim-1 (text)"]); + assert.match(readFileSync(path.join(dir, "report.html"), "utf8"), /Revision 2 · 2026-10-01 · report sha256/); + + await compose("cited"); + const history = readJson<{ + clone: string; + revisions: { revision: number; commit: string; reportSha256: string; date: string; summary: string[]; claims: { id: string }[] }[]; + }>(pub("reports", REPORT, "history", "history.json")); + assert.equal(history.clone, `https://cited.example.test/reports/${REPORT}/history/repo`); + assert.deepEqual(history.revisions.map((x) => x.revision), [1, 2]); + assert.equal(history.revisions[1].commit, m.revision?.commit); + assert.equal(history.revisions[1].reportSha256, sha(file)); + assert.equal(history.revisions[1].date, "2026-10-06T08:00:00Z"); + assert.deepEqual(history.revisions[1].claims.map((c) => c.id), ["claim-1"]); + const repo = filesUnder(pub("reports", REPORT, "history", "repo")); + assert.deepEqual(repo.filter((f) => !f.startsWith("objects/pack/")), ["HEAD", "info/refs", "objects/info/packs", "packed-refs", "refs/heads/main"]); + assert.ok(repo.some((f) => f.endsWith(".pack"))); + const view = readJson<{ history: unknown }>(pub("reports", REPORT, "page.json")); + assert.deepEqual(view.history, { revision: 2, date: "2026-10-06T08:00:00Z", href: `/reports/${REPORT}/history/`, current: true }); + assert.match(readFileSync(pub("sitemap.xml"), "utf8"), new RegExp(`/reports/${REPORT}/history/`)); + assert.equal(citedBuildProblem(paths.exportPublicDir), null); + assert.equal(reportHistoryProblem(paths.exportPublicDir), null); + } finally { + writeFileSync(file, saved); + } + // Edited since the newest revision and not exported: the page says so. + await compose("cited"); + const view = readJson<{ history: { revision: number; current: boolean } }>(pub("reports", REPORT, "page.json")); + assert.deepEqual([view.history.revision, view.history.current], [2, false]); + // Exported again, the way back is revision 3. + const back = await exportSiteReports({ siteId: "cited", paths, now: () => new Date("2026-10-06T09:00:00Z"), openPdfPrinter: fakePrinter([]), onLog: () => {} }); + assert.equal(back.exported[0].manifest.revision?.revision, 3); + assert.deepEqual(back.exported[0].manifest.revision?.summary, ["Verdict changed: claim-1 PARTLY → CONTRADICTED", "Claim edited: claim-1 (text)"]); + // A site with no reports ships no history. + await compose("plain"); + assert.ok(!existsSync(pub("reports"))); +}); + test("an export of another version of the report is not published; a pack over the limit stays local", async () => { const dir = reportExportDir(paths, "cited", REPORT); const file = path.join(paths.sitesDir, "cited", "reports", REPORT, "report.json"); diff --git a/common/publish/composeReports.ts b/common/publish/composeReports.ts @@ -33,6 +33,10 @@ // an `archilyzer-citations` set; // never a source's `saved` copy) // reports/<id>/<still> each cited source still +// reports/<id>/history/history.json, the report's revisions and a +// history/repo/… dumb-HTTP clone of them, when +// `reports export` has committed +// one (./reportHistory.ts) // reports/<id>/report.{html,pdf,md}, the report's exports, when // evidence-pack.zip `archilyzer reports export` made // them from the report as it is now @@ -78,6 +82,7 @@ import { momentKeyOf, momentPath, parseMomentKey, type SpanMoment } from "../lib import { CITATIONS_VERSION, type Citation, type PostCitation, type SpanCitation } from "../lib/citations/schema"; import { cueWindowText, quoteDrifted, quoteVerification, QUOTE_DRIFT_THRESHOLD } from "../lib/citations/verify"; import { buildCitedIn } from "../lib/report/citedIn"; +import { reportHistoryPagePath } from "../lib/report/revisions"; import type { Report } from "../lib/report/schema"; import { reportCitationNumbers } from "../lib/report/uses"; import { @@ -116,7 +121,9 @@ import { siteReportDir, type ReportMediaEntry, } from "./reportMedia"; -import { publishableReportExports, type PublishableReportExports } from "./reportExportFiles"; +import { publishableReportExports, reportFileSha256, type PublishableReportExports } from "./reportExportFiles"; +import { publishReportHistory, readReportHistoryView, reportHistoryGitDir, reportHistoryRef } from "./reportHistory"; +import type { ReportHistoryRef, ReportHistoryView } from "../lib/report/revisions"; // The public dir's entries this stage owns. Every compose removes them first. export const REPORT_PUBLIC_ENTRIES: readonly string[] = ["reports", "m", "media"]; @@ -208,6 +215,8 @@ export type ComposedReports = { moments: string[]; // Media problems let through by `allowMissingMedia`. allowed: ComposeReportsProblem[]; + // The reports whose revision history was published. + histories?: string[]; }; // ─── Reading the corpus ─── @@ -402,6 +411,8 @@ const sameSpan = (a: EvidenceSpan, b: EvidenceSpan) => export type ResolveSiteReportsOptions = Omit<ComposeReportsOptions, "publicDir"> & { // Each report's downloads, as its view carries them. downloads?: (reportId: string) => ReportDownloads | undefined; + // Each report's newest revision, as its view carries it. + history?: (reportId: string) => ReportHistoryRef | undefined; }; // The site's reports resolved against the corpus — verified, their views and @@ -670,6 +681,7 @@ export async function resolveSiteReports(opts: ResolveSiteReportsOptions): Promi return post ? { author: postAuthor(post), text: post.text, shot: postShot(c) } : undefined; }, downloads: opts.downloads?.(report.id), + history: opts.history?.(report.id), }), ); @@ -775,8 +787,30 @@ export async function composeReports(opts: ComposeReportsOptions): Promise<Compo exportsOf.set(id, found); for (const note of found.notes) log(`[reports] ${id}: ${note}`); } + // Each report's revision history (publish/reportHistory.ts), read before + // anything is written; a report never exported has none. + const histories = new Map<string, { view: ReportHistoryView; ref: ReportHistoryRef; gitDir: string }>(); + const historyProblems: ComposeReportsProblem[] = []; + for (const id of site.reports ?? []) { + const gitDir = reportHistoryGitDir(paths, site.siteId, id); + try { + const view = await readReportHistoryView(gitDir, { reportId: id, siteUrl: site.siteUrl }); + if (!view) continue; + const ref = reportHistoryRef(view, await reportFileSha256(paths, site.siteId, id)); + if (!ref.current) log(`[reports] ${id}: report.json changed since revision ${ref.revision} — export again to commit it`); + histories.set(id, { view, ref, gitDir }); + } catch (e) { + historyProblems.push({ + kind: "unreadable", + report: id, + message: `its revision history (${gitDir}) cannot be read: ${String((e as Error)?.message ?? e).split("\n")[0]}`, + }); + } + } + if (historyProblems.length > 0) throw new ComposeReportsError(historyProblems); const { reports, views, index, moments, mediaOf, cacheDir, allowed } = await resolveSiteReports({ ...opts, + history: (id) => histories.get(id)?.ref, downloads: (id) => ({ ...Object.fromEntries( REPORT_EXPORT_FORMATS.filter((f) => exportsOf.get(id)?.files[f]).map((f) => [f, reportExportDownloadPath(id, f)]), @@ -805,6 +839,11 @@ export async function composeReports(opts: ComposeReportsOptions): Promise<Compo const src = exported[f]; if (src) await copyOut(publicDir, src, reportExportDownloadPath(report.id, f)); } + const history = histories.get(report.id); + if (history) { + const files = await publishReportHistory({ gitDir: history.gitDir, publicDir, view: history.view }); + log(`[reports] ${report.id}: revision ${history.ref.revision}, history published (${files.length} repository files).`); + } } await writeOut( publicDir, @@ -830,7 +869,7 @@ export async function composeReports(opts: ComposeReportsOptions): Promise<Compo `[reports] ${reports.length} report(s), ${moments.length} moment page(s), ${copied} media file(s)` + `${allowed.length ? `, ${allowed.length} without media` : ""}.`, ); - return { reports: index.reports, moments: moments.map((m) => m.key), allowed }; + return { reports: index.reports, moments: moments.map((m) => m.key), allowed, histories: [...histories.keys()] }; } // A clip's published path: the moment's (lib/report/views.ts), `.m4a` for a @@ -848,9 +887,14 @@ function postAuthor(post: Post): string { const defined = <T extends object>(o: T): T => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T; -// The site-root routes the reports add to a sitemap: the index, each report, -// each moment page. -export function reportRoutes(composed: Pick<ComposedReports, "reports" | "moments">): string[] { +// The site-root routes the reports add to a sitemap: the index, each report +// (and its history page, when it has one), each moment page. +export function reportRoutes(composed: Pick<ComposedReports, "reports" | "moments" | "histories">): string[] { if (composed.reports.length === 0) return []; - return ["/reports/", ...composed.reports.map((r) => r.href), ...composed.moments.map((k) => momentPath(k))]; + const histories = new Set(composed.histories ?? []); + return [ + "/reports/", + ...composed.reports.flatMap((r) => (histories.has(r.id) ? [r.href, reportHistoryPagePath(r.id)] : [r.href])), + ...composed.moments.map((k) => momentPath(k)), + ]; } 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_REPO_FILE_RE, + REPORT_HISTORY_VERSION, + claimChanges, + reportChangeSummary, + reportHistoryPagePath, + reportHistoryRepoPath, + reportHistoryViewPath, + revisionCommitMessage, + revisionSummaryOf, + type ReportHistoryRef, + type ReportHistoryView, + type ReportRevisionView, +} from "../lib/report/revisions"; +import { cleanGitEnv } from "./sourceAudit"; + +export { REPORT_HISTORY_REPO_FILE_RE }; +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", + } as unknown as NodeJS.ProcessEnv); + // Set here, for the git child only (written, never read). + return { + ...env, + ...(identity + ? { + GIT_AUTHOR_NAME: identity.name, + GIT_AUTHOR_EMAIL: identity.email, + GIT_COMMITTER_NAME: identity.name, + GIT_COMMITTER_EMAIL: identity.email, + } + : {}), + ...(date ? { GIT_AUTHOR_DATE: gitDateUtc(date), GIT_COMMITTER_DATE: gitDateUtc(date) } : {}), + }; +} + +// 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 ─── + +// 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) { + if (!REPORT_HISTORY_REPO_FILE_RE.test(f)) throw new Error(`the history clone would publish ${f}, which it may not`); + 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; + } +} diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Exporting a changed report records a new revision of it.** `reports export` (and **Export reports** on a site's Reports tab, and the end of a prepare) now commits a revision to the report's own git history, `sites/<site>/reports/<id>/history-git/`, whenever its `report.json` changed since the last one: the `report.json`, its Markdown export and the checksums of every export file, with a message of `Revision N` and a summary of the change. A re-export of an unchanged report records nothing. The commits carry the site's name and a `noreply@<site>.invalid` address with dates in UTC, never your git name, email or time zone. The Reports tab shows each report's revision, its commit and the last change under **Exports**, and the site's next build publishes the history. Add `history-git/` to the corpus repository's `.gitignore`. - **A forum thread can be archived as a posts source.** A XenForo thread URL (`…/threads/<title>.<id>/`; Kiwi Farms is recognised by host) makes a forum-thread channel — platform "xenforo", one channel per thread, each forum post a post — searchable and readable like X and Bluesky posts, in the editor, the export and the MCP (`get_thread` gives a forum post's conversation: the posts it quotes and the posts quoting it). **Fetch posts** reads the thread in a headless browser, newest page first, one page at a time with a 10–20 s pause (the channel key `postPagePauseSeconds` sets it), and stops at already-archived posts; a **Latest N pages** box (`archilyzer posts fetch --pages N`) caps a run, and the next run continues where it stopped. The browser keeps one profile per forum host, so a browser check it clears once (KiwiFlare's proof of work, say) stays cleared; a check that does not clear within a minute, a captcha, a login wall or a refusal stops the run with the reason and keeps its place — never retried at once. **Connect forum session** on the channel page opens that profile in a window on the editor's machine, at the thread, for the operator to clear it or log in. **Import saved pages** (`archilyzer posts import-html <slug> <file-or-dir>…`) reads thread pages saved from a browser ("Save page as", complete or HTML only) through the same parser: new posts are added and a post saved again after an edit is updated. **Capture posts** works on forum posts: a screenshot of the post and its attached files, through the same profile. A forum post keeps its thread title, page, position, author id, last-edit time, quoted posts and its media links; quoted text is marked with "> " lines. - **A site's reports can be exported as files a reader saves and hosts again.** `archilyzer reports export <site> [--report <id>] [--formats html,pdf,md,zip]`, the `reports-export` job (`POST /api/ops/reports-export`, `pnpm ops reports-export`, and **Export reports** on a site's Reports tab) write each published report, checked as the build checks it, into `.export-index/sites/<site>/report-exports/<report>/`: `report.html`, one self-contained page (its own style, no script, stills and post screenshots inlined and recompressed, clips linked on the site); `report.pdf`, that page printed by headless Chromium, skipped with a note where there is none; `report.md`, plain Markdown with numbered references; and `evidence-pack.zip`, the page with its clips, stills and screenshots as files plus the Markdown and the citations, packed by the system `zip` (a host without it fails that format, naming it). An `export.json` names each file's size and checksum and the checksum of the report.json it was made from; every export ends with the report's date and the start of that checksum. Preparing the evidence media now exports at its end when nothing is missing, on the same queue. The build publishes an export beside the report only when it was made from the report as it is now and is at most 24 MiB — a larger evidence pack stays local — and the Reports tab lists each report's exports, their sizes and which the next build publishes. The 24 MiB limit the source mirror and the evidence clips already kept is now one shared number. - **archive.org items are a source (`platform: "archiveorg"`).** A channel can hold recordings imported from archive.org and transcribe them like any transcribe channel. **Import video** takes an item page (`https://archive.org/details/<identifier>`) when the item holds one media file, or ONE file of a multi-file item (`…/details/<identifier>/<file>`); an item with several media files is refused with the way to choose files. `pnpm ops import-archive-org --json '{"slug":…,"item":…,"files":[…]}'` (or `"match": "<regex>"`, `"dryRun": true`) imports chosen files of one item as one drainable job. A whole item's id is its identifier; a file's is `<identifier>__<slug>-<hash>`, stable and unique per file. Each record keeps an `archiveorg.json` sidecar — the item's title, date, creator and collections, its torrent, and for a mirror of a YouTube upload the original's id, URL, title and upload date read from the info.json uploaded beside it — and its metadata takes the file's own page and title (and a mirror's original title and date), recorded in the metadata history as `archiveorg-provenance`. The video page says "Archived on archive.org: <item> · torrent" and, for a mirror, "Originally on YouTube: <url> (uploaded <date>)". The channel form offers archive.org in both platform lists. diff --git a/editor/app/sites/[siteId]/reports/page.tsx b/editor/app/sites/[siteId]/reports/page.tsx @@ -36,7 +36,9 @@ export const metadata: Metadata = { title: "Reports" }; // what prepare and the build would refuse. Publish, unpublish and reorder write // that one key (lib/reportsActions.ts). The evidence media the published // reports cite is prepared here too, before a build, and the reports exported -// as files (HTML, PDF, Markdown, an evidence pack) for the build to publish. +// as files (HTML, PDF, Markdown, an evidence pack) for the build to publish; +// each export of a changed report.json commits a revision to the report's own +// history, and each report shows its revision and the last change. // // The documents themselves are written elsewhere (a converter, or by hand); // this tab never edits a report.json. @@ -163,7 +165,8 @@ export default async function SiteReportsPage({ evidence pack (the page with its clips, stills and screenshots) — for the site&apos;s next build to publish beside the report. Preparing the evidence media exports too. A file over 24 MiB stays - on this machine. + on this machine. Exporting a changed report also commits a new + revision to the report&apos;s history, which the site publishes. </p> </div> <ExportReportsButton siteId={siteId} /> @@ -368,6 +371,18 @@ function ExportSummary({ row }: { row: ReportExportsRow }) { <span className="text-muted-foreground">not exported yet</span> )} </p> + {manifest?.revision && ( + <p data-testid="report-revision" className="text-xs"> + Revision {manifest.revision.revision} · {manifest.revision.date.slice(0, 10)} ·{" "} + <code title={manifest.revision.commit}>{manifest.revision.commit.slice(0, 12)}</code> + {manifest.revision.summary.length > 0 && ( + <span className="text-muted-foreground"> + {" "} + — {manifest.revision.summary.join("; ")} + </span> + )} + </p> + )} {manifest && ( <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs"> {REPORT_EXPORT_FORMATS.filter((f) => manifest.files[f]).map((f) => ( diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **A report shows its revision, and every edit to it can be checked.** Under a report's dates its page now reads "Revision N · <date> · history". The history page, `/reports/<id>/history/`, lists every revision, newest first: its number, date (UTC), commit hash and the sha256 of its `report.json`, what changed (claims added or removed, verdicts changed, claims edited, citations added or removed, quotes edited, title, series or subtitle changed), and each changed claim's title, text, verdict and findings with the words removed struck through and the words added marked. The same data is in `history.json` beside the page. Each report's history is its own git repository, published for cloning: `git clone <site>/reports/<id>/history/repo`. Each commit names the site as its author, with its date in UTC. The footer of the report's HTML, PDF and Markdown downloads now begins with the revision number, and its sha256 can be looked up on the history page. Needs `reports export` and a rebuild and deploy of each site with reports. - **A report can be saved whole: as one HTML page, a PDF, Markdown, or an evidence pack.** A report page's download line now reads HTML · PDF · Markdown · Evidence pack · Citations JSON · CSV, each listed only when the site publishes it. The HTML is one file that opens with no network: the report with its verdicts, the document's sentences and the post screenshots inside it, numbered citations, and a reference list giving each quote's speaker, date, record, the original at its time and the moment page on the site. The PDF is that page printed. The Markdown is the same report as plain text with numbered references. The evidence pack is a zip of the page with its clips, stills and screenshots beside it, so the clips play offline. Each ends with a line naming the report's date and the start of its checksum. Needs `reports export` (or prepare) and a rebuild and deploy of each site with reports. - **A report's claim can carry a flag, its title a byline, and a site with one report names it in the browser tab.** `report.json` claim `flag` (one line, at most 60 characters) shows as a small pill in the accent colour beside the claim's verdict, e.g. "No source given". On a report-only site with one report, the home page's tab title is the report's, as on the report's own page, where it was the site's title alone. A report's page follows its title with a byline from the document under review, "by <author> · <publisher>", in the same line when it fits; the publisher (or, with none, the author) links to the document. Under it, a step apart, the page names its own: "Fact-check by <site title>" ("Report by …"), then the dates, then the subtitle; the kind's label no longer sits above the title on the report's page. A claim's sentence from the document under review no longer links up to the document's box: it sits on a rail in the document's colour, and the box's left edge wears the same colour (a source's `accent`, `"#rrggbb"`; without one, the border colour). A sentence of another document keeps its "from <title>" link. A report's citation can say where its evidence came from (`origin`: `"subject"`, the document under review gave it; `"added"`, the report's author found it). A claim lists what the report added first, each card marked with the Archilyzer mark and "Not in the article" ("Not in the source"), then evidence of unknown origin, then what the document gave itself folded under "In the article (n)"; the reference list marks an added citation with the mark alone, and a claim's flag pill wears the same mark. A fact-check's page reads in three tiers, each opened by a hairline with one, two or three dots and its reading time (at 230 words a minute): the quick take (the tally, the summary, and links to what the check found, every claim and the downloads); **What the check found**, every ruled claim grouped by verdict (contradicted, not found, partly, untestable, corroborated), one line each linking to the claim, with its `gist` (a new optional claim field, one line, at most 240 characters) and its flag; and **Every claim, with its evidence**, which opens with **How it was checked** (`method`, a new optional report field in markdown). A report of kind `sweep` has the first and last tiers only. Needs a rebuild and deploy of the site. - **Forum posts read like the other posts.** A post from a forum-thread channel shows its place in the thread (#N), an "edited" mark, the thread's title and its media as links, and opening its thread shows its conversation — the posts it quotes and the posts quoting it — rather than the whole forum thread. diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx @@ -24,7 +24,7 @@ import { ArchiveList, ReportName, SourceBlock, dateLabel, textLink } from "./par // ONE REPORT, from its view (common/lib/report/views.ts): the header (title and // the reviewed document's byline, then ours — "Fact-check by <site>" — and the -// dates, the subtitle, the document under review with its archive links), a +// dates and the revision with its history link, the subtitle, the document under review with its archive links), a // fact-check's tally, the summary, the sections and their claims — each claim // its verdict and flag, the document's own sentence (its still), the findings with // their inline citations, and the evidence cards (what the report added first, @@ -296,6 +296,17 @@ export default function ReportArticle({ view, siteTitle }: { view: ReportPageVie {reportAttribution(view.kind, siteTitle)} </p> {dates.length > 0 && <p className="font-mono text-xs text-muted-foreground">{dates.join(" · ")}</p>} + {view.history && ( + <p data-report-revision={view.history.revision} className="font-mono text-xs text-muted-foreground"> + {view.history.current + ? `Revision ${view.history.revision} · ${dateLabel(view.history.date)}` + : `Edited since revision ${view.history.revision}`} + {" · "} + <a href={view.history.href} className={textLink}> + history + </a> + </p> + )} </div> {view.subtitle && <p className="text-lg text-muted-foreground">{view.subtitle}</p>} {subject && <SourceBlock source={subject} label="Under review" />} diff --git a/export/app/components/reports/ReportHistory.tsx b/export/app/components/reports/ReportHistory.tsx @@ -0,0 +1,146 @@ +import { + reportHistoryViewPath, + type ClaimChange, + type ClaimDiffField, + type DiffSegment, + type ReportHistoryView, + type ReportRevisionView, +} from "yt-dlp-transcript-common/lib/report/revisions"; +import { reportPagePath } from "yt-dlp-transcript-common/lib/report/views"; +import { ReportName, textLink } from "./parts"; + +// A REPORT'S REVISION HISTORY, from its history.json (common/lib/report/revisions.ts): +// how to clone it, then every revision, newest first — its number, date, +// commit and report.json sha256 (the hash every export's footer prints), the +// change summary, and each changed claim as an inline word diff against the +// revision before. + +const FIELD_LABELS: Record<ClaimDiffField, string> = { + title: "Title", + text: "Claim", + verdict: "Verdict", + findings: "Findings", +}; + +const STATUS_LABELS: Record<ClaimChange["status"], string> = { + added: "added", + removed: "removed", + edited: "edited", +}; + +// `2026-03-08T12:00:00Z` → `2026-03-08 12:00 UTC`. +export function revisionDate(iso: string): string { + const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso); + return m ? `${m[1]} ${m[2]} UTC` : iso; +} + +function Diff({ segments }: { segments: DiffSegment[] }) { + return ( + <> + {segments.map((s, i) => + s.op === "ins" ? ( + <ins key={i} data-diff="ins" className="rounded-sm bg-success-soft text-foreground no-underline decoration-success"> + {s.text} + </ins> + ) : s.op === "del" ? ( + <del key={i} data-diff="del" className="rounded-sm bg-destructive-soft text-muted-foreground decoration-destructive"> + {s.text} + </del> + ) : ( + <span key={i}>{s.text}</span> + ), + )} + </> + ); +} + +function ClaimDiff({ change }: { change: ClaimChange }) { + return ( + <div + data-claim-change={change.id} + data-status={change.status} + className="flex flex-col gap-1.5 rounded-md border border-border bg-card px-3 py-2 text-sm" + > + <p className="font-mono text-[11px] text-muted-foreground"> + <span className="text-foreground">{change.id}</span> · {change.section} · {STATUS_LABELS[change.status]} + </p> + {change.fields.map((f) => ( + <p key={f.field} data-field={f.field} className="whitespace-pre-wrap break-words leading-relaxed"> + <span className="mr-2 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground"> + {FIELD_LABELS[f.field]} + </span> + <Diff segments={f.diff} /> + </p> + ))} + </div> + ); +} + +function Revision({ r }: { r: ReportRevisionView }) { + return ( + <li id={`r${r.revision}`} data-revision={r.revision} className="flex scroll-mt-20 flex-col gap-3 border-t border-border pt-5"> + <div className="flex flex-col gap-1"> + <h2 className="font-display text-xl font-semibold tracking-tight text-foreground">Revision {r.revision}</h2> + <p className="font-mono text-xs text-muted-foreground">{revisionDate(r.date)}</p> + </div> + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 font-mono text-xs"> + <dt className="text-muted-foreground">commit</dt> + <dd data-commit={r.commit} className="break-all text-foreground"> + {r.commit} + </dd> + <dt className="text-muted-foreground">report sha256</dt> + <dd data-report-sha256={r.reportSha256} className="break-all text-foreground"> + {r.reportSha256} + </dd> + </dl> + {r.summary.length > 0 && ( + <ul data-revision-summary="" className="flex list-disc flex-col gap-0.5 pl-5 text-sm text-foreground"> + {r.summary.map((line, i) => ( + <li key={i}>{line}</li> + ))} + </ul> + )} + {r.claims.length > 0 && ( + <div className="flex flex-col gap-2"> + {r.claims.map((c) => ( + <ClaimDiff key={c.id} change={c} /> + ))} + </div> + )} + </li> + ); +} + +export default function ReportHistory({ view }: { view: ReportHistoryView }) { + const revisions = [...view.revisions].reverse(); + return ( + <article data-report-history={view.reportId} className="mx-auto flex w-full max-w-3xl flex-col gap-6"> + <header className="flex flex-col gap-3"> + <p className="text-sm"> + <a href={reportPagePath(view.reportId)} className={textLink}> + <ReportName series={view.series} title={view.title} /> + </a> + </p> + <h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-foreground">Revision history</h1> + <p className="text-sm text-muted-foreground"> + Every edit to this report, newest first. Each revision is a commit in the report&apos;s own git history; its + report sha256 is the hash printed at the foot of the report&apos;s downloads.{" "} + <a href={reportHistoryViewPath(view.reportId)} className={textLink}> + history.json + </a> + </p> + <pre + data-history-clone="" + className="overflow-x-auto rounded-md border border-border bg-muted px-3 py-2 font-mono text-xs text-foreground" + > + <code>git clone {view.clone}</code> + </pre> + </header> + <ol className="flex flex-col gap-6"> + {revisions.map((r) => ( + <Revision key={r.revision} r={r} /> + ))} + </ol> + </article> + ); +} diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts @@ -90,7 +90,7 @@ test("the report page: header, tally, sections and claims, inline cites, referen // then ours, a step apart: the kind by the site, the dates under it, then the subtitle assert.match( html, - /<\/h1><div[^>]*><p data-report-attribution=""[^>]*>Fact-check by Demo Reports<\/p><p[^>]*>Published 2026-10-01 · Updated 2026-10-04<\/p><\/div><p[^>]*>Four claims about a demo channel/, + /<\/h1><div[^>]*><p data-report-attribution=""[^>]*>Fact-check by Demo Reports<\/p><p[^>]*>Published 2026-10-01 · Updated 2026-10-04<\/p><p data-report-revision="2"[^>]*>Revision 2 · 2026-10-04 · <a href="\/reports\/demo-factcheck\/history\/"[^>]*>history<\/a><\/p><\/div><p[^>]*>Four claims about a demo channel/, ); assert.doesNotMatch(html.slice(0, html.indexOf("<h1")), /Fact-check/, "no kind eyebrow above the title"); assert.match(html, /data-subject-source="s0"/); diff --git a/export/app/lib/reports.ts b/export/app/lib/reports.ts @@ -7,6 +7,11 @@ import { currentSite } from "./site"; import { parseMomentKey } from "yt-dlp-transcript-common/lib/citations/moments"; import { isReportId } from "yt-dlp-transcript-common/lib/report/schema"; import { + REPORT_HISTORY_FORMAT, + reportHistoryViewPath, + type ReportHistoryView, +} from "yt-dlp-transcript-common/lib/report/revisions"; +import { MOMENT_INDEX_FORMAT, MOMENT_PAGE_FORMAT, MOMENTS_INDEX_PATH, @@ -62,6 +67,14 @@ export function readReportView(reportId: string): ReportPageView | null { return v && v.id === reportId && Array.isArray(v.sections) ? v : null; } +// A report's revision history (/reports/<id>/history/history.json), or null +// when compose published none for it. +export function readReportHistory(reportId: string): ReportHistoryView | null { + if (!isReportId(reportId)) return null; + const v = readView<ReportHistoryView>(reportHistoryViewPath(reportId), REPORT_HISTORY_FORMAT); + return v && v.reportId === reportId && Array.isArray(v.revisions) && v.revisions.length > 0 ? v : null; +} + // The build's one report, when it publishes exactly one: a cited site's home // page IS that report. export function onlyReportView(): ReportPageView | null { diff --git a/export/app/reports/[reportId]/history/page.tsx b/export/app/reports/[reportId]/history/page.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; +import { REPORT_PLACEHOLDER_ID, reportFullTitle } from "yt-dlp-transcript-common/lib/report/views"; +import ReportHistory from "../../../components/reports/ReportHistory"; +import { EmptyState } from "../../../components/reports/parts"; +import { readReportHistory, reportIds } from "../../../lib/reports"; + +// /reports/<reportId>/history/: the report's revisions, from +// /reports/<reportId>/history/history.json. Static: built for every published +// report (and, with none, the placeholder, as /reports/<reportId>/ is); a +// report with no published history says so. +export const dynamicParams = false; + +export function generateStaticParams(): { reportId: string }[] { + const ids = reportIds(); + return (ids.length > 0 ? ids : [REPORT_PLACEHOLDER_ID]).map((reportId) => ({ reportId })); +} + +type Params = { params: Promise<{ reportId: string }> }; + +export async function generateMetadata({ params }: Params): Promise<Metadata> { + const view = readReportHistory((await params).reportId); + if (!view) return { title: "No history" }; + return { title: `Revision history — ${reportFullTitle(view)}` }; +} + +export default async function ReportHistoryPage({ params }: Params) { + const view = readReportHistory((await params).reportId); + if (!view) { + return <EmptyState title="No history">This report has no published revision history.</EmptyState>; + } + return <ReportHistory view={view} />; +} diff --git a/export/e2e-report/audit.spec.ts b/export/e2e-report/audit.spec.ts @@ -33,6 +33,12 @@ test("it holds the reports, the moments, the cited media and the contract", () = "reports/demo-factcheck/report.md", "reports/demo-factcheck/evidence-pack.zip", "reports/demo-factcheck/stills/a01.png", + "reports/demo-factcheck/history/index.html", + "reports/demo-factcheck/history/history.json", + "reports/demo-factcheck/history/repo/HEAD", + "reports/demo-factcheck/history/repo/info/refs", + "reports/demo-factcheck/history/repo/objects/info/packs", + "reports/demo-factcheck/history/repo/refs/heads/main", "m/index.json", "m/demo-channel/abc123/3126.00-3151.00/index.html", "m/demo-channel/abc123/3126.00-3151.00/moment.json", diff --git a/export/e2e-report/contract.ts b/export/e2e-report/contract.ts @@ -1,8 +1,10 @@ // The cited fixture site's compose step (stage.ts runs it, in a child process // with the stage's environment): what compose's reports stage writes beside -// the views — the citation downloads and the report's exports (HTML, PDF, -// Markdown, evidence pack, by publish/reportExports.ts's writer) — and the -// cited site's contract, by +// the views — the citation downloads, the report's exports (HTML, PDF, +// Markdown, evidence pack, by publish/reportExports.ts's writer), its two +// revisions (fixture.ts FIXTURE_REVISIONS, committed by that writer into a +// store in the stage) and their published history (history.json and the +// dumb-HTTP clone, publish/reportHistory.ts) — and the cited site's contract, by // compose's own functions (bin/compose-site.ts emitFederationFiles / // emitAiFiles), so the stage's public/ is what compose would leave for this // site. The view JSON, stills and media are the fixture's, already in place. @@ -21,7 +23,10 @@ const { citationSet, citationsCsv } = await import("yt-dlp-transcript-common/pub const { MOMENTS_INDEX_PATH, REPORTS_INDEX_PATH, reportCitationsDownloadPath, reportViewPath } = await import( "yt-dlp-transcript-common/lib/report/views" ); -const { FIXTURE_DIR, readFixtureReport } = await import("../fixtures/report-site/fixture"); +const { FIXTURE_REVISIONS, buildFixtureReportView, fixtureReportFile, readFixtureReport } = await import( + "../fixtures/report-site/fixture" +); +const { publishReportHistory, readReportHistoryView } = await import("yt-dlp-transcript-common/publish/reportHistory"); const { openPlaywrightPdfPrinter, writeReportExports } = await import("yt-dlp-transcript-common/publish/reportExports"); const { sha256Hex } = await import("yt-dlp-transcript-common/publish/reportExportFiles"); const { REPORT_EXPORT_FILENAMES, REPORT_EXPORT_FORMATS, momentViewPath, reportExportDownloadPath } = await import( @@ -39,6 +44,7 @@ const readJson = <T>(urlPath: string): T => JSON.parse(fs.readFileSync(pub(urlPa const index = readJson<ReportIndexView>(REPORTS_INDEX_PATH); const moments = readJson<MomentIndexView>(MOMENTS_INDEX_PATH).moments; const report = readFixtureReport(); +const histories: string[] = []; for (const entry of index.reports) { if (entry.id !== report.id) throw new Error(`contract.ts: the fixture has no report "${entry.id}"`); const view = readJson<ReportPageView>(reportViewPath(entry.id)); @@ -50,43 +56,79 @@ for (const entry of index.reports) { // The report's exports, by the host step's own writer, from the files // already in public/ (its stills, the post's shot, the clips), published - // where compose publishes them. + // where compose publishes them. Each earlier revision is exported first (its + // Markdown, which commits it); the last export commits the report as it is. const printer = openPlaywrightPdfPrinter(); - const dir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "report-site-exports-")), entry.id); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "report-site-exports-")); + const dir = path.join(tmp, entry.id); + const gitDir = path.join(tmp, "history-git"); + const files = { + local: (p: string) => (fs.existsSync(pub(p)) ? pub(p) : undefined), + clip: (c: { moment: string }) => { + const m = readJson<MomentPageView>(momentViewPath(c.moment)); + return m.clip + ? { sitePath: m.clip.src, file: pub(m.clip.src), kind: m.kind === "audio" ? ("audio" as const) : ("video" as const) } + : undefined; + }, + }; try { + for (const rev of FIXTURE_REVISIONS.slice(0, -1)) { + const earlier = readFixtureReport(rev.file); + const reportJson = fs.readFileSync(fixtureReportFile(rev.file)); + const r = await writeReportExports({ + dir: path.join(tmp, `earlier-${rev.file}`), + site, + report: earlier, + view: buildFixtureReportView(earlier), + reportSha256: sha256Hex(reportJson), + files, + ffmpegBin: paths.ffmpegBin, + formats: ["md"], + printer: () => printer, + zipBin: "zip", + now: new Date(rev.date), + log: console.log, + history: { gitDir, reportJson }, + }); + if (r.problems.length > 0) throw new Error(`contract.ts: revision ${rev.file}: ${JSON.stringify(r.problems)}`); + } + const reportJson = fs.readFileSync(fixtureReportFile()); const { result, problems } = await writeReportExports({ dir, site, report, view, - reportSha256: sha256Hex(fs.readFileSync(path.join(FIXTURE_DIR, "source", entry.id, "report.json"))), - files: { - local: (p) => (fs.existsSync(pub(p)) ? pub(p) : undefined), - clip: (c) => { - const m = readJson<MomentPageView>(momentViewPath(c.moment)); - return m.clip ? { sitePath: m.clip.src, file: pub(m.clip.src), kind: m.kind === "audio" ? "audio" : "video" } : undefined; - }, - }, + reportSha256: sha256Hex(reportJson), + files, ffmpegBin: paths.ffmpegBin, formats: REPORT_EXPORT_FORMATS, printer: () => printer, zipBin: "zip", - now: new Date(), + now: new Date(FIXTURE_REVISIONS[FIXTURE_REVISIONS.length - 1].date), log: console.log, + history: { gitDir, reportJson }, }); + if (result.manifest.revision?.revision !== FIXTURE_REVISIONS.length) { + throw new Error(`contract.ts: the fixture's last export is not revision ${FIXTURE_REVISIONS.length}`); + } if (problems.length > 0 || result.manifest.notes.length > 0) { throw new Error(`contract.ts: the fixture's exports: ${JSON.stringify([...problems, ...result.manifest.notes])}`); } for (const f of REPORT_EXPORT_FORMATS) { fs.copyFileSync(path.join(dir, REPORT_EXPORT_FILENAMES[f]), pub(reportExportDownloadPath(entry.id, f))); } + // The history, published as compose publishes it. + const history = await readReportHistoryView(gitDir, { reportId: entry.id, siteUrl: site.siteUrl }); + if (!history) throw new Error("contract.ts: the fixture has no revision history"); + await publishReportHistory({ gitDir, publicDir: paths.exportPublicDir, view: history }); + histories.push(entry.id); } finally { const p = await printer; if (!("missing" in p)) await p.close(); - fs.rmSync(path.dirname(dir), { recursive: true, force: true }); + fs.rmSync(tmp, { recursive: true, force: true }); } } await emitFederationFiles(site, paths); -await emitAiFiles(site, paths, { reports: index.reports, moments, allowed: [] }); +await emitAiFiles(site, paths, { reports: index.reports, moments, allowed: [], histories }); console.log(`[report-site] composed the contract of cited site "${site.siteId}" into ${paths.exportPublicDir}`); diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts @@ -1,5 +1,13 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { expect, test } from "./helpers"; import type { Locator, Page } from "@playwright/test"; +import { reportChangeSummary, type ReportHistoryView } from "yt-dlp-transcript-common/lib/report/revisions"; +import { historyGitEnv } from "yt-dlp-transcript-common/publish/reportHistory"; +import { readFixtureReport } from "../fixtures/report-site/fixture"; // A cited site, as a reader meets it: its one report as the home page, the // report (verdicts, tally, the source's sentence, numbered inline citations @@ -10,12 +18,14 @@ import type { Locator, Page } from "@playwright/test"; // The site is export/fixtures/report-site, staged and built by // e2e-report/stage.ts: one fact-check citing a video span (c01), an audio // span (au1), a post (p01), the article's own sentences (a01, a02) and a web -// page (w01). +// page (w01). It has two revisions (report.v1.json, then report.json), and +// publishes their history. const REPORT = "/reports/demo-factcheck/"; const VIDEO_MOMENT = "/m/demo-channel/abc123/3126.00-3151.00/"; const AUDIO_MOMENT = "/m/demo-podcast/ep-042/610.50-628.00/"; const POST_MOMENT = "/m/demo-social/1234567890/"; +const HISTORY = "/reports/demo-factcheck/history/"; const inlineCite = (page: Page, id: string): Locator => page.locator(`[data-inline-cite="${id}"]`).first(); const preview = (page: Page, id: string): Locator => page.locator(`[data-cite-preview="${id}"]`); @@ -271,6 +281,85 @@ test("report.html opens with no network: one file, no script, every image inline expect(asked).toEqual([]); }); +test("the report names its revision beside its dates, and links its history", async ({ page }) => { + await page.goto(REPORT); + const line = page.locator("[data-report-revision]"); + await expect(line).toHaveText("Revision 2 · 2026-10-04 · history"); + await line.getByRole("link", { name: "history" }).click(); + await expect(page).toHaveURL(HISTORY); + await expect(page.locator("h1")).toHaveText("Revision history"); +}); + +test("the history lists both revisions, newest first: hashes, the change summary, a claim-level word diff", async ({ + page, + request, +}) => { + const history = (await (await request.get(`${HISTORY}history.json`)).json()) as ReportHistoryView; + expect(history.revisions.map((r) => r.revision)).toEqual([1, 2]); + await page.goto(HISTORY); + const revisions = page.locator("li[data-revision]"); + await expect(revisions).toHaveCount(2); + await expect(revisions.nth(0)).toHaveAttribute("data-revision", "2"); + for (const r of history.revisions) { + const li = page.locator(`li[data-revision="${r.revision}"]`); + await expect(li.locator("h2")).toHaveText(`Revision ${r.revision}`); + await expect(li.locator("[data-commit]")).toHaveText(r.commit); + await expect(li.locator("[data-report-sha256]")).toHaveText(r.reportSha256); + } + // The summary is the commit's, from the two report.json files. + const v1 = readFixtureReport("report.v1.json"); + const v2 = readFixtureReport(); + await expect(page.locator('li[data-revision="1"] [data-revision-summary] li')).toHaveText(reportChangeSummary(null, v1)); + await expect(page.locator('li[data-revision="2"] [data-revision-summary] li')).toHaveText(reportChangeSummary(v1, v2)); + // The diff: a verdict changed, words of a claim replaced, a claim added. + const r2 = page.locator('li[data-revision="2"]'); + const verdict = r2.locator('[data-claim-change="claim-1"] [data-field="verdict"]'); + await expect(verdict.locator("del")).toHaveText("PARTLY"); + await expect(verdict.locator("ins")).toHaveText("CONTRADICTED"); + const text = r2.locator('[data-claim-change="claim-2"] [data-field="text"]'); + await expect(text.locator("del")).toHaveText("month."); + await expect(text.locator("ins")).toHaveText("year."); + await expect(r2.locator('[data-claim-change="claim-5"]')).toHaveAttribute("data-status", "added"); + await expect(page.locator('li[data-revision="1"] [data-claim-change]')).toHaveCount(0); + await expect(page.locator("[data-history-clone]")).toHaveText( + "git clone https://reports.example.org/reports/demo-factcheck/history/repo", + ); + // The footer of every export names the revision and its report sha256. + const md = await (await request.get(`${REPORT}report.md`)).text(); + expect(md).toContain(`Revision 2 · 2026-10-04 · report sha256 ${history.revisions[1].reportSha256.slice(0, 12)}`); +}); + +test("the history on a phone: no page-wide horizontal scroll", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 800 }); + await page.goto(HISTORY); + await expect(page.locator("li[data-revision]")).toHaveCount(2); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(0); +}); + +test("the history clones over HTTP: its report.json is the one history.json names", async ({ baseURL, request }) => { + const history = (await (await request.get(`${HISTORY}history.json`)).json()) as ReportHistoryView; + const dest = fs.mkdtempSync(path.join(os.tmpdir(), "report-history-clone-")); + try { + execFileSync("git", ["clone", "--quiet", `${baseURL}${HISTORY}repo`, path.join(dest, "repo")], { + env: historyGitEnv(), + stdio: "pipe", + }); + const sha = (b: Buffer) => createHash("sha256").update(b).digest("hex"); + expect(sha(fs.readFileSync(path.join(dest, "repo", "report.json")))).toBe(history.revisions[1].reportSha256); + const log = execFileSync("git", ["-C", path.join(dest, "repo"), "log", "--format=%H %s"], { + env: historyGitEnv(), + encoding: "utf8", + }); + expect(log.trim().split("\n")).toEqual([ + `${history.revisions[1].commit} Revision 2`, + `${history.revisions[0].commit} Revision 1`, + ]); + } finally { + fs.rmSync(dest, { recursive: true, force: true }); + } +}); + test("a video citation opens its moment: the clip, the cue lines with the span marked, where it is cited", async ({ page, request, @@ -339,7 +428,7 @@ for (const route of ["/ask/", "/downloads/", "/duplicates/"]) { } test("reading the whole site never asks for the corpus", async ({ page, traffic }) => { - for (const route of ["/", REPORT, VIDEO_MOMENT, AUDIO_MOMENT, POST_MOMENT, "/ask/", "/downloads/"]) { + for (const route of ["/", REPORT, HISTORY, VIDEO_MOMENT, AUDIO_MOMENT, POST_MOMENT, "/ask/", "/downloads/"]) { await page.goto(route); await page.waitForLoadState("networkidle"); } diff --git a/export/fixtures/report-site/fixture.ts b/export/fixtures/report-site/fixture.ts @@ -8,6 +8,11 @@ // `write.ts` writes them under public/, and fixture.test.ts holds the written // files equal to a fresh build, so the JSON can never drift from the builder. // +// THE REVISIONS. The report has two (common/publish/reportHistory.ts): +// source/demo-factcheck/report.v1.json, then report.json — FIXTURE_REVISIONS, +// committed at fixed dates by e2e-report/contract.ts. The page view names the +// second as current. +// // Everything here is neutral demo data. import fs from "node:fs"; @@ -16,6 +21,7 @@ import { fileURLToPath } from "node:url"; import { buildCitedIn } from "yt-dlp-transcript-common/lib/report/citedIn"; import { parseReport } from "yt-dlp-transcript-common/lib/report/validate"; import type { Report } from "yt-dlp-transcript-common/lib/report/schema"; +import { reportHistoryPagePath } from "yt-dlp-transcript-common/lib/report/revisions"; import { momentKeyOf, parseMomentKey, type SpanMoment } from "yt-dlp-transcript-common/lib/citations/moments"; import { MOMENT_INDEX_FORMAT, @@ -43,6 +49,13 @@ export const FIXTURE_DIR = path.dirname(fileURLToPath(import.meta.url)); export const FIXTURE_PUBLIC_DIR = path.join(FIXTURE_DIR, "public"); export const FIXTURE_REPORT_ID = "demo-factcheck"; +// The report's revisions, oldest first: the file under source/<id>/ and the +// commit's date. +export const FIXTURE_REVISIONS: readonly { file: string; date: string }[] = [ + { file: "report.v1.json", date: "2026-10-01T09:00:00Z" }, + { file: "report.json", date: "2026-10-04T12:00:00Z" }, +]; + const RECORDS: Record<string, RecordView> = { "demo-channel/abc123": { channel: "demo-channel", @@ -76,10 +89,12 @@ function shotFor(c: { channel: string; id: string }): string { return `/media/posts/${c.channel}/${c.id}/shot.png`; } -export function readFixtureReport(): Report { - const raw = JSON.parse( - fs.readFileSync(path.join(FIXTURE_DIR, "source", FIXTURE_REPORT_ID, "report.json"), "utf8"), - ); +export function fixtureReportFile(file = "report.json"): string { + return path.join(FIXTURE_DIR, "source", FIXTURE_REPORT_ID, file); +} + +export function readFixtureReport(file = "report.json"): Report { + const raw = JSON.parse(fs.readFileSync(fixtureReportFile(file), "utf8")); const parsed = parseReport(raw); if (!parsed.ok || parsed.problems.length > 0) { throw new Error(`fixture report is not sound: ${JSON.stringify(parsed.problems)}`); @@ -110,6 +125,13 @@ export function buildFixtureReportView(report = readFixtureReport()): ReportPage json: reportCitationsDownloadPath(report.id, "json"), csv: reportCitationsDownloadPath(report.id, "csv"), }, + // The newest revision, as compose names it (contract.ts commits both). + history: { + revision: FIXTURE_REVISIONS.length, + date: FIXTURE_REVISIONS[FIXTURE_REVISIONS.length - 1].date, + href: reportHistoryPagePath(report.id), + current: true, + }, }); } diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json @@ -230,5 +230,11 @@ "zip": "/reports/demo-factcheck/evidence-pack.zip", "json": "/reports/demo-factcheck/citations.json", "csv": "/reports/demo-factcheck/citations.csv" + }, + "history": { + "revision": 2, + "date": "2026-10-04T12:00:00Z", + "href": "/reports/demo-factcheck/history/", + "current": true } } diff --git a/export/fixtures/report-site/source/demo-factcheck/report.v1.json b/export/fixtures/report-site/source/demo-factcheck/report.v1.json @@ -0,0 +1,150 @@ +{ + "format": "archilyzer-report", + "version": 1, + "id": "demo-factcheck", + "kind": "factcheck", + "title": "Checking an example article", + "subtitle": "Three claims about a demo channel, tested against what was said on air", + "summary": "The article makes four claims. The recordings partly support two and contradict a third; the fourth cannot be tested. The host said the opening date out loud [on stream](cite:c01), and later [repeated it](cite:au1).", + "method": "Each claim was searched for in the channel's transcripts and the guest's podcast, and checked against the city's public records.", + "published": "2026-10-01", + "subject": { + "source": "s0" + }, + "sources": { + "s0": { + "kind": "article", + "title": "An example article about a demo channel", + "url": "https://example.org/articles/demo", + "publisher": "Example Gazette", + "author": "A. Writer", + "date": "2026-09-20", + "note": "Read in the edition published on the day; it has since been revised.", + "accent": "#8a6fb0", + "archives": [ + { + "label": "archive.org", + "url": "https://web.archive.org/web/2026/https://example.org/articles/demo", + "context": "as published on the day" + } + ] + } + }, + "citations": { + "c01": { + "kind": "video", + "channel": "demo-channel", + "id": "abc123", + "start": 3126, + "end": 3151, + "pad": { + "before": 5, + "after": 5 + }, + "quote": "The bridge opened in the spring of twenty nineteen, I was there for it.", + "origin": "added", + "verification": { + "quoteScore": 0.97, + "quoteCheckedAt": "2026-10-04T12:00:00Z", + "voiceChecked": true, + "method": "cue-window v1" + } + }, + "au1": { + "kind": "audio", + "channel": "demo-podcast", + "id": "ep-042", + "start": 610.5, + "end": 628, + "quote": "Like I said before, spring twenty nineteen.", + "speaker": "The guest", + "verification": { + "quoteScore": 0.88, + "quoteCheckedAt": "2026-10-04T12:00:00Z" + } + }, + "p01": { + "kind": "post", + "channel": "demo-social", + "id": "1234567890", + "quote": "Never said I would move. Not once.", + "date": "2025-03-14" + }, + "a01": { + "kind": "source", + "source": "s0", + "quote": "He opened the bridge himself in 2018.", + "image": "stills/a01.png" + }, + "a02": { + "kind": "source", + "source": "s0", + "quote": "He has said many times that he would move away." + }, + "unused": { + "kind": "page", + "url": "https://example.org/unused", + "quote": "Not cited anywhere." + } + }, + "sections": [ + { + "id": "bridge", + "title": "The bridge", + "body": "The article's first chapter is about the bridge.", + "claims": [ + { + "id": "claim-1", + "title": "“He opened it himself”", + "text": "He opened the bridge himself in 2018.", + "verdict": "PARTLY", + "gist": "He was there; the date is in doubt.", + "sourceQuote": { + "citation": "a01" + }, + "findings": "He was there, but the date is in doubt: he says so [on stream](cite:c01).", + "citations": [ + "c01", + "au1" + ] + }, + { + "id": "claim-2", + "text": "He went back to the bridge every month.", + "verdict": "PARTLY", + "gist": "Once is on record; every year is not.", + "findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.", + "citations": [ + "au1" + ] + } + ] + }, + { + "id": "moving", + "title": "Moving away", + "claims": [ + { + "id": "claim-3", + "text": "He has said many times that he would move away.", + "verdict": "CONTRADICTED", + "gist": "He denies ever saying it.", + "sourceQuote": { + "citation": "a02" + }, + "findings": "The article [puts it as a habit](cite:a02); he [denies it outright](cite:p01).", + "citations": [ + "p01" + ] + }, + { + "id": "claim-4", + "text": "He privately regrets it.", + "verdict": "UNTESTABLE", + "flag": "No source given", + "findings": "Nothing public speaks to a private feeling." + } + ] + } + ] +} diff --git a/plans/report-sites.md b/plans/report-sites.md @@ -1,6 +1,7 @@ # Report sites — a site built around cited reports -Status: BUILT — R0–R8 merged 2026-10-05 (plus R4b page size); first private report site built locally. 2026-10-05: `publish` replaced by a per-site `search` switch; report-only is derived (search off); export's `start` serves moment dirs (`scripts/serve-out.mjs`). Follow-ups: cited corpus.json still describes shards, voice checks recorded in citations, saveSiteAction onto patchSite. +Status: BUILT — R0–R8 merged 2026-10-05 (plus R4b page size); first private report site built locally. RH +(per-report revision history) built 2026-10-05, see "History". 2026-10-05: `publish` replaced by a per-site `search` switch; report-only is derived (search off); export's `start` serves moment dirs (`scripts/serve-out.mjs`). Follow-ups: cited corpus.json still describes shards, voice checks recorded in citations, saveSiteAction onto patchSite. ## What it is @@ -157,6 +158,56 @@ A report must survive a takedown as files anyone can save and host again (slice stays local. The view's `downloads` lists what was published and the report page's download line shows HTML · PDF · Markdown · Evidence pack · Citations JSON · CSV. `reports/` is allowed wholesale by the cited audit. +## History + +Readers can check that a report is the one published and see every edit, with hashes (slice RH). History is +PER REPORT, never site-wide. + +- **Store** — a bare git repo per report beside its report.json: `sites/<siteId>/reports/<id>/history-git/` + (`common/publish/reportHistory.ts`; never a path segment named `.git`). It is inside the corpus repo's tree; + nothing writes a `.gitignore` for it — the operator adds `history-git/` there. One branch, `main`. +- **Revisions** — `reports export` (`reportExports.ts` `writeReportExports`, given the store and the report.json's + bytes) commits one when the sha256 of report.json differs from the newest revision's; an unchanged report commits + nothing. A commit holds `report.json` (byte for byte), `report.md` (the Markdown export) and `exports.json` + (format `archilyzer-report-revision-exports`: the sha256 and size of every export file made and of the citations + JSON and CSV). Message: `Revision N`, a blank line, `- ` lines of `reportChangeSummary` + (`common/lib/report/revisions.ts`, pure): title/series/subtitle changes, claims added/removed, verdict changes, + claims edited (title, text, findings), citations added/removed, quotes edited, summary/method edited; the first + revision counts what it holds; anything else is one "Other edits" line. Written with git plumbing + (`hash-object`, `mktree`, `commit-tree --no-gpg-sign`, `update-ref` with the old value). +- **Identity** — author and committer = the site's title, `noreply@<siteId>.invalid`; `GIT_AUTHOR_DATE` / + `GIT_COMMITTER_DATE` as `@<seconds> +0000`. git runs with `extendEnv: false` and a minimal env through + `cleanGitEnv`: PATH, HOME/XDG_CONFIG_HOME at a path that does not exist, `GIT_CONFIG_NOSYSTEM=1`, + `GIT_CONFIG_GLOBAL=/dev/null`, `TZ=UTC`, plus `-c commit.gpgSign=false -c core.hooksPath=/dev/null`. +- **Footer and commit** — an export's footer is `Revision N · <date> · report sha256 <12>`: N is the revision the + export belongs to (the next one when report.json changed), computed before the files are written. The footer + never names the commit: the 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; `export.json` records the + revision, commit, date and summary once committed. +- **Publish** — compose reads every revision into `reports/<id>/history/history.json` (`ReportHistoryView`, format + `archilyzer-report-history`: per revision N, date, commit, report sha256, summary, and `claimChanges` — each + changed claim's title/text/verdict/findings as a word diff of `eq`/`ins`/`del` runs) and stages a dumb-HTTP + clone at `reports/<id>/history/repo/` as the source mirror does: a fresh `clone --bare --no-local + --single-branch`, `repack -a -d --max-pack-size=20m`, `pack-refs --all`, `update-server-info`, then an + allowlisted copy (`HEAD`, `packed-refs`, `info/refs`, `objects/info/packs`, `objects/pack/pack-*.{pack,idx}`, + `refs/heads/main`). The page view carries `history: { revision, date, href, current }` (`current` false when + report.json changed after the newest revision and was not exported again — the line then reads "Edited since + revision N"). The sitemap lists the history page. +- **Export site** — `/reports/<id>/history/` (static params as the report page's): `git clone <siteUrl>/reports/<id>/history/repo` + (the site-root path when `siteUrl` is unset), then each revision newest first with its hashes, summary and inline + `<ins>`/`<del>` diff. The report header shows "Revision N · <date> · history" under the dates. +- **Audit** — `reportHistoryProblem` (`lib/builtExport.ts`, asked by `builtSiteProblem` and `builtBundleProblem` + for every build) refuses any file in a `reports/<id>/history/repo/` outside the allowlist or over + `PUBLISH_MAX_FILE_BYTES`; the cited audit counts the clone's files toward Pages' 20,000. +- **Editor** — the Reports tab's Exports list shows each report's revision, commit and last change (from + `export.json`). +- **Tests** — `lib/report/revisions.test.ts` (summary, word diff, claim changes), `publish/reportHistory.test.ts` + (commit only on change; site identity and UTC dates with an operator's GIT_*/EMAIL/TZ set; no operator string in + any object; the staged clone served by `export/scripts/serve-out.mjs` and cloned over HTTP), compose and audit + tests; the report-site e2e fixture has two revisions (`source/demo-factcheck/report.v1.json`, then + `report.json`) and checks the page line, the history page's hashes, summary and diff, and a `git clone` over the + e2e server. + ## Compose and the contract - `compose-site` gains a reports stage: resolve citations against the shared transcript trees, verify quotes,