Archilyzer · Source

archilyzer

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

commit 6fcadfffea368687ee00a9ed64ed3eb6e01d8ad5
parent 6eafa357249f14f0a739c83fe1dc89b790fb4a50
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 09:07:42 -0400

common: the source's history pages — `source publish` renders the scrubbed mirror with stagit into /source/git/ (log, a page per commit with its diff, refs, files, two Atom feeds) after the object audit and before the file audit; an allowlist of stagit's output, never its per-file pages (every file/ link now points into the raw tree); a post-pass adds the homepage's pre-paint theme script and one line back to /source/ to every page, never the feeds; style.css from tokens.css; the manifest's history block, stagit and the pages' digest in the skip key, the deploy check names history pages that are not the audited ones; a render cache under the scratch root (stagit -c), keyed, ancestry-checked, one holder at a time; without stagit, one line and no history, never a failed build; STAGIT_BIN; step version 4

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
MENVIRONMENT.md | 3++-
Mcommon/lib/envVars.ts | 3++-
Mcommon/lib/paths.ts | 6++++++
Mcommon/lib/sourceManifest.test.ts | 34++++++++++++++++++++++++++++++++++
Mcommon/lib/sourceManifest.ts | 41+++++++++++++++++++++++++++++++++++++++++
Mcommon/publish/source.test.ts | 241++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/publish/source.ts | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Acommon/publish/sourceHistory.test.ts | 467+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/sourceHistory.ts | 573+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1618 insertions(+), 31 deletions(-)

diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md @@ -42,7 +42,8 @@ The one override surface for where things live and which binary runs. Every one | `ARCHILYZER_CONFIG_DIR` | `~/.config/archilyzer` | The operator's private config dir, outside the repo: the two inputs of `archilyzer source publish` below. Never committed. | common/lib/paths.ts (getPaths) | | `SOURCE_SCRUB_FILE` | `<ARCHILYZER_CONFIG_DIR>/source-scrub.txt` | git-filter-repo `lhs==>rhs` rules applied to file contents AND commit messages when the source mirror is generated (`<home dir>==>/home/user` is built in and runs first). Every rule's left side is also denied. See [PUBLISH.md](PUBLISH.md). | common/lib/paths.ts (getPaths) | | `SOURCE_DENYLIST_FILE` | `<ARCHILYZER_CONFIG_DIR>/source-denylist.txt` | Literals the published source must never contain, one per line (`i:` = any case). One hit anywhere in the mirror, the tree or the tarball refuses the publish. | common/lib/paths.ts (getPaths) | -| `ARCHILYZER_SOURCE_SCRATCH` | the OS temp dir | Where `source publish` makes its scratch clone and stage (removed afterwards unless `--keep-scratch`). | common/lib/paths.ts (getPaths) | +| `ARCHILYZER_SOURCE_SCRATCH` | the OS temp dir | Where `source publish` makes its scratch clone and stage (removed afterwards unless `--keep-scratch`), and keeps the history pages' render cache (`archilyzer-source-history/`). | common/lib/paths.ts (getPaths) | +| `STAGIT_BIN` | `stagit` on PATH, then `~/.local/bin/stagit` | stagit, which renders the source's history pages (`/source/git/`: the log and a page per commit with its diff). Optional: without it the source is published without them. See [PUBLISH.md](PUBLISH.md). | common/lib/paths.ts (getPaths) | ## Runtime diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts @@ -83,7 +83,8 @@ const DECLARED: EnvVarDecl[] = [ paths("ARCHILYZER_CONFIG_DIR", "`~/.config/archilyzer`", "The operator's private config dir, outside the repo: the two inputs of `archilyzer source publish` below. Never committed."), paths("SOURCE_SCRUB_FILE", "`<ARCHILYZER_CONFIG_DIR>/source-scrub.txt`", "git-filter-repo `lhs==>rhs` rules applied to file contents AND commit messages when the source mirror is generated (`<home dir>==>/home/user` is built in and runs first). Every rule's left side is also denied. See [PUBLISH.md](PUBLISH.md)."), paths("SOURCE_DENYLIST_FILE", "`<ARCHILYZER_CONFIG_DIR>/source-denylist.txt`", "Literals the published source must never contain, one per line (`i:` = any case). One hit anywhere in the mirror, the tree or the tarball refuses the publish."), - paths("ARCHILYZER_SOURCE_SCRATCH", "the OS temp dir", "Where `source publish` makes its scratch clone and stage (removed afterwards unless `--keep-scratch`)."), + paths("ARCHILYZER_SOURCE_SCRATCH", "the OS temp dir", "Where `source publish` makes its scratch clone and stage (removed afterwards unless `--keep-scratch`), and keeps the history pages' render cache (`archilyzer-source-history/`)."), + paths("STAGIT_BIN", "`stagit` on PATH, then `~/.local/bin/stagit`", "stagit, which renders the source's history pages (`/source/git/`: the log and a page per commit with its diff). Optional: without it the source is published without them. See [PUBLISH.md](PUBLISH.md)."), // ── runtime ──────────────────────────────────────────────────────────── { name: "WORKER_TOKEN", audience: "runtime", default: "unset (both surfaces off)", readBy: "common/lib/workerToken.ts, scripts/archilyzer-ops.mjs, mcp/src/fetchClip.ts", doc: "Bearer token for the remote-worker API and for `/api/ops/*` (`pnpm ops`, the MCP's `fetch_clip`). Set the same value on both ends." }, diff --git a/common/lib/paths.ts b/common/lib/paths.ts @@ -168,6 +168,11 @@ export type Paths = { sourceDenylistFile: string; // Where `source publish` makes its scratch clone (removed afterwards). sourceScratchDir: string; + // stagit, which renders the source's history pages (/source/git/). An + // operator-installed tool, never vendored: `stagit` on PATH, then + // ~/.local/bin/stagit (publish/sourceHistory.ts resolveStagit). Without it + // the publish goes on without the history pages. + stagitBin: string; }; let cached: Paths | null = null; @@ -296,6 +301,7 @@ export function getPaths(): Paths { process.env.SOURCE_DENYLIST_FILE ?? path.join(configDir, "source-denylist.txt"), sourceScratchDir: process.env.ARCHILYZER_SOURCE_SCRATCH ?? os.tmpdir(), + stagitBin: process.env.STAGIT_BIN ?? "stagit", }; return cached; } diff --git a/common/lib/sourceManifest.test.ts b/common/lib/sourceManifest.test.ts @@ -48,3 +48,37 @@ test("a wrong version, a bad id or sha, or any number the page reads that is not for (const [what, make] of broken) assert.equal(parseSourceManifest(make(good())), null, what); for (const junk of [null, 1, "x", [], {}]) assert.equal(parseSourceManifest(junk), null, JSON.stringify(junk)); }); + +// Release 15 slice SG: the history block is optional — absent is a manifest +// without History links — and all-or-nothing when present. +const history = () => ({ + href: "/source/git/log.html", + commits: 3, + head: "2".repeat(40), + files: 9, + bytes: 1234, + sha256: "4".repeat(64), + tool: "stagit (sha256 0123456789ab)", +}); + +test("a manifest with a history block parses; without one it is the same manifest", () => { + const withHistory = { ...good(), history: history() }; + assert.deepEqual(parseSourceManifest(withHistory), withHistory); + assert.equal(parseSourceManifest(good())?.history, undefined); +}); + +test("a history block that is present but malformed makes the manifest untrusted", () => { + const broken: Array<[string, (h: ReturnType<typeof history>) => unknown]> = [ + ["null", () => null], + ["commits a string", (h) => ({ ...h, commits: "3" })], + ["files negative", (h) => ({ ...h, files: -1 })], + ["bytes missing", (h) => ({ ...h, bytes: undefined })], + ["short head", (h) => ({ ...h, head: "abc" })], + ["sha256 not hex", (h) => ({ ...h, sha256: "z".repeat(64) })], + ["no href", (h) => ({ ...h, href: undefined })], + ["tool a number", (h) => ({ ...h, tool: 1 })], + ]; + for (const [what, make] of broken) { + assert.equal(parseSourceManifest({ ...good(), history: make(history()) }), null, what); + } +}); diff --git a/common/lib/sourceManifest.ts b/common/lib/sourceManifest.ts @@ -25,6 +25,16 @@ export const CLONE_URL = `${PROJECT_URL}/source/${MIRROR_DIR}`; /** The raw tree's generated index. */ export const TREE_HREF = "/source/tree/"; +/** + * The history pages' directory under /source/: stagit's rendering of the + * mirror (publish/sourceHistory.ts) — the log, a page per commit with its + * diff, the refs, the files index (into the raw tree) and two Atom feeds. + */ +export const HISTORY_DIR = "git"; +export const HISTORY_LOG_HREF = `/source/${HISTORY_DIR}/log.html`; +export const HISTORY_REFS_HREF = `/source/${HISTORY_DIR}/refs.html`; +export const HISTORY_ATOM_HREF = `/source/${HISTORY_DIR}/atom.xml`; + /** The manifest's public path. */ export const SOURCE_MANIFEST_HREF = "/source/manifest.json"; @@ -69,6 +79,27 @@ export type SourceManifest = { }; // The tools that made it (a filter-repo upgrade may change mirrorHead). tools: { git: string; filterRepo: string }; + // The history pages, when stagit rendered them (absent: no stagit on the + // publishing machine, or a render that failed — the page then shows no + // History links). + history?: SourceHistory; +}; + +export type SourceHistory = { + // The log page (HISTORY_LOG_HREF). + href: string; + // Every commit of the mirror's main has a page; `head` is the one the log + // starts at (the manifest's mirrorHead). + commits: number; + head: string; + // Every file under /source/git/ (the pages, the two feeds, style.css): how + // many, their bytes, and one sha256 over them — each file's path, size and + // sha256, in sorted path order — which the deploy check recomputes over out/. + files: number; + bytes: number; + sha256: string; + // The renderer: "stagit (sha256 <first 12 of its binary's>)". + tool: string; }; const HEX40 = /^[0-9a-f]{40}$/; @@ -101,5 +132,15 @@ export function parseSourceManifest(value: unknown): SourceManifest | null { } if (!obj(m.tree) || !num(m.tree.files) || !num(m.tree.dirs) || !num(m.tree.bytes)) return null; if (!obj(m.audit)) return null; + // Optional, and all-or-nothing: a history block that is present but + // malformed makes the whole manifest untrusted, like any other bad field. + if (m.history !== undefined) { + const h = m.history as Partial<SourceHistory> | null; + if (!obj(h)) return null; + if (!num(h.commits) || !num(h.files) || !num(h.bytes)) return null; + if (typeof h.head !== "string" || !HEX40.test(h.head)) return null; + if (typeof h.sha256 !== "string" || !HEX64.test(h.sha256)) return null; + if (typeof h.href !== "string" || typeof h.tool !== "string") return null; + } return m as SourceManifest; } diff --git a/common/publish/source.test.ts b/common/publish/source.test.ts @@ -18,6 +18,7 @@ import { } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { Paths } from "../lib/paths"; import { CLONE_URL, MIRROR_DIR, TARBALL_HREF, TREE_HREF } from "../lib/sourceManifest"; import { @@ -33,12 +34,14 @@ import { publishSource, publishedSourceProblem, gitleaksIdentity, + historyDigest, resolveFilterRepo, rulesHashOf, scratchRootProblem, sourceDigest, type SourcePublishOpts, } from "./source"; +import { HISTORY_BACK_LINK, historyCacheDir } from "./sourceHistory"; // Run with: // pnpm --filter yt-dlp-transcript-common test @@ -125,6 +128,8 @@ function opts(repo: string, files: { scrubFile: string; denylistFile: string }, publicDir: path.join(dir("site"), "public"), scratchRoot: path.join(TMP, "scratch"), gitleaks: null, + // No history pages unless a test gives a stagit (the machine may have one). + stagit: null, homeDir: HOME, onLog: (l) => logs.push(l), now: () => new Date("2026-09-28T12:00:00.000Z"), @@ -249,6 +254,8 @@ test("skip: an unchanged main with unchanged rules, tools and files does nothing filterRepo: "false (given)", gitleaks: "skipped", contentDigest: await sourceDigest(pub), + stagit: "absent", + history: null, }; writeFileSync(state, JSON.stringify({ ...key, ...tweak })); }; @@ -268,6 +275,7 @@ test("skip: an unchanged main with unchanged rules, tools and files does nothing ["another gitleaks", { gitleaks: "gitleaks 8.0 (abc)" }], ["other files", { contentDigest: "0".repeat(64) }], ["other rules", { rulesHash: "0".repeat(64) }], + ["another stagit", { stagit: "stagit (sha256 0123456789ab)" }], ] as Array<[string, Record<string, string>]>) { await plant(tweak); assert.equal(await run(), 1, `${what} must not skip`); @@ -299,7 +307,7 @@ test("the rules hash moves with the step's version (review R2-L3), and loadSourc const files = operatorFiles("a==>b\n", ""); const rules = await loadSourceRules({ ...files, homeDir: "/" }); assert.equal(rules.rulesHash, rulesHashOf(["a==>b"], rules.literals, SOURCE_STEP_VERSION)); - assert.ok(SOURCE_STEP_VERSION >= 3); + assert.ok(SOURCE_STEP_VERSION >= 4, "release 15 slice SG: the history pages"); }); test("gitleaksIdentity: skipped, absent, or the version line with the binary's sha256 (review R2-L4)", async () => { @@ -551,7 +559,7 @@ test("a denied literal no rule removes: refused, nothing written, the report nev assert.ok(kept, report); // The log tildifies with the real home dir, so a TMPDIR under it prints "~/…". const keptDir = kept[1].replace(/^~(?=\/|$)/, os.homedir()); - assert.ok(existsSync(path.join(keptDir, "bare"))); + assert.ok(existsSync(path.join(keptDir, MIRROR_DIR)), "the clone is named as the mirror is (stagit names it after its directory)"); assert.ok(!existsSync(path.join(keptDir, "replace.txt"))); rmSync(keptDir, { recursive: true, force: true }); }); @@ -603,3 +611,232 @@ test("--no-source's clear: the manifest, mirror, tree, tarball and skip key go; assert.equal(readFileSync(path.join(elsewhere, "snapshot.json"), "utf8"), "the other checkout's"); assert.match(logs.join("\n"), /previously published source .* was removed/); }); + +// ── the history pages (release 15 slice SG) ───────────────────────────────── +// +// Over a fake stagit that writes what stagit writes (sourceHistory.test.ts +// runs the real one), and with filter-repo replaced by `true` — no rewrite, +// so these run on any machine; the history does not care what the rewrite did. + +const TOKENS_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "styles", "tokens.css"); + +// A page per commit (kept if there is one, as stagit keeps it), the top-level +// pages and feeds, a per-file page, and the cache file under -c. `extra` goes +// into every commit page; `pad` bytes are added to the head's page. +function fakeStagit(log: string, o: { exit?: number; extra?: string; pad?: number } = {}): string { + const file = path.join(dir("fake-stagit"), "stagit"); + writeFileSync( + file, + `#!/bin/sh +cache=""; base=""; repo="" +while [ $# -gt 0 ]; do + case "$1" in + -c) cache="$2"; shift 2;; + -u) base="$2"; shift 2;; + *) repo="$1"; shift;; + esac +done +echo "cache=$cache base=$base repo=$(basename "$repo")" >> '${log}' +${o.exit ? `echo 'stagit: something broke' >&2; exit ${o.exit}` : ""} +mkdir -p commit file +for c in $(git --git-dir "$repo" rev-list HEAD); do + [ -f "commit/$c.html" ] || printf '<html>\\n<head>\\n</head>\\n<body>\\n<a href="../file/README.md.html">README</a> ${o.extra ?? ""}\\n</body>\\n</html>\\n' > "commit/$c.html" +done +${o.pad ? `head -c ${o.pad} /dev/zero >> "commit/$(git --git-dir "$repo" rev-parse HEAD).html"` : ""} +printf '<html>\\n<head>\\n</head>\\n<body>\\n<span class="desc">%s</span> %s <a href="file/README.md.html">README</a>\\n</body>\\n</html>\\n' "$(cat "$repo/description")" "$(cat "$repo/url")" > log.html +for f in files refs; do printf '<html>\\n<head>\\n</head>\\n<body>\\n</body>\\n</html>\\n' > $f.html; done +printf '<feed>%s</feed>\\n' "$base" > atom.xml +printf '<feed/>\\n' > tags.xml +printf 'x\\n' > file/README.md.html +if [ -n "$cache" ]; then git --git-dir "$repo" rev-parse HEAD > "$cache"; fi +exit 0 +`, + ); + chmodSync(file, 0o755); + return file; +} + +const stagitCalls = (log: string) => (existsSync(log) ? readFileSync(log, "utf8").trim().split("\n") : []); + +test("history: published with the source — the allowlist at /source/git/, the manifest's block, the skip key, incremental, and the deploy check names it", async () => { + const repo = sourceRepo(); + const files = operatorFiles("", ""); + const logs: string[] = []; + const log = path.join(dir("stagit-log"), "calls"); + const scratchRoot = dir("scratch-root"); + const o = opts(repo, files, logs, { filterRepo: ["true"], stagit: fakeStagit(log), tokensFile: TOKENS_FILE, scratchRoot }); + const pub = o.publicDir!; + const site = path.dirname(pub); + + assert.equal(await publishSource(o), 0, logs.join("\n")); + let text = logs.join("\n"); + assert.match(text, /\[source\] history: stagit \(sha256 [0-9a-f]{12}\) — 3 commits, 3 pages rendered; 9 files, [\d.]+ MB, the largest git\/\S+ [\d.]+ MB/); + assert.match(text, /\[source\] published main .* tree \d+ dirs, history 3 commits in 9 files\)/); + assert.deepEqual(stagitCalls(log), [ + `cache=${path.join(historyCacheDir(scratchRoot), "stagit.cache")} base=https://archilyzer.pages.dev/source/git/ repo=${MIRROR_DIR}`, + ]); + + const manifest = JSON.parse(readFileSync(path.join(pub, "source", "manifest.json"), "utf8")); + assert.deepEqual(Object.keys(manifest.history).sort(), ["bytes", "commits", "files", "head", "href", "sha256", "tool"]); + assert.equal(manifest.history.href, "/source/git/log.html"); + assert.equal(manifest.history.commits, 3); + assert.equal(manifest.history.head, manifest.mirrorHead); + assert.equal(manifest.history.files, 9); + assert.equal(manifest.history.sha256, await historyDigest(pub)); + assert.match(manifest.history.tool, /^stagit \(sha256 [0-9a-f]{12}\)$/); + assert.ok(manifest.files >= 9, "the manifest's count includes the history"); + + const git = path.join(pub, "source", "git"); + assert.deepEqual(readdirSync(git).sort(), ["atom.xml", "commit", "files.html", "log.html", "refs.html", "style.css", "tags.xml"]); + const logHtml = readFileSync(path.join(git, "log.html"), "utf8"); + assert.ok(logHtml.includes(`<span class="desc">Archilyzer</span> ${CLONE_URL} `), "stagit's header: the name and the clone URL"); + assert.ok(logHtml.includes(HISTORY_BACK_LINK)); + assert.match(logHtml, /<script>\(function\(\)\{try\{var d=document\.documentElement/); + assert.match(logHtml, /href="\.\.\/tree\/README\.md"/); + assert.equal(readFileSync(path.join(git, "atom.xml"), "utf8"), "<feed>https://archilyzer.pages.dev/source/git/</feed>\n"); + assert.match(readFileSync(path.join(git, "style.css"), "utf8"), /html\[data-base="dark"\] \{/); + // The description and url files stagit read are never published. + for (const f of ["description", "url"]) assert.ok(!existsSync(path.join(pub, "source", MIRROR_DIR, f)), f); + + const stateFile = path.join(site, ".source-publish.json"); + const state = JSON.parse(readFileSync(stateFile, "utf8")); + assert.equal(state.stagit, manifest.history.tool); + assert.deepEqual(state.history, { files: 9, digest: manifest.history.sha256 }); + + // Unchanged: skipped, stagit not run. + logs.length = 0; + assert.equal(await publishSource(o), 0); + assert.match(logs.join("\n"), /up to date at/); + assert.equal(stagitCalls(log).length, 1); + + // The deploy check: this publish in out/ deploys; a history page edited, + // or the history missing, is named. + const out = path.join(dir("out"), "out"); + mkdirSync(path.join(out, "source"), { recursive: true }); + writeFileSync(path.join(out, "source", "index.html"), "<p>the page</p>"); + cpSync(path.join(pub, "source"), path.join(out, "source"), { recursive: true }); + cpSync(path.join(pub, "downloads"), path.join(out, "downloads"), { recursive: true }); + const check = { ...files, homeDir: HOME, publicDir: pub, sourceRepo: path.join(repo, ".git"), gitleaks: null, stagit: null }; + assert.equal(await publishedSourceProblem(o.paths!, out, check), null); + const outLog = path.join(out, "source", "git", "log.html"); + const was = readFileSync(outLog); + writeFileSync(outLog, Buffer.concat([was, Buffer.from(" ")])); + assert.match((await publishedSourceProblem(o.paths!, out, check))!, /history pages \(\/source\/git\/\) are not the ones that were audited — run `archilyzer build homepage`/); + rmSync(path.join(out, "source", "git"), { recursive: true }); + assert.match((await publishedSourceProblem(o.paths!, out, check))!, /history pages \(\/source\/git\/\) are not the ones that were audited/); + cpSync(path.join(pub, "source", "git"), path.join(out, "source", "git"), { recursive: true }); + assert.equal(await publishedSourceProblem(o.paths!, out, check), null, "restored, it deploys"); + + // A new commit on main: only its page is rendered, the rest from the cache. + writeFileSync(path.join(repo, "later.txt"), "x\n"); + gitIn(repo, "add", "-A"); + gitIn(repo, "commit", "-q", "-m", "later"); + logs.length = 0; + assert.equal(await publishSource(o), 0, logs.join("\n")); + assert.match(logs.join("\n"), /— 4 commits, 1 page rendered \(the rest from the cache\); 10 files/); + // --force renders every page again. + logs.length = 0; + assert.equal(await publishSource({ ...o, force: true }), 0, logs.join("\n")); + assert.match(logs.join("\n"), /— 4 commits, 4 pages rendered; 10 files/); + // --check writes nothing — the cache included — and still renders (and + // sweeps) the pages in its own scratch. + const cacheBefore = readFileSync(path.join(historyCacheDir(scratchRoot), "key.json"), "utf8"); + logs.length = 0; + assert.equal(await publishSource({ ...o, check: true }), 0, logs.join("\n")); + assert.match(logs.join("\n"), /— 4 commits, 4 pages rendered;/); + assert.equal(stagitCalls(log).at(-1), `cache= base=https://archilyzer.pages.dev/source/git/ repo=${MIRROR_DIR}`); + assert.equal(readFileSync(path.join(historyCacheDir(scratchRoot), "key.json"), "utf8"), cacheBefore); +}); + +test("history: without stagit — one line with the install, nothing at /source/git/, no block in the manifest; installing it re-publishes", async () => { + const repo = sourceRepo(); + const files = operatorFiles("", ""); + const logs: string[] = []; + const o = opts(repo, files, logs, { filterRepo: ["true"], stagit: null, tokensFile: TOKENS_FILE, scratchRoot: dir("scratch-root") }); + const pub = o.publicDir!; + assert.equal(await publishSource(o), 0, logs.join("\n")); + const said = logs.filter((l) => /stagit/.test(l)); + assert.deepEqual(said, [ + "[source] stagit not found (not on PATH, not in ~/.local/bin) — publishing without the history pages (/source/git/); install it once: git clone git://git.codemadness.org/stagit && make -C stagit && cp stagit/stagit ~/.local/bin/", + ]); + assert.match(logs.join("\n"), /\[source\] published main .*, no history\)/); + const manifest = JSON.parse(readFileSync(path.join(pub, "source", "manifest.json"), "utf8")); + assert.ok(!("history" in manifest)); + assert.ok(!existsSync(path.join(pub, "source", "git"))); + const state = JSON.parse(readFileSync(path.join(path.dirname(pub), ".source-publish.json"), "utf8")); + assert.equal(state.stagit, "absent"); + assert.equal(state.history, null); + + logs.length = 0; + assert.equal(await publishSource(o), 0); + assert.match(logs.join("\n"), /up to date at/, "no stagit, nothing changed: skipped"); + + // stagit installed since: not skipped, and the history is published. + logs.length = 0; + const log = path.join(dir("stagit-log"), "calls"); + assert.equal(await publishSource({ ...o, stagit: fakeStagit(log) }), 0, logs.join("\n")); + assert.ok(!/up to date/.test(logs.join("\n"))); + assert.equal(JSON.parse(readFileSync(path.join(pub, "source", "manifest.json"), "utf8")).history.commits, 3); + assert.ok(existsSync(path.join(pub, "source", "git", "log.html"))); +}); + +test("history: a denied literal in a history page refuses, names the page and never the literal, and withdraws the publish and the render cache", async () => { + const repo = sourceRepo(); + const planted = "plantedinhistory"; + const files = operatorFiles("", ""); + const logs: string[] = []; + const log = path.join(dir("stagit-log"), "calls"); + const scratchRoot = dir("scratch-root"); + // The literal is only ever in what stagit writes, never in the repository: + // what this proves is that the file gate reads source/git/**. + const o = opts(repo, files, logs, { filterRepo: ["true"], stagit: fakeStagit(log, { extra: `said ${planted} here` }), tokensFile: TOKENS_FILE, scratchRoot }); + assert.equal(await publishSource(o), 0, logs.join("\n")); + assert.ok(existsSync(path.join(o.publicDir!, "source", "git", "log.html"))); + assert.ok(existsSync(historyCacheDir(scratchRoot))); + + // Deny it: the rules change, so every page is rendered again — and swept. + writeFileSync(files.denylistFile, `${planted}\n`); + logs.length = 0; + assert.equal(await publishSource(o), 1); + const report = logs.join("\n"); + assert.match(report, /AUDIT REFUSED: 3 hits in \d+ objects \(3 commits\), \d+ staged files/); + assert.match(report, /denylist line 1 \(len 16\): 3 in files/); + assert.match(report, /\[source\] {3}file source\/git\/commit\/[0-9a-f]{40}\.html \(contents, byte \d+\): denylist line 1 \(len 16\)/); + assert.ok(!report.includes(planted), report); + assert.ok(!report.includes("said"), "no byte from beside the hit"); + assert.match(report, /the previous publish was WITHDRAWN \(mirror, tree, history, tarball\)/); + assert.ok(!existsSync(path.join(o.publicDir!, "source")), "public/source, the history with it, is withdrawn"); + assert.ok(!existsSync(historyCacheDir(scratchRoot)), "the render cache is removed too"); +}); + +test("history: a stagit that fails, or a page over the host's limit, is a WARNING; the rest is published, and the next build tries again", async () => { + const repo = sourceRepo(); + const files = operatorFiles("", ""); + const logs: string[] = []; + const log = path.join(dir("stagit-log"), "calls"); + const o = opts(repo, files, logs, { filterRepo: ["true"], stagit: fakeStagit(log, { exit: 3 }), tokensFile: TOKENS_FILE, scratchRoot: dir("scratch-root") }); + const pub = o.publicDir!; + assert.equal(await publishSource(o), 0, logs.join("\n")); + assert.match(logs.join("\n"), /\[source\] WARNING: the history pages were not rendered: stagit exited 3: stagit: something broke — publishing without the history pages \(\/source\/git\/\)/); + assert.ok(existsSync(path.join(pub, "source", MIRROR_DIR, "info", "refs")), "the mirror is published"); + assert.ok(!existsSync(path.join(pub, "source", "git"))); + const state = JSON.parse(readFileSync(path.join(path.dirname(pub), ".source-publish.json"), "utf8")); + assert.match(state.stagit, /^stagit \(sha256/); + assert.equal(state.history, null); + logs.length = 0; + assert.equal(await publishSource(o), 0); + assert.ok(!/up to date/.test(logs.join("\n")), "a stagit present and no history: the next build tries again"); + assert.equal(stagitCalls(log).length, 2); + + // One page over the step's 24 MiB: the history is dropped, not the publish. + logs.length = 0; + assert.equal(await publishSource({ ...o, stagit: fakeStagit(log, { pad: MAX_FILE_BYTES }) }), 0, logs.join("\n")); + assert.match(logs.join("\n"), /\[source\] WARNING: git\/commit\/[0-9a-f]{40}\.html is 24\.0 MiB, over the step's limit of 24\.0 MiB \(Pages allows 25 MiB per file\) — publishing without the history pages/); + assert.ok(!existsSync(path.join(pub, "source", "git"))); + assert.ok(!("history" in JSON.parse(readFileSync(path.join(pub, "source", "manifest.json"), "utf8")))); + + // Unreadable design tokens: the same. + logs.length = 0; + assert.equal(await publishSource({ ...o, stagit: fakeStagit(log), tokensFile: path.join(dir("none"), "tokens.css") }), 0); + assert.match(logs.join("\n"), /\[source\] WARNING: the design tokens \(.*tokens\.css\) could not be read — publishing without the history pages/); +}); diff --git a/common/publish/source.ts b/common/publish/source.ts @@ -3,12 +3,15 @@ // The private repository is never rewritten. Every publish makes a FRESH bare // clone of its `main`, rewrites that copy with git-filter-repo (the operator's // scrub rules over file contents AND commit messages), repacks it for git's -// dumb-HTTP protocol, and publishes three things under homepage/public: +// dumb-HTTP protocol, and publishes these under homepage/public: // // source/archilyzer.git/ a clonable mirror: HEAD, refs, info/refs, // objects/info/packs, the packs — static files only // source/tree/ the tracked files of main, raw, with an // index.html per directory (sourceTree.ts) +// source/git/ the history: the log, a page per commit with its +// diff, the refs, two Atom feeds — stagit's, when +// it is installed (sourceHistory.ts) // downloads/ archilyzer-source.tar.gz + snapshot.json, the // tarball the Downloads page has always offered // source/manifest.json written LAST: what was published, from what @@ -38,16 +41,33 @@ import path from "node:path"; import { runChildIntoLog } from "../jobs/runChild"; import { copyPublicFile, ownDir, writePublicFile } from "../bin/_publicFile"; import { getPaths, type Paths } from "../lib/paths"; +import { PROJECT_NAME, PROJECT_URL } from "../lib/project"; import { CLONE_URL, + HISTORY_DIR, + HISTORY_LOG_HREF, MIRROR_DIR, SOURCE_MANIFEST_VERSION, TARBALL_HREF, TREE_HREF, parseSourceManifest, + type SourceHistory, type SourceManifest, } from "../lib/sourceManifest"; import { + HistoryProblem, + STAGIT_INSTALL, + dropHistoryCache, + historyCacheDir, + historyCacheKey, + historyStylesheet, + homepageThemeScript, + renderHistory, + resolveStagit, + stagitIdentity, + type HistoryRun, +} from "./sourceHistory"; +import { SourceRefusal, auditBare, auditFiles, @@ -82,8 +102,11 @@ export const SOURCE_BRANCH = "main"; * left sides refused, `404.html` refused, no context bytes. * 3 — the re-review: masked refusal messages, a digest of every published * file and the gitleaks identity in the key. + * 4 — release 15 slice SG: the history pages (source/git/, stagit) are + * staged, swept by the file gate and bound by the digest; stagit's + * identity is in the key. */ -export const SOURCE_STEP_VERSION = 3; +export const SOURCE_STEP_VERSION = 4; /** What `pipx run` fetches when `git filter-repo` is not installed. */ export const FILTER_REPO_PIPX_SPEC = "git-filter-repo==2.47.0"; @@ -120,6 +143,12 @@ export type SourcePublishOpts = PublishOpts & { filterRepo?: string[] | null; // The gitleaks binary; null skips the secret scan (tests). Default "gitleaks". gitleaks?: string | null; + // The stagit binary; null publishes without the history pages, as a + // machine without stagit does. Default: resolveStagit(paths.stagitBin). + stagit?: string | null; + // The design tokens the history pages' style.css is written from. Default: + // <repo>/common/styles/tokens.css. + tokensFile?: string; // Where the scratch dir is made. Default: paths.sourceScratchDir. scratchRoot?: string; // The environment the children run in (PATH decides which tools). Default: @@ -433,18 +462,46 @@ type PublishState = { // sourceDigest() over every published file: an out/ (or public/) holding // another publish's mirror or tree beside this manifest does not match. contentDigest: string; + // stagitIdentity(): "absent", or which stagit rendered the history. An + // install (or an upgrade) re-publishes. + stagit: string; + // The history pages as audited (historyDigest), or null when none were + // published. A null beside a stagit that is present (a render that failed, + // pages over the limits) never skips: the next build tries again. + history: { files: number; digest: string } | null; }; /** * One digest over every file a publish puts on the site, under `root` laid * out as public/ and out/ both are: `source/archilyzer.git/**`, - * `source/tree/**`, `source/manifest.json`, `downloads/archilyzer-source.tar.gz` - * and `downloads/snapshot.json`. Each file contributes its relative path, size - * and sha256 (streamed), in sorted path order; a symlink or a missing piece - * contributes a marker, so it can only differ. The /source PAGE and anything - * else Next writes beside them are not part of it. + * `source/tree/**`, `source/git/**`, `source/manifest.json`, + * `downloads/archilyzer-source.tar.gz` and `downloads/snapshot.json`. Each + * file contributes its relative path, size and sha256 (streamed), in sorted + * path order; a symlink or a missing piece contributes a marker, so it can + * only differ. The /source PAGE and anything else Next writes beside them are + * not part of it. */ export async function sourceDigest(root: string): Promise<string> { + return digestOf(root, [ + `source/${MIRROR_DIR}`, + "source/tree", + `source/${HISTORY_DIR}`, + "source/manifest.json", + `downloads/${TARBALL_NAME}`, + "downloads/snapshot.json", + ]); +} + +/** + * The same digest over the history pages alone (`source/git/**`): the + * manifest's `history.sha256`, and what the deploy check recomputes over + * out/ to name a history that is not the audited one. + */ +export async function historyDigest(root: string): Promise<string> { + return digestOf(root, [`source/${HISTORY_DIR}`]); +} + +async function digestOf(root: string, tops: readonly string[]): Promise<string> { const entries: string[] = []; const walk = async (rel: string): Promise<void> => { const abs = path.join(root, rel); @@ -459,15 +516,7 @@ export async function sourceDigest(root: string): Promise<string> { entries.push(`${rel}\0${st.size}\0${await sha256File(abs)}`); } }; - for (const top of [ - `source/${MIRROR_DIR}`, - "source/tree", - "source/manifest.json", - `downloads/${TARBALL_NAME}`, - "downloads/snapshot.json", - ]) { - await walk(top); - } + for (const top of tops) await walk(top); entries.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); const h = createHash("sha256"); for (const e of entries) h.update(`${e}\n`); @@ -512,10 +561,10 @@ export const NO_REPOSITORY = /** * Remove a publish from `publicDir`: the manifest FIRST (the page never - * describes a half-removed tree), the skip key, the mirror and the tree, the - * tarball and snapshot.json. Link-safe like the install: a linked - * `source/` or `downloads/` (a worktree's, into the primary) goes as a link, - * its target untouched. True when there was something to remove. + * describes a half-removed tree), the skip key, the mirror, the tree and the + * history pages, the tarball and snapshot.json. Link-safe like the install: a + * linked `source/` or `downloads/` (a worktree's, into the primary) goes as a + * link, its target untouched. True when there was something to remove. */ export async function removePublishedSource(publicDir: string): Promise<boolean> { const pubSource = path.join(publicDir, "source"); @@ -523,6 +572,7 @@ export async function removePublishedSource(publicDir: string): Promise<boolean> const had = existsSync(path.join(pubSource, "manifest.json")) || existsSync(path.join(pubSource, MIRROR_DIR)) || + existsSync(path.join(pubSource, HISTORY_DIR)) || existsSync(path.join(pubDownloads, TARBALL_NAME)); await rm(path.join(pubSource, "manifest.json"), { force: true }); await rm(statePath(publicDir), { force: true }); @@ -614,14 +664,16 @@ export async function publishSource(opts: SourcePublishOpts = {}): Promise<numbe const signal = opts.signal ?? new AbortController().signal; const ctx: Ctx = { onLog, signal, env: cleanGitEnv(opts.env ?? process.env), literals: [] }; const publicDir = sourcePublicDir(paths, opts.publicDir); - const progress = { rulesLoaded: false }; + const progress = { rulesLoaded: false, scratchRoot: null as string | null }; const withdraw = async () => { if (!progress.rulesLoaded || opts.check) return; if (await removePublishedSource(publicDir)) { onLog( - "[source] the previous publish was WITHDRAWN (mirror, tree, tarball): it was audited under rules that may not be today's. /source shows its empty state until a publish passes.", + "[source] the previous publish was WITHDRAWN (mirror, tree, history, tarball): it was audited under rules that may not be today's. /source shows its empty state until a publish passes.", ); } + // The history's render cache was rendered under those rules too. + if (progress.scratchRoot) await dropHistoryCache(historyCacheDir(progress.scratchRoot)); }; let code: number; try { @@ -649,7 +701,7 @@ async function publish( paths: Paths, ctx: Ctx, publicDir: string, - progress: { rulesLoaded: boolean }, + progress: { rulesLoaded: boolean; scratchRoot: string | null }, ): Promise<number> { const { onLog } = ctx; const started = Date.now(); @@ -684,9 +736,17 @@ async function publish( const filterRepoId = `${filterRepo.label} ${filterRepo.version}`; const gitleaksBin = opts.gitleaks === undefined ? "gitleaks" : opts.gitleaks; const gitleaksId = await gitleaksIdentity(gitleaksBin, ctx.env); + // stagit is optional: absent, the source is published without its history. + const stagit = + opts.stagit === undefined + ? resolveStagit(paths.stagitBin ?? "stagit", ctx.env) + : opts.stagit && existsSync(opts.stagit) + ? opts.stagit + : null; + const stagitId = await stagitIdentity(stagit); // 4. Nothing changed: skip. The key is main, the rules (with the step's - // version), both tools, and the published files themselves. + // version), the tools, and the published files themselves. if (!opts.force && !opts.check) { const manifest = await readPublishedManifest(publicDir); const state = (await readJson(statePath(publicDir))) as Partial<PublishState> | null; @@ -696,6 +756,8 @@ async function publish( state.rulesHash === rules.rulesHash && state.filterRepo === filterRepoId && state.gitleaks === gitleaksId && + state.stagit === stagitId && + (stagitId === "absent" ? state.history === null : !!state.history) && state.mirrorHead === manifest.mirrorHead && existsSync(path.join(pubSource, MIRROR_DIR, "info", "refs")) && existsSync(path.join(pubDownloads, TARBALL_NAME)) && @@ -720,11 +782,14 @@ async function publish( ["the public dir", publicDir], ]); if (badRoot) throw new SourceRefusal(badRoot); + progress.scratchRoot = scratchRoot; await mkdir(scratchRoot, { recursive: true }); const scratch = await mkdtemp(path.join(scratchRoot, "archilyzer-source-")); const replace = path.join(scratch, "replace.txt"); try { - const bare = path.join(scratch, "bare"); + // Named as the published mirror is: stagit names the repository after its + // directory ("archilyzer", the `.git` dropped). + const bare = path.join(scratch, MIRROR_DIR); // 5. A fresh bare clone of main alone. --no-local: through upload-pack, not // a copy of the object dir (which would carry every loose leftover). @@ -852,6 +917,41 @@ async function publish( } const mirrorFiles = await walkFiles(stageMirror); + // 12b. The history pages (stagit), rendered from the audited mirror into + // the stage, so the file gate (13) reads every page. Without stagit — or + // when the render fails, or would break the host's limits — one line says + // so and the rest is published without them: never a failed build. + const stageHistoryDir = path.join(stageSource, HISTORY_DIR); + let history: SourceHistory | null = null; + if (!stagit) { + const why = (paths.stagitBin ?? "stagit").includes("/") + ? "STAGIT_BIN names no executable" + : "not on PATH, not in ~/.local/bin"; + onLog( + `[source] stagit not found (${why}) — publishing without the history pages (/source/${HISTORY_DIR}/); install it once: ${STAGIT_INSTALL}`, + ); + } else { + const commits = Number(lastLine((await g(["rev-list", "--count", `refs/heads/${SOURCE_BRANCH}`])).out)); + history = await stageHistoryPages({ + ctx, + opts, + paths, + stagit, + stagitId, + bare, + mirrorHead, + commits, + scratch, + scratchRoot, + rulesHash: rules.rulesHash, + filterRepoId, + stage, + dest: stageHistoryDir, + // Everything staged so far, and the manifest still to come. + stagedSoFar: (await walkFiles(stage)).length + 1, + }); + } + // The manifest, staged with the rest so the file sweep reads it too. It // carries no rules hash and no literal count: `plans/` publishes which // literals the plan put in the files, so either would say whether the @@ -881,6 +981,7 @@ async function publish( gitleaks: audit.gitleaks === "clean" ? "clean" : "skipped", }, tools: { git: gitVersion, filterRepo: filterRepoId }, + ...(history ? { history } : {}), }; const manifestText = JSON.stringify(manifest, null, 2) + "\n"; await writeFile(path.join(stageSource, "manifest.json"), manifestText); @@ -904,7 +1005,9 @@ async function publish( const totalBytes = all.reduce((n, f) => n + f.bytes, 0); const summary = `main ${sourceCommit.slice(0, 12)} as ${mirrorHead.slice(0, 12)}: ${all.length} files, ${mb(totalBytes)} MB ` + - `(mirror ${packs} pack${packs === 1 ? "" : "s"}, tree ${tree.dirs} dirs), tarball ${mb(tarBytes)} MB sha256 ${tarSha.slice(0, 12)}`; + `(mirror ${packs} pack${packs === 1 ? "" : "s"}, tree ${tree.dirs} dirs, ` + + `${history ? `history ${history.commits} commits in ${history.files} files` : "no history"}), ` + + `tarball ${mb(tarBytes)} MB sha256 ${tarSha.slice(0, 12)}`; // 15. --check writes nothing. if (opts.check) { @@ -919,8 +1022,10 @@ async function publish( await rm(path.join(pubSource, "manifest.json"), { force: true }); await rm(path.join(pubSource, MIRROR_DIR), { recursive: true, force: true }); await rm(path.join(pubSource, "tree"), { recursive: true, force: true }); + await rm(path.join(pubSource, HISTORY_DIR), { recursive: true, force: true }); await cp(stageMirror, path.join(pubSource, MIRROR_DIR), { recursive: true }); await cp(stageTree, path.join(pubSource, "tree"), { recursive: true }); + if (history) await cp(stageHistoryDir, path.join(pubSource, HISTORY_DIR), { recursive: true }); await ownDir(pubDownloads); await copyPublicFile(tarball, path.join(pubDownloads, TARBALL_NAME)); await writePublicFile(path.join(pubDownloads, "snapshot.json"), snapshotText); @@ -931,6 +1036,8 @@ async function publish( filterRepo: filterRepoId, gitleaks: gitleaksId, contentDigest, + stagit: stagitId, + history: history ? { files: history.files, digest: history.sha256 } : null, }; await writeFile(statePath(publicDir), JSON.stringify(state, null, 2) + "\n"); await writePublicFile(path.join(pubSource, "manifest.json"), manifestText); @@ -954,6 +1061,118 @@ function elapsed(started: number): string { } /** + * Step 12b: render the history pages into `a.dest` (stage/source/git) and say + * what they are, or say why not and leave no `dest` — a WARNING, never a + * refusal: the tokens unreadable, stagit failing, or pages that would break + * the host's limits (the step's, which step 14 applies to the whole publish). + * A cancel still cancels. + */ +async function stageHistoryPages(a: { + ctx: Ctx; + opts: SourcePublishOpts; + paths: Paths; + stagit: string; + stagitId: string; + bare: string; + mirrorHead: string; + commits: number; + scratch: string; + scratchRoot: string; + rulesHash: string; + filterRepoId: string; + stage: string; + dest: string; + stagedSoFar: number; +}): Promise<SourceHistory | null> { + const { ctx } = a; + const started = Date.now(); + const where = `/source/${HISTORY_DIR}/`; + const without = async (why: string): Promise<null> => { + await rm(a.dest, { recursive: true, force: true }); + ctx.onLog(`[source] WARNING: ${maskLiterals(why, ctx.literals)} — publishing without the history pages (${where})`); + return null; + }; + const tokensFile = + a.opts.tokensFile ?? + path.join(/* turbopackIgnore: true */ a.paths.monorepoRoot, "common", "styles", "tokens.css"); + let stylesheet: string; + try { + stylesheet = historyStylesheet(await readFile(tokensFile, "utf8")); + } catch (err) { + if (err instanceof HistoryProblem) return without(err.message); + return without(`the design tokens (${tildify(tokensFile)}) could not be read`); + } + // stagit and git through the step's own runner (the environment, the + // cancel, the timeout); a failure or a timeout is the history's problem. + const child: HistoryRun = async (command, args, o) => { + try { + return await run(ctx, command, args, { ...o, allowFail: true }); + } catch (err) { + if (err instanceof SourceRefusal) throw new HistoryProblem(err.message); + throw err; + } + }; + const baseUrl = `${PROJECT_URL}${where}`; + let r; + try { + r = await renderHistory({ + stagit: a.stagit, + gitDir: a.bare, + head: a.mirrorHead, + commits: a.commits, + dest: a.dest, + scratch: a.scratch, + // `--check` writes nothing outside its own scratch. + cacheDir: a.opts.check ? null : historyCacheDir(a.scratchRoot), + cacheKey: historyCacheKey({ + rulesHash: a.rulesHash, + filterRepo: a.filterRepoId, + stagit: a.stagitId, + description: PROJECT_NAME, + cloneUrl: CLONE_URL, + baseUrl, + }), + fresh: !!a.opts.force, + description: PROJECT_NAME, + cloneUrl: CLONE_URL, + baseUrl, + stylesheet, + themeScript: homepageThemeScript(), + run: child, + onLog: ctx.onLog, + }); + } catch (err) { + if (err instanceof HistoryProblem) return without(`the history pages were not rendered: ${err.message}`); + throw err; + } + if (a.stagedSoFar + r.files > MAX_FILES) { + return without( + `${r.files} history files would make ${a.stagedSoFar + r.files} files to publish, over the step's limit of ${MAX_FILES} (Pages allows 20,000 per deployment)`, + ); + } + if (r.largest.bytes > MAX_FILE_BYTES) { + return without( + `${HISTORY_DIR}/${r.largest.rel} is ${mb(r.largest.bytes)} MiB, over the step's limit of ${mb(MAX_FILE_BYTES)} MiB (Pages allows 25 MiB per file)`, + ); + } + const sha256 = await historyDigest(a.stage); + ctx.onLog( + `[source] history: ${a.stagitId} — ${a.commits} commits, ${r.rendered} page${r.rendered === 1 ? "" : "s"} rendered` + + `${r.cached ? " (the rest from the cache)" : ""}; ${r.files} files, ${mb(r.bytes)} MB, ` + + `the largest ${HISTORY_DIR}/${r.largest.rel} ${mb(r.largest.bytes)} MB (${elapsed(started)})`, + ); + return { + href: HISTORY_LOG_HREF, + commits: a.commits, + head: a.mirrorHead, + files: r.files, + bytes: r.bytes, + sha256, + tool: a.stagitId, + }; +} + +/** * `build homepage --no-source`: remove what an earlier publish left, because * it was audited against the rules of ITS day. The pages then show their * empty states. @@ -966,7 +1185,7 @@ export async function clearPublishedSource( const had = await removePublishedSource(sourcePublicDir(paths, opts.publicDir)); onLog( had - ? "[notice] --no-source: the previously published source (mirror, tree, tarball) was removed — this build ships none.\n" + ? "[notice] --no-source: the previously published source (mirror, tree, history, tarball) was removed — this build ships none.\n" : "[notice] --no-source: no source published in this build.\n", ); } @@ -1009,6 +1228,7 @@ export async function publishedSourceProblem( path.join(outSource, "manifest.json"), path.join(outSource, MIRROR_DIR), path.join(outSource, "tree"), + path.join(outSource, HISTORY_DIR), outTarball, ]; // A `--no-source` build: the page's empty state and nothing else. @@ -1061,6 +1281,13 @@ export async function publishedSourceProblem( if (state.gitleaks !== gitleaks) { return `homepage/out's source was scanned by another gitleaks (${state.gitleaks}; this machine has ${gitleaks}) — ${rebuild}`; } + // The history pages: exactly the audited ones, or none when none were + // published. The digest below binds them too; this names them. + const historyNow = existsSync(path.join(outSource, HISTORY_DIR)) ? await historyDigest(outDir) : null; + const historyThen = state.history?.digest ?? null; + if (historyNow !== historyThen || (manifest.history?.sha256 ?? null) !== historyThen) { + return `homepage/out's history pages (/source/${HISTORY_DIR}/) are not the ones that were audited — ${rebuild}`; + } // Last, and the one that binds every byte: the mirror, the tree, the // manifest and both downloads, against the digest of what was audited. if ((await sourceDigest(outDir)) !== state.contentDigest) { diff --git a/common/publish/sourceHistory.test.ts b/common/publish/sourceHistory.test.ts @@ -0,0 +1,467 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildThemeScript, HOMEPAGE_DEFAULT_BASE } from "../lib/themeConfig"; +import { + HISTORY_BACK_LINK, + HISTORY_TOKENS, + HistoryProblem, + SITE_ICON_HREF, + historyStylesheet, + holdHistoryCache, + homepageThemeScript, + renderHistory, + resolveStagit, + rewriteHistoryPage, + stageHistory, + stagitIdentity, + tokenBlocks, + type HistoryRun, + type RenderHistoryOpts, +} from "./sourceHistory"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common test +// +// The history pages (sourceHistory.ts): the post-pass, the stylesheet from +// the tokens, the binary's lookup, and the render with its cache — over a +// fake stagit that writes what stagit writes, and over the real one when it +// is installed (that test SKIPS without it; a gate says whether it ran). +for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX"]) { + delete process.env[key]; +} + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const TOKENS = readFileSync(path.join(HERE, "..", "styles", "tokens.css"), "utf8"); +const TMP = mkdtempSync(path.join(os.tmpdir(), "source-history-")); +after(() => rmSync(TMP, { recursive: true, force: true })); + +let n = 0; +function dir(name: string): string { + const d = path.join(TMP, `${name}-${n++}`); + mkdirSync(d, { recursive: true }); + return d; +} + +// ── the post-pass ─────────────────────────────────────────────────────────── + +// stagit's own markup, as it writes a commit page two levels down (relpath +// "../"): the header, the diffstat's anchors, a diff header linking both +// sides into file/, and a message that quotes an href as text. +const COMMIT_PAGE = `<!DOCTYPE html> +<html> +<head> +<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> +<title>a subject - archilyzer - Archilyzer</title> +<link rel="icon" type="image/png" href="../favicon.png" /> +<link rel="alternate" type="application/atom+xml" title="archilyzer.git Atom Feed" href="../atom.xml" /> +<link rel="stylesheet" type="text/css" href="../style.css" /> +</head> +<body> +<table><tr><td><a href="../../"><img src="../logo.png" alt="" width="32" height="32" /></a></td><td><h1>archilyzer</h1></td></tr><tr><td></td><td> +<a href="../log.html">Log</a> | <a href="../files.html">Files</a> | <a href="../refs.html">Refs</a> | <a href="../file/README.md.html">README</a> | <a href="../file/LICENSE.html">LICENSE</a></td></tr></table> +<hr/> +<div id="content"> +<pre><b>commit</b> <a href="../commit/${"a".repeat(40)}.html">${"a".repeat(40)}</a> +a message that says href=&quot;file/x.html&quot; as text +<b>diff --git a/<a id="h0" href="../file/app/%5Bslug%5D/page.tsx.html">app/[slug]/page.tsx</a> b/<a href="../file/app/%5Bslug%5D/page.tsx.html">app/[slug]/page.tsx</a></b> +<a href="#h0-0-0" id="h0-0-0" class="i">+added +</a></pre> +</div> +</body> +</html> +`; + +test("the post-pass: the theme script before </head>, the back link after <body>, file/ links to the raw tree, the logo and favicon to the site's icon — nothing else", () => { + const script = "var x=1;"; + const out = rewriteHistoryPage(COMMIT_PAGE, script); + const expected = COMMIT_PAGE + .replace("</head>", `<script>${script}</script>\n</head>`) + .replace("<body>\n", `<body>\n${HISTORY_BACK_LINK}\n`) + .replace('href="../favicon.png"', `href="${SITE_ICON_HREF}"`) + .replace('src="../logo.png"', `src="${SITE_ICON_HREF}"`) + .replace('href="../file/README.md.html"', 'href="../../tree/README.md"') + .replace('href="../file/LICENSE.html"', 'href="../../tree/LICENSE"') + .replaceAll('href="../file/app/%5Bslug%5D/page.tsx.html"', 'href="../../tree/app/%5Bslug%5D/page.tsx"'); + assert.equal(out, expected); + // Text that quotes an href is encoded by stagit, and left alone. + assert.match(out, /says href=&quot;file\/x\.html&quot; as text/); + // The back link is the one line added to the body, and it is ASCII. + assert.equal(HISTORY_BACK_LINK, '<p class="archilyzer-source"><a href="/source/">Archilyzer &middot; Source</a></p>'); + assert.ok(!/[^\x00-\x7f]/.test(HISTORY_BACK_LINK)); +}); + +test("the post-pass at the top level: files.html's links go to ../tree/<path>, as stagit encoded them", () => { + const files = `<html>\n<head>\n</head>\n<body>\n<tr><td>-rw-r--r--</td><td><a href="file/fonts/Archivo%5Bwdth%2Cwght%5D.ttf.html">fonts/Archivo[wdth,wght].ttf</a></td></tr>\n<a href="file/a.html.html">a.html</a>\n</body>\n</html>\n`; + const out = rewriteHistoryPage(files, "s()"); + assert.match(out, /<a href="\.\.\/tree\/fonts\/Archivo%5Bwdth%2Cwght%5D\.ttf">/); + assert.match(out, /<a href="\.\.\/tree\/a\.html">a\.html<\/a>/, "a file that is itself .html keeps its name"); + assert.ok(!out.includes('href="file/')); +}); + +test("the theme script is the homepage's own: its base, its string", () => { + assert.equal(HOMEPAGE_DEFAULT_BASE, "dark"); + assert.equal(homepageThemeScript(), buildThemeScript({ defaultBase: HOMEPAGE_DEFAULT_BASE })); + assert.ok(!/[^\x00-\x7f]/.test(homepageThemeScript()), "ASCII, for the byte-for-byte rewrite"); + // It must not be able to end its own element. + assert.throws(() => rewriteHistoryPage("<head></head>", "a</script><b>")); + // The homepage's layout passes the same constant to ThemeScript. + const layout = readFileSync(path.join(HERE, "..", "..", "homepage", "app", "layout.tsx"), "utf8"); + assert.match(layout, /<ThemeScript defaultBase=\{HOMEPAGE_DEFAULT_BASE\} \/>/); +}); + +// ── the stylesheet ────────────────────────────────────────────────────────── + +test("style.css: the homepage's two grounds from tokens.css, by prefers-color-scheme and by data-base", () => { + const css = historyStylesheet(TOKENS); + const { light, dark } = tokenBlocks(TOKENS); + const block = (selector: RegExp) => { + const m = selector.exec(css); + assert.ok(m, String(selector)); + return m[1]; + }; + const lightBlock = block(/:root,\nhtml\[data-base="light"\] \{\n([\s\S]*?)\n\}/); + const mediaBlock = block(/@media \(prefers-color-scheme: dark\) \{\n {2}:root:not\(\[data-base\]\) \{\n([\s\S]*?)\n {2}\}/); + const darkBlock = block(/\nhtml\[data-base="dark"\] \{\n([\s\S]*?)\n\}/); + for (const name of [...HISTORY_TOKENS, "color-scheme", "--swatch-signal"]) { + assert.ok(lightBlock.includes(` ${name}: ${light.get(name)};`), `light ${name}`); + assert.ok(darkBlock.includes(` ${name}: ${dark.get(name)};`), `dark ${name}`); + assert.ok(mediaBlock.includes(` ${name}: ${dark.get(name)};`), `no-JS dark ${name}`); + } + // Only what the rules read (and what that reaches through var()). + assert.ok(!lightBlock.includes("--chart-1")); + // Diff lines: insertions in --success, deletions in --destructive. + assert.match(css, /pre a\.i \{ color: var\(--success\); \}/); + assert.match(css, /pre a\.d \{ color: var\(--destructive\); \}/); + assert.match(css, /p\.archilyzer-source a \{/); +}); + +test("style.css follows the tokens: a changed value is the new value; a missing block or token is a HistoryProblem", () => { + const moved = TOKENS.replace(/(html\[data-base="dark"\] \{[\s\S]*?--background: )#[0-9a-f]+;/, "$1#010203;"); + assert.notEqual(moved, TOKENS); + assert.match(historyStylesheet(moved), /html\[data-base="dark"\] \{[\s\S]*?--background: #010203;/); + assert.throws(() => historyStylesheet(":root { --background: #fff; }"), HistoryProblem); + const noInfo = TOKENS.replace(/--info: #[0-9a-f]+;/g, ""); + assert.throws(() => historyStylesheet(noInfo), (e) => e instanceof HistoryProblem && /no --info/.test(e.message)); +}); + +// ── the binary ────────────────────────────────────────────────────────────── + +function exe(file: string, body = "#!/bin/sh\nexit 0\n"): string { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, body); + chmodSync(file, 0o755); + return file; +} + +test("resolveStagit: STAGIT_BIN as a path, else stagit on PATH, else ~/.local/bin/stagit, else null", async () => { + const home = dir("home"); + const bin = dir("bin"); + const empty = dir("empty"); + assert.equal(resolveStagit("stagit", { PATH: empty }, home), null); + const local = exe(path.join(home, ".local", "bin", "stagit")); + assert.equal(resolveStagit("stagit", { PATH: empty }, home), local); + const onPathBin = exe(path.join(bin, "stagit")); + assert.equal(resolveStagit("stagit", { PATH: `${empty}:${bin}` }, home), onPathBin, "PATH first"); + const given = exe(path.join(dir("given"), "my-stagit")); + assert.equal(resolveStagit(given, { PATH: bin }, home), given); + assert.equal(resolveStagit(path.join(empty, "nope"), { PATH: bin }, home), null, "a path that is not there is nothing, not a PATH lookup"); + writeFileSync(path.join(empty, "not-exec"), "x"); + assert.equal(resolveStagit(path.join(empty, "not-exec"), { PATH: bin }, home), null); + + assert.equal(await stagitIdentity(null), "absent"); + const a = await stagitIdentity(given); + assert.match(a, /^stagit \(sha256 [0-9a-f]{12}\)$/); + exe(given, "#!/bin/sh\nexit 1\n"); + assert.notEqual(await stagitIdentity(given), a, "another binary, another identity"); + assert.ok(!a.includes(given), "never the path"); +}); + +// ── the render, over a fake stagit ────────────────────────────────────────── + +function gitIn(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, stdio: "pipe" }).toString().trim(); +} + +// A bare repository named archilyzer.git with `count` commits on main. +function bareRepo(count: number): { gitDir: string; work: string } { + const work = dir("work"); + gitIn(work, "init", "-q", "-b", "main"); + gitIn(work, "config", "user.name", "history test"); + gitIn(work, "config", "user.email", "history@example.invalid"); + gitIn(work, "config", "commit.gpgsign", "false"); + for (let i = 0; i < count; i++) addCommit(work, i); + const gitDir = path.join(dir("bare"), "archilyzer.git"); + gitIn(TMP, "clone", "-q", "--bare", work, gitDir); + return { gitDir, work }; +} + +function addCommit(work: string, i: number): void { + writeFileSync(path.join(work, "README.md"), `hello ${i}\n`); + mkdirSync(path.join(work, "app", "[slug]"), { recursive: true }); + writeFileSync(path.join(work, "app", "[slug]", "page.tsx"), `export default ${i};\n`); + gitIn(work, "add", "-A"); + gitIn(work, "commit", "-q", "-m", `commit ${i}`); +} + +// What stagit writes, in the directory it runs in: a page per commit (kept +// when there already is one, as stagit keeps it), the log, the files index, +// the refs, the feeds, and a per-file page. `-c` makes it write the cache +// file (the head on its first line). Every call is logged to `log`. +function fakeStagit(log: string, o: { exit?: number; extra?: string } = {}): string { + return exe( + path.join(dir("fake"), "stagit"), + `#!/bin/sh +cache=""; base=""; repo="" +while [ $# -gt 0 ]; do + case "$1" in + -c) cache="$2"; shift 2;; + -u) base="$2"; shift 2;; + *) repo="$1"; shift;; + esac +done +echo "cache=$cache base=$base repo=$(basename "$repo")" >> '${log}' +${o.exit ? `echo 'stagit: something broke' >&2; exit ${o.exit}` : ""} +mkdir -p commit file/app +for c in $(git --git-dir "$repo" rev-list HEAD); do + if [ ! -f "commit/$c.html" ]; then + printf '<!DOCTYPE html>\\n<html>\\n<head>\\n<link rel="icon" type="image/png" href="../favicon.png" />\\n</head>\\n<body>\\n<a href="../file/README.md.html">README</a> ${o.extra ?? ""}\\n</body>\\n</html>\\n' > "commit/$c.html" + fi +done +printf '<!DOCTYPE html>\\n<html>\\n<head>\\n</head>\\n<body>\\n<span class="desc">%s</span> %s <a href="file/README.md.html">README</a> <img src="logo.png" />\\n</body>\\n</html>\\n' "$(cat "$repo/description")" "$(cat "$repo/url")" > log.html +printf '<html>\\n<head>\\n</head>\\n<body>\\n<a href="file/app/%%5Bslug%%5D/page.tsx.html">x</a>\\n</body>\\n</html>\\n' > files.html +printf 'refs\\n' > refs.html +printf '<feed>%s file/README.md.html</feed>\\n' "$base" > atom.xml +printf '<feed/>\\n' > tags.xml +printf 'x\\n' > file/README.md.html +touch cache.XXXXleftover +if [ -n "$cache" ]; then git --git-dir "$repo" rev-parse HEAD > "$cache"; fi +exit 0 +`, + ); +} + +const run: HistoryRun = async (command, args, o) => { + const p = spawnSync(command, args, { cwd: o.cwd, encoding: "utf8" }); + return { code: p.status ?? 1, out: `${p.stdout}${p.stderr}`.split("\n") }; +}; + +function renderOpts(gitDir: string, stagit: string, extra: Partial<RenderHistoryOpts> = {}): RenderHistoryOpts & { logs: string[] } { + const logs: string[] = []; + const head = gitIn(TMP, "--git-dir", gitDir, "rev-parse", "main"); + const commits = Number(gitIn(TMP, "--git-dir", gitDir, "rev-list", "--count", "main")); + return { + stagit, + gitDir, + head, + commits, + dest: path.join(dir("stage"), "source", "git"), + scratch: dir("scratch"), + cacheDir: null, + cacheKey: "k1", + fresh: false, + description: "Archilyzer", + cloneUrl: "https://archilyzer.pages.dev/source/archilyzer.git", + baseUrl: "https://archilyzer.pages.dev/source/git/", + stylesheet: "/* css */\n", + themeScript: "t()", + run, + onLog: (l) => logs.push(l), + logs, + ...extra, + }; +} + +const calls = (log: string) => (existsSync(log) ? readFileSync(log, "utf8").trim().split("\n") : []); + +test("render: the allowlist of stagit's output, each page through the post-pass, the feeds as they are, style.css; never file/", async () => { + const { gitDir } = bareRepo(3); + const log = path.join(dir("log"), "calls"); + const o = renderOpts(gitDir, fakeStagit(log)); + const r = await renderHistory(o); + assert.deepEqual(calls(log), [`cache= base=${o.baseUrl} repo=archilyzer.git`]); + assert.equal(r.files, 3 + 5 + 1); + assert.equal(r.rendered, 3); + assert.equal(r.cached, false); + assert.deepEqual(readdirSync(o.dest).sort(), ["atom.xml", "commit", "files.html", "log.html", "refs.html", "style.css", "tags.xml"]); + assert.equal(readdirSync(path.join(o.dest, "commit")).length, 3); + const logHtml = readFileSync(path.join(o.dest, "log.html"), "utf8"); + // stagit's header reads the description and the clone URL from the repo. + assert.match(logHtml, /<span class="desc">Archilyzer<\/span> https:\/\/archilyzer\.pages\.dev\/source\/archilyzer\.git /); + assert.ok(logHtml.includes(`<body>\n${HISTORY_BACK_LINK}\n`)); + assert.ok(logHtml.includes("<script>t()</script>\n</head>")); + assert.match(logHtml, /href="\.\.\/tree\/README\.md"/); + assert.match(logHtml, new RegExp(`src="${SITE_ICON_HREF}"`)); + assert.match(readFileSync(path.join(o.dest, "files.html"), "utf8"), /href="\.\.\/tree\/app\/%5Bslug%5D\/page\.tsx"/); + // The feeds are not pages: not one byte changes. + assert.equal(readFileSync(path.join(o.dest, "atom.xml"), "utf8"), `<feed>${o.baseUrl} file/README.md.html</feed>\n`); + assert.equal(readFileSync(path.join(o.dest, "style.css"), "utf8"), "/* css */\n"); + // No cache: the render ran in the publish's own scratch. + assert.ok(existsSync(path.join(o.scratch, "history", "log.html"))); + assert.ok(!existsSync(path.join(o.scratch, "history", "file")), "the per-file pages are dropped where they were rendered"); +}); + +test("render: the page bytes are kept (a diff of a file that is not UTF-8), only the ASCII injections added", async () => { + const work = dir("bytes"); + mkdirSync(path.join(work, "commit")); + const odd = Buffer.concat([Buffer.from("<html>\n<head>\n</head>\n<body>\n"), Buffer.from([0xff, 0xfe, 0x80]), Buffer.from("\n</body>\n</html>\n")]); + writeFileSync(path.join(work, "log.html"), odd); + for (const f of ["files.html", "refs.html"]) writeFileSync(path.join(work, f), "<html>\n<head>\n</head>\n<body>\n</body>\n</html>\n"); + for (const f of ["atom.xml", "tags.xml"]) writeFileSync(path.join(work, f), Buffer.from([0x3c, 0xff, 0x3e])); + const dest = path.join(dir("dest"), "git"); + await stageHistory(work, dest, { themeScript: "t()", stylesheet: "" }); + const out = readFileSync(path.join(dest, "log.html")); + assert.ok(out.includes(Buffer.from([0xff, 0xfe, 0x80])), "the odd bytes survive"); + assert.ok(out.includes(Buffer.from(HISTORY_BACK_LINK))); + assert.deepEqual(readFileSync(path.join(dest, "atom.xml")), Buffer.from([0x3c, 0xff, 0x3e])); + await assert.rejects(stageHistory(work, dest, { themeScript: "é", stylesheet: "" }), /ASCII/); +}); + +test("render: a failing stagit, or one that leaves the wrong pages, is a HistoryProblem", async () => { + const { gitDir } = bareRepo(2); + const log = path.join(dir("log"), "calls"); + await assert.rejects( + renderHistory(renderOpts(gitDir, fakeStagit(log, { exit: 3 }))), + (e) => e instanceof HistoryProblem && /stagit exited 3: stagit: something broke/.test(e.message), + ); + await assert.rejects( + renderHistory(renderOpts(gitDir, fakeStagit(log), { commits: 5 })), + (e) => e instanceof HistoryProblem && /stagit left 2 commit pages for 5 commits/.test(e.message), + ); +}); + +test("the cache: -c under the cache dir, incremental the next time, the key and the ancestry checked, busy or cut-off locks handled", async () => { + const { gitDir, work } = bareRepo(2); + const log = path.join(dir("log"), "calls"); + const stagit = fakeStagit(log); + const cacheDir = path.join(dir("scratch-root"), "archilyzer-source-history"); + const cacheFile = path.join(cacheDir, "stagit.cache"); + const cacheArgs = (o: RenderHistoryOpts) => `cache=${cacheFile} base=${o.baseUrl} repo=archilyzer.git`; + + // First: everything rendered, the cache kept (key, cache file, output) and + // unlocked; no per-file pages and no stagit leftovers are kept. + let o = renderOpts(gitDir, stagit, { cacheDir }); + let r = await renderHistory(o); + assert.equal(r.cached, false); + assert.equal(r.rendered, 2); + assert.equal(calls(log).at(-1), cacheArgs(o)); + assert.deepEqual(readdirSync(cacheDir).sort(), ["key.json", "out", "stagit.cache"]); + assert.ok(!existsSync(path.join(cacheDir, "out", "file"))); + assert.ok(!existsSync(path.join(o.dest, "cache.XXXXleftover")), "only the allowlist is published"); + + // A new commit: the cache is used, and only the new page is rendered. + addCommit(work, 2); + gitIn(TMP, "--git-dir", gitDir, "fetch", "-q", work, "main:main"); + o = renderOpts(gitDir, stagit, { cacheDir }); + r = await renderHistory(o); + assert.equal(r.cached, true); + assert.equal(r.rendered, 1); + assert.equal(r.files, 3 + 5 + 1); + + // Another key (other rules, another stagit…): every page rendered again, + // and a page the cache held that is not in history is gone. + const junk = path.join(cacheDir, "out", "commit", `${"f".repeat(40)}.html`); + writeFileSync(junk, "junk"); + o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2" }); + r = await renderHistory(o); + assert.equal(r.cached, false); + assert.equal(r.rendered, 3); + assert.ok(!existsSync(junk)); + assert.ok(!existsSync(path.join(o.dest, "commit", `${"f".repeat(40)}.html`))); + + // A cache whose commit is not an ancestor of the head (a rewritten main). + writeFileSync(cacheFile, `${"e".repeat(40)}\n`); + o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2" }); + r = await renderHistory(o); + assert.equal(r.cached, false); + + // A cache that looks sound but holds a page history does not: the render + // notices (pages ≠ commits) and renders every page again. + writeFileSync(junk, "junk"); + o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2" }); + r = await renderHistory(o); + assert.equal(r.cached, false); + assert.match(o.logs.join("\n"), /stagit left 4 commit pages for 3 commits from the cache; rendering every page again/); + assert.ok(!existsSync(junk)); + + // `fresh` (--force) renders every page, whatever the cache holds. + o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2", fresh: true }); + r = await renderHistory(o); + assert.equal(r.cached, false); + assert.equal(r.rendered, 3); + + // Busy: another live publish holds it (this process's pid stands in). + writeFileSync(path.join(cacheDir, "lock"), `${process.pid}\n`); + o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2" }); + r = await renderHistory(o); + assert.equal(r.cached, false); + assert.equal(calls(log).at(-1), `cache= base=${o.baseUrl} repo=archilyzer.git`, "rendered without -c"); + assert.match(o.logs.join("\n"), /the render cache is in use by another publish; rendering without it/); + assert.ok(existsSync(path.join(cacheDir, "lock")), "the other holder's lock is left alone"); + + // Cut off: the holder is gone, so nothing in the cache is trusted. + const dead = spawnSync("sh", ["-c", "echo $$"], { encoding: "utf8" }).stdout.trim(); + writeFileSync(path.join(cacheDir, "lock"), `${dead}\n`); + writeFileSync(junk, "junk"); + assert.equal(await holdHistoryCache(cacheDir), true); + assert.ok(!existsSync(junk), "emptied"); + assert.ok(!existsSync(path.join(cacheDir, "key.json"))); + rmSync(path.join(cacheDir, "lock")); + + // A render that fails empties the cache and releases it. + o = renderOpts(gitDir, fakeStagit(log, { exit: 2 }), { cacheDir, cacheKey: "k2" }); + await assert.rejects(renderHistory(o), HistoryProblem); + assert.deepEqual(readdirSync(cacheDir), []); +}); + +// ── the real stagit ───────────────────────────────────────────────────────── + +test("the real stagit: a page per commit, every link into file/ now into the tree, every relative link resolves", async (t) => { + const stagit = resolveStagit(process.env.STAGIT_BIN ?? "stagit", process.env); + if (!stagit) return t.skip("stagit is not installed (STAGIT_BIN, PATH, ~/.local/bin)"); + const { gitDir } = bareRepo(3); + const o = renderOpts(gitDir, stagit, { cacheDir: path.join(dir("real-root"), "archilyzer-source-history") }); + const r = await renderHistory(o); + assert.equal(r.rendered, 3); + assert.equal(readdirSync(path.join(o.dest, "commit")).length, 3); + assert.ok(!existsSync(path.join(o.dest, "file"))); + for (const page of ["log.html", "files.html", "refs.html", ...readdirSync(path.join(o.dest, "commit")).map((f) => `commit/${f}`)]) { + const html = readFileSync(path.join(o.dest, page), "utf8"); + assert.ok(html.includes(`<body>\n${HISTORY_BACK_LINK}\n`), page); + assert.ok(html.includes("<script>t()</script>\n</head>"), page); + assert.ok(!/href="(?:\.\.\/)*file\//.test(html), `${page} links into file/`); + assert.ok(!/(?:logo|favicon)\.png"/.test(html), `${page} names stagit's images`); + for (const m of html.matchAll(/(?:href|src)="([^"#:]+)"/g)) { + const target = m[1]; + if (target.startsWith("/")) continue; // the site's own: /source/, the icon + const resolved = path.posix + .normalize(path.posix.join(path.posix.dirname(`source/git/${page}`), target)) + .replace(/\/$/, ""); + if (resolved.startsWith("source/tree/")) { + // Into the raw tree: a file of main. + assert.ok(["source/tree/README.md", "source/tree/app/%5Bslug%5D/page.tsx"].includes(resolved), `${page}: ${target}`); + } else if (resolved.startsWith("source/git/")) { + assert.ok(existsSync(path.join(path.dirname(path.dirname(o.dest)), resolved)), `${page}: ${target} → ${resolved}`); + } else { + assert.equal(resolved, "source", `${page}: ${target}`); // the logo's link, to /source/ + } + } + } + const logHtml = readFileSync(path.join(o.dest, "log.html"), "utf8"); + assert.match(logHtml, /<title>Log - archilyzer - Archilyzer<\/title>/); + assert.match(logHtml, /git clone <a href="https:\/\/archilyzer\.pages\.dev\/source\/archilyzer\.git">/); + assert.match(readFileSync(path.join(o.dest, "atom.xml"), "utf8"), /href="https:\/\/archilyzer\.pages\.dev\/source\/git\/commit\/[0-9a-f]{40}\.html"/); +}); diff --git a/common/publish/sourceHistory.ts b/common/publish/sourceHistory.ts @@ -0,0 +1,573 @@ +// The source's history pages: stagit's rendering of the scrubbed mirror, +// published at /source/git/ by `archilyzer source publish` (source.ts), which +// runs this after the mirror is built and its objects audited, and before the +// staged files are audited — so every page goes through the same gate as the +// mirror, the tree and the tarball. +// +// stagit (codemadness.org, C over libgit2) is an OPERATOR-INSTALLED tool, like +// git-filter-repo: never vendored, never committed. It is found as STAGIT_BIN, +// else `stagit` on PATH, else ~/.local/bin/stagit (resolveStagit). WITHOUT IT +// THE PUBLISH GOES ON: one log line says so and how to install it, and the +// source is published without /source/git/ (the /source/ page then shows no +// History links). A render that fails, or pages over the host's limits, are +// the same: a WARNING, and no history — never a failed build. +// +// WHAT IS PUBLISHED, from an allowlist of what stagit writes: +// log.html, files.html, refs.html, atom.xml, tags.xml, commit/<sha>.html +// and style.css, written here from common/styles/tokens.css. NOT stagit's +// per-file pages (file/…): browsing is the raw tree at /source/tree/, so +// every link into file/ is rewritten to it and file/ is never staged. +// +// THE POST-PASS (rewriteHistoryPage) touches every .html page and never the +// two feeds. It adds exactly two things — the homepage's pre-paint theme +// script (lib/themeConfig.ts buildThemeScript, the string the homepage's +// ThemeScript emits), and one line at the top linking back to /source/ — and +// points two kinds of stagit link somewhere that exists: `…file/<path>.html` +// at the raw tree, and its logo.png / favicon.png at the site's own icon. +// stagit's header (the name, the description, the clone line, Log | Files | +// Refs) stays as stagit writes it. +// +// THE CACHE. stagit's `-c <cachefile>` renders incrementally: it walks from +// HEAD to the commit the cache names and keeps every commit page already in +// its output directory. So the cache is a DIRECTORY kept between publishes — +// `<ARCHILYZER_SOURCE_SCRATCH>/archilyzer-source-history/` (outside the +// checkout and the public dir; the scratch root's own check) holding the +// cache file, stagit's output and a key. It is trusted only when the key +// matches (the scrub rules and step, filter-repo, stagit, the header text), +// the commit it names is an ancestor of today's head, and the last run +// finished (the key is removed before a render and written after it). One +// publish holds it at a time (`lock`, the holder's pid); a second renders +// without it. `--force` renders it afresh, `--check` never touches it, and a +// refusal removes it. + +import { createHash } from "node:crypto"; +import { accessSync, constants, existsSync, statSync } from "node:fs"; +import { cp, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PROJECT_NAME } from "../lib/project"; +import { buildThemeScript, HOMEPAGE_DEFAULT_BASE } from "../lib/themeConfig"; +import { onPath } from "./sourceAudit"; + +/** How to install stagit, as the log line and the doctor say it. */ +export const STAGIT_INSTALL = + "git clone git://git.codemadness.org/stagit && make -C stagit && cp stagit/stagit ~/.local/bin/"; + +/** The cache directory's name under the scratch root. */ +export const HISTORY_CACHE_NAME = "archilyzer-source-history"; + +/** The site's own 32 px icon, which stagit's logo.png and favicon.png become. */ +export const SITE_ICON_HREF = "/icons/icon-32.png"; + +/** + * The one line put at the top of every page. ASCII only (`&middot;`): the + * pages are rewritten byte for byte (latin1 in, latin1 out), so anything + * injected must be the same bytes in every encoding. + */ +export const HISTORY_BACK_LINK = `<p class="archilyzer-source"><a href="/source/">${PROJECT_NAME} &middot; Source</a></p>`; + +/** The pre-paint script the homepage emits (homepage/app/layout.tsx). */ +export function homepageThemeScript(): string { + return buildThemeScript({ defaultBase: HOMEPAGE_DEFAULT_BASE }); +} + +// What stagit writes that is published: the top-level pages and feeds, and +// one page per commit. +const TOP_FILES = ["log.html", "files.html", "refs.html", "atom.xml", "tags.xml"] as const; +const COMMIT_PAGE = /^[0-9a-f]{40}\.html$/; + +/** Something about the history went wrong: a WARNING, and no history. */ +export class HistoryProblem extends Error { + constructor(message: string) { + super(message); + this.name = "HistoryProblem"; + } +} + +// ── the binary ────────────────────────────────────────────────────────────── + +function isExecutableFile(p: string): boolean { + try { + accessSync(p, constants.X_OK); + return statSync(p).isFile(); + } catch { + return false; + } +} + +/** + * The stagit binary to run, or null. `bin` is paths.stagitBin: STAGIT_BIN, + * else "stagit". A name with a slash is that file, or nothing. A bare name is + * looked up on PATH, then in ~/.local/bin (the editor's process may not have + * it on its PATH, where a hand-built tool is usually put). + */ +export function resolveStagit(bin: string, env: NodeJS.ProcessEnv, homeDir: string = os.homedir()): string | null { + if (bin.includes("/")) return isExecutableFile(bin) ? bin : null; + const found = onPath(bin, env.PATH); + if (found) return found; + const local = path.join(homeDir, ".local", "bin", bin); + return isExecutableFile(local) ? local : null; +} + +/** + * Which stagit renders: "absent", else the first 12 hex of its binary's + * sha256 (stagit has no version flag). Part of the publish's skip key and of + * the cache's, and the manifest's `history.tool`. Never the path: the + * manifest is published, and a path under the home dir is a denied literal. + */ +export async function stagitIdentity(found: string | null): Promise<string> { + if (!found) return "absent"; + const bytes = await readFile(await realpath(found)); + return `stagit (sha256 ${createHash("sha256").update(bytes).digest("hex").slice(0, 12)})`; +} + +// ── the stylesheet ────────────────────────────────────────────────────────── + +// The tokens the stylesheet reads, on each base. `--brand` is Signal on both +// (the homepage's accent: the base blocks default to it), and every token a +// value names through var() comes along. +export const HISTORY_TOKENS = [ + "--background", + "--surface", + "--foreground", + "--muted-foreground", + "--faint", + "--border-strong", + "--brand", + "--brand-soft", + "--info", + "--success", + "--destructive", +] as const; + +type Decls = Map<string, string>; + +function declarations(body: string): Decls { + const out: Decls = new Map(); + for (const part of body.split(";")) { + const i = part.indexOf(":"); + if (i === -1) continue; + const name = part.slice(0, i).trim(); + if (name.startsWith("--") || name === "color-scheme") out.set(name, part.slice(i + 1).trim()); + } + return out; +} + +/** + * The light and the dark base blocks of tokens.css, as declarations: the rule + * whose selector list holds `html[data-base="light"]`, and the one holding + * `html[data-base="dark"]`. A file without both is an error. + */ +export function tokenBlocks(tokensCss: string): { light: Decls; dark: Decls } { + const text = tokensCss.replace(/\/\*[\s\S]*?\*\//g, ""); + let light: Decls | null = null; + let dark: Decls | null = null; + for (const m of text.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + const selectors = m[1].split(",").map((s) => s.trim()); + if (selectors.includes('html[data-base="light"]')) light = declarations(m[2]); + else if (selectors.includes('html[data-base="dark"]')) dark = declarations(m[2]); + } + if (!light || !dark) throw new HistoryProblem("tokens.css has no light or no dark base block"); + return { light, dark }; +} + +// `names` and every custom property their values reach through var(), in +// the block's own order, as ` name: value;` lines. +function closure(block: Decls, names: readonly string[], base: string): string { + const want = new Set<string>(["color-scheme"]); + const visit = (name: string) => { + if (want.has(name)) return; + const value = block.get(name); + if (value === undefined) throw new HistoryProblem(`tokens.css's ${base} block has no ${name}`); + want.add(name); + for (const ref of value.matchAll(/var\(\s*(--[\w-]+)/g)) visit(ref[1]); + }; + for (const n of names) visit(n); + return [...block].filter(([k]) => want.has(k)).map(([k, v]) => ` ${k}: ${v};`).join("\n"); +} + +/** + * style.css for the history pages, from common/styles/tokens.css: the + * homepage's two grounds. Without the theme script (no JS) the pages follow + * `prefers-color-scheme`; with it, `html[data-base]` is the visitor's stored + * choice, or the homepage's default. The rules are stagit's own stylesheet, + * recoloured: links in the accent, diff insertions in --success and deletions + * in --destructive (each line also keeps its + or − sign). + */ +export function historyStylesheet(tokensCss: string): string { + const { light, dark } = tokenBlocks(tokensCss); + const lightVars = closure(light, HISTORY_TOKENS, "light"); + const darkVars = closure(dark, HISTORY_TOKENS, "dark"); + const indent = (s: string) => s.split("\n").map((l) => ` ${l}`).join("\n"); + return `/* The source's history pages (stagit), styled by \`archilyzer source publish\` + from common/styles/tokens.css — the homepage's two grounds. Generated. */ +:root, +html[data-base="light"] { +${lightVars} +} +@media (prefers-color-scheme: dark) { + :root:not([data-base]) { +${indent(darkVars)} + } +} +html[data-base="dark"] { +${darkVars} +} + +html { background: var(--background); } +body { + margin: 0; + padding: 1rem; + background: var(--background); + color: var(--foreground); + font-family: ui-monospace, "IBM Plex Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: 0.875rem; + line-height: 1.5; +} +a { color: var(--brand); } +a:hover { color: var(--foreground); } +p.archilyzer-source { + margin: 0 0 1rem; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + font-size: 0.8125rem; +} +p.archilyzer-source a { color: var(--muted-foreground); text-decoration: none; } +p.archilyzer-source a:hover { color: var(--foreground); text-decoration: underline; } +h1, h2, h3, h4, h5, h6 { font-size: 1em; margin: 0; } +img, h1, h2 { vertical-align: middle; } +img { border: 0; } +a:target { background-color: var(--brand-soft); } +a.d, a.h, a.i, a.line { text-decoration: none; } +#blob a { color: var(--faint); } +#blob a:hover { color: var(--brand); text-decoration: none; } +table thead td { font-weight: bold; } +table td { padding: 0 0.4em; } +#content { overflow-x: auto; } +#content table td { vertical-align: top; white-space: nowrap; } +#branches tr:hover td, +#tags tr:hover td, +#index tr:hover td, +#log tr:hover td, +#files tr:hover td { background-color: var(--surface); } +#index tr td:nth-child(2), +#tags tr td:nth-child(3), +#branches tr td:nth-child(3), +#log tr td:nth-child(2) { white-space: normal; } +td.num { text-align: right; } +.desc { color: var(--muted-foreground); } +hr { border: 0; border-top: 1px solid var(--border-strong); height: 1px; } +pre { font-family: inherit; } +pre a.h { color: var(--info); } +.A, +span.i, +pre a.i { color: var(--success); } +.D, +span.d, +pre a.d { color: var(--destructive); } +pre a.h:hover, +pre a.i:hover, +pre a.d:hover { text-decoration: none; } +`; +} + +// ── the post-pass ─────────────────────────────────────────────────────────── + +/** + * One stagit page, as published. Adds the theme script before `</head>` and + * HISTORY_BACK_LINK after `<body>`; points every `href="…file/<path>.html"` + * (the Files index, the header's README and LICENSE, a diff's file names) at + * the raw tree — `../tree/<path>` from the same depth, the path as stagit + * encoded it — and stagit's logo.png and favicon.png at the site's icon. + * Nothing else changes. Page text cannot fake an `href="…"`: stagit encodes + * every `"` it prints from the repository as `&quot;`. + */ +export function rewriteHistoryPage(html: string, themeScript: string): string { + if (/<\/script/i.test(themeScript)) throw new Error("the theme script may not close its own element"); + let out = html; + const head = out.indexOf("</head>"); + if (head !== -1) out = `${out.slice(0, head)}<script>${themeScript}</script>\n${out.slice(head)}`; + const body = /<body[^>]*>\n?/.exec(out); + if (body) { + const at = body.index + body[0].length; + out = `${out.slice(0, at)}${HISTORY_BACK_LINK}\n${out.slice(at)}`; + } + return out + .replace(/href="((?:\.\.\/)*)file\/([^"]*)\.html"/g, (_m, up: string, p: string) => `href="${up}../tree/${p}"`) + .replace(/src="(?:\.\.\/)*logo\.png"/g, `src="${SITE_ICON_HREF}"`) + .replace(/href="(?:\.\.\/)*favicon\.png"/g, `href="${SITE_ICON_HREF}"`); +} + +/** + * Copy the allowlist of stagit's output from `work` into `dest`, each page + * through the post-pass (byte for byte otherwise: read and written as latin1, + * so a diff of a file that is not UTF-8 keeps its bytes), then style.css. + * The feeds are copied as they are. + */ +export async function stageHistory( + work: string, + dest: string, + o: { themeScript: string; stylesheet: string }, +): Promise<{ files: number; bytes: number; largest: { rel: string; bytes: number } }> { + if (/[^\x00-\x7f]/.test(o.themeScript)) throw new Error("the theme script must be ASCII"); + const rels: string[] = []; + for (const f of TOP_FILES) { + if (!existsSync(path.join(work, f))) throw new HistoryProblem(`stagit wrote no ${f}`); + rels.push(f); + } + const commitDir = path.join(work, "commit"); + for (const f of existsSync(commitDir) ? await readdir(commitDir) : []) { + if (COMMIT_PAGE.test(f)) rels.push(`commit/${f}`); + } + await mkdir(path.join(dest, "commit"), { recursive: true }); + let bytes = 0; + let largest = { rel: "", bytes: -1 }; + const note = (rel: string, n: number) => { + bytes += n; + if (n > largest.bytes) largest = { rel, bytes: n }; + }; + for (const rel of rels) { + const src = path.join(work, rel); + const dst = path.join(dest, rel); + if (rel.endsWith(".html")) { + const text = rewriteHistoryPage((await readFile(src)).toString("latin1"), o.themeScript); + const buf = Buffer.from(text, "latin1"); + await writeFile(dst, buf); + note(rel, buf.length); + } else { + await cp(src, dst); + note(rel, (await stat(dst)).size); + } + } + await writeFile(path.join(dest, "style.css"), o.stylesheet); + note("style.css", Buffer.byteLength(o.stylesheet)); + return { files: rels.length + 1, bytes, largest }; +} + +// ── the render, with its cache ────────────────────────────────────────────── + +export type HistoryRun = ( + command: string, + args: string[], + o: { cwd: string; timeoutMs: number }, +) => Promise<{ code: number; out: string[] }>; + +export type RenderHistoryOpts = { + stagit: string; + // The scrubbed bare clone — a directory named archilyzer.git (stagit names + // the repository after it) — at `head`, with `commits` commits. + gitDir: string; + head: string; + commits: number; + // Where the published copy goes (stage/source/git). + dest: string; + // The publish's own scratch dir: the render's directory when there is no + // cache to use. + scratch: string; + // The kept cache directory, or null (a `--check`). + cacheDir: string | null; + // Changes whenever pages rendered before must not be kept. + cacheKey: string; + // `--force`: render every page again. + fresh: boolean; + // stagit's header: the description line and the clone URL. + description: string; + cloneUrl: string; + // The Atom feeds' absolute base (`<site>/source/git/`). + baseUrl: string; + stylesheet: string; + themeScript: string; + // A child, run with the publish's environment and cancel signal. + run: HistoryRun; + onLog: (line: string) => void; +}; + +export type RenderedHistory = { + files: number; + bytes: number; + largest: { rel: string; bytes: number }; + // Pages stagit wrote this run (all of them without a usable cache). + rendered: number; + cached: boolean; +}; + +const pidAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "EPERM"; + } +}; + +/** + * Hold the cache directory, or say it is busy. The lock names its holder's + * pid; a lock whose holder is gone means a render was cut off, so nothing in + * the directory is trusted: it is emptied and taken. + */ +export async function holdHistoryCache(dir: string): Promise<boolean> { + await mkdir(dir, { recursive: true, mode: 0o700 }); + const lock = path.join(dir, "lock"); + for (let attempt = 0; attempt < 2; attempt++) { + try { + await writeFile(lock, `${process.pid}\n`, { flag: "wx" }); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + const pid = Number((await readFile(lock, "utf8").catch(() => "")).trim()); + if (Number.isInteger(pid) && pid > 0 && pidAlive(pid)) return false; + await emptyDir(dir); + } + } + return false; +} + +async function emptyDir(dir: string): Promise<void> { + for (const f of await readdir(dir).catch(() => [] as string[])) { + await rm(path.join(dir, f), { recursive: true, force: true }); + } +} + +async function releaseHistoryCache(dir: string): Promise<void> { + await rm(path.join(dir, "lock"), { force: true }); +} + +/** + * Remove the cache (a refusal: it was rendered under rules that may not be + * today's). Left alone while another publish holds it. + */ +export async function dropHistoryCache(dir: string): Promise<void> { + if (!existsSync(dir)) return; + if (await holdHistoryCache(dir)) await rm(dir, { recursive: true, force: true }); +} + +async function countCommitPages(work: string): Promise<number> { + const names = await readdir(path.join(work, "commit")).catch(() => [] as string[]); + return names.filter((f) => COMMIT_PAGE.test(f)).length; +} + +/** + * Render the history into `o.dest`. Throws HistoryProblem when stagit fails + * or its output is not what it should be; the caller publishes without the + * history then. + */ +export async function renderHistory(o: RenderHistoryOpts): Promise<RenderedHistory> { + // stagit's header reads both from the repository directory. Neither is + // published as a file (the mirror is staged from an allowlist). No newline + // after the description: stagit keeps it, in the <title> and the header. + await writeFile(path.join(o.gitDir, "description"), o.description); + await writeFile(path.join(o.gitDir, "url"), `${o.cloneUrl}\n`); + + const held = o.cacheDir ? await holdHistoryCache(o.cacheDir) : false; + if (o.cacheDir && !held) o.onLog("[source] history: the render cache is in use by another publish; rendering without it"); + try { + if (!held || !o.cacheDir) { + const work = path.join(o.scratch, "history"); + return { ...(await renderOnce(o, work, null)), cached: false }; + } + const dir = o.cacheDir; + const keyFile = path.join(dir, "key.json"); + const cacheFile = path.join(dir, "stagit.cache"); + const work = path.join(dir, "out"); + let usable = !o.fresh && (await cacheUsable(o, dir, keyFile, cacheFile, work)); + if (!usable) await emptyCache(dir); + // The key goes before the render and comes back after it: a render cut + // off leaves none, and the next publish starts over. + await rm(keyFile, { force: true }); + try { + const r = await renderOnce(o, work, cacheFile); + await writeFile(keyFile, JSON.stringify({ key: o.cacheKey }) + "\n"); + return { ...r, cached: usable }; + } catch (err) { + if (!(err instanceof HistoryProblem) || !usable) throw err; + // A cache that looked sound and did not render: once more from nothing. + o.onLog(`[source] history: ${err.message} from the cache; rendering every page again`); + usable = false; + await emptyCache(dir); + const r = await renderOnce(o, work, cacheFile); + await writeFile(keyFile, JSON.stringify({ key: o.cacheKey }) + "\n"); + return { ...r, cached: false }; + } + } catch (err) { + if (held && o.cacheDir) await emptyCache(o.cacheDir); + throw err; + } finally { + if (held && o.cacheDir) await releaseHistoryCache(o.cacheDir); + } +} + +// Everything in the cache but its lock. +async function emptyCache(dir: string): Promise<void> { + for (const f of await readdir(dir).catch(() => [] as string[])) { + if (f !== "lock") await rm(path.join(dir, f), { recursive: true, force: true }); + } +} + +async function cacheUsable( + o: RenderHistoryOpts, + dir: string, + keyFile: string, + cacheFile: string, + work: string, +): Promise<boolean> { + let key: unknown = null; + try { + key = (JSON.parse(await readFile(keyFile, "utf8")) as { key?: unknown }).key; + } catch { + return false; + } + if (key !== o.cacheKey || !existsSync(path.join(work, "log.html"))) return false; + const last = (await readFile(cacheFile, "utf8").catch(() => "")).split("\n")[0].trim(); + if (!/^[0-9a-f]{40}$/.test(last)) return false; + // History that was rewritten since (a force-push to the private main) + // leaves pages of commits that are no longer in it. + const r = await o.run("git", ["--git-dir", o.gitDir, "merge-base", "--is-ancestor", last, o.head], { + cwd: dir, + timeoutMs: 30_000, + }); + return r.code === 0; +} + +async function renderOnce( + o: RenderHistoryOpts, + work: string, + cacheFile: string | null, +): Promise<Omit<RenderedHistory, "cached">> { + await mkdir(work, { recursive: true }); + const before = await countCommitPages(work); + const args = [...(cacheFile ? ["-c", cacheFile] : []), "-u", o.baseUrl, o.gitDir]; + const r = await o.run(o.stagit, args, { cwd: work, timeoutMs: 600_000 }); + // The per-file pages are never published, and stagit writes them all again + // on every run: none is kept. + await rm(path.join(work, "file"), { recursive: true, force: true }); + if (r.code !== 0) { + const tail = r.out.filter((l) => l.trim()).slice(-2).join(" / "); + throw new HistoryProblem(`stagit exited ${r.code}${tail ? `: ${tail}` : ""}`); + } + const pages = await countCommitPages(work); + if (pages !== o.commits) { + throw new HistoryProblem(`stagit left ${pages} commit pages for ${o.commits} commits`); + } + const staged = await stageHistory(work, o.dest, { themeScript: o.themeScript, stylesheet: o.stylesheet }); + return { ...staged, rendered: cacheFile ? pages - before : pages }; +} + +/** The cache directory under a scratch root. */ +export function historyCacheDir(scratchRoot: string): string { + return path.join(scratchRoot, HISTORY_CACHE_NAME); +} + +/** + * The cache's key: whatever, changed, makes a page rendered before wrong — the + * rules (with the step's version) and filter-repo that made the ids, the + * stagit that wrote the pages, and the header text every page carries. + */ +export function historyCacheKey(parts: { + rulesHash: string; + filterRepo: string; + stagit: string; + description: string; + cloneUrl: string; + baseUrl: string; +}): string { + return createHash("sha256").update(JSON.stringify(parts)).digest("hex"); +}