// 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//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, ReportEntry } 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 /reports//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//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 { const out = new Map(); 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; timeline entries added, removed and edited; // 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) { const entries = next.entries?.length ?? 0; return [ `First revision: ${plural(next.sections.length, "section")}, ${plural(countClaims(next), "claim")}, ` + `${plural(Object.keys(next.citations ?? {}).length, "citation")}` + `${entries > 0 ? `, ${plural(entries, "timeline entry", "timeline entries")}` : ""}.`, ]; } 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 eBefore = new Map((prev.entries ?? []).map((e) => [e.id, e])); const eAfter = new Map((next.entries ?? []).map((e) => [e.id, e])); const entryLabel = (e: ReportEntry) => `${e.id} “${short(e.title)}”`; const eAdded = [...eAfter.values()].filter((e) => !eBefore.has(e.id)).map(entryLabel); const eRemoved = [...eBefore.values()].filter((e) => !eAfter.has(e.id)).map(entryLabel); const eEdited: string[] = []; for (const [id, e] of eAfter) { const old = eBefore.get(id); if (!old) continue; const fields = (["date", "title", "body"] as const).filter((f) => old[f] !== e[f]); if (fields.length) eEdited.push(`${id} (${fields.join(", ")})`); } if (eAdded.length) out.push(`${eAdded.length === 1 ? "Entry" : `${eAdded.length} entries`} added: ${list(eAdded)}`); if (eRemoved.length) out.push(`${eRemoved.length === 1 ? "Entry" : `${eRemoved.length} entries`} removed: ${list(eRemoved)}`); if (eEdited.length) out.push(`${eEdited.length === 1 ? "Entry" : "Entries"} edited: ${list(eEdited)}`); 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(/^- /, "")); }