Archilyzer · Source

archilyzer

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

commit 3bd33837f5e37f17bc661e0e9a7f92ead2e5ef4f
parent 8941c1e696360fd4a10a1dfeb107e8955f518350
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 12:47:14 -0400

common: archilyzer doctor checks the site build image — there, and newer than its Dockerfile

A new "build image" block. When the container engine answers `version` (the
same question Build all asks before choosing containers), the doctor inspects
the image the settings name (buildPipeline.dockerImage — the tag is read, not
spelled again) and reports its age and size. It warns when the image is absent
or was created before its Dockerfile's last change — the file's last commit in
a checkout, its mtime when it has uncommitted edits or there is no checkout —
and prints the one command that rebuilds it: `cd <root> && docker build -f
Dockerfile.build -t <tag> .`. A Dockerfile the settings name that is not there
warns too. No engine: one line saying the check was skipped. Never a failure
(Build all rebuilds the image itself before its fan-out), and only a note while
there is no corpus to build, as for a tool nothing needs yet. DOCKER_BIN names
the engine it asks and the command it prints.

build.ts exports the two pieces the doctor needs so neither has a second copy:
dockerBin(env) and buildImageArgs(pipeline), which ensureBuildImage now runs;
its comment says it runs before every fan-out and what a Dockerfile change
costs there. The git calls run with GIT_OPTIONAL_LOCKS=0, so `git status`
refreshes no index: the doctor stays read-only (a test commits into a temp
checkout and compares .git before and after).

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

Diffstat:
Mcommon/bin/doctor.test.ts | 185++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/bin/doctor.ts | 148++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/publish/build.ts | 29+++++++++++++++++++++++------
3 files changed, 353 insertions(+), 9 deletions(-)

diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts @@ -8,6 +8,7 @@ // wants. The last assertion of each is the one that matters most: the tree is // byte-for-byte and mtime-for-mtime what it was — the doctor wrote nothing. +import { execFileSync } from "node:child_process"; import { test, after } from "node:test"; import assert from "node:assert/strict"; import { @@ -23,7 +24,14 @@ import { import os from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; -import { collectDoctorReport, renderDoctorReport, type DoctorReport } from "./doctor"; +import { + collectDoctorReport, + parseEngineTime, + renderDoctorReport, + type BuildImageProbe, + type DoctorDeps, + type DoctorReport, +} from "./doctor"; const TMP = mkdtempSync(path.join(os.tmpdir(), "doctor-")); after(() => rmSync(TMP, { recursive: true, force: true })); @@ -82,7 +90,14 @@ function tree(root: string): string[] { return out.sort(); } -async function run(c: ReturnType<typeof checkout>, env: NodeJS.ProcessEnv = {}): Promise<DoctorReport> { +// No container engine unless a scenario hands one in: a unit test never asks docker. +const noEngine = async (): Promise<BuildImageProbe> => ({ engine: false }); + +async function run( + c: ReturnType<typeof checkout>, + env: NodeJS.ProcessEnv = {}, + more: Partial<DoctorDeps> = {}, +): Promise<DoctorReport> { return collectDoctorReport({ env: { PATH: c.bin, ...env }, paths: c.paths, @@ -90,6 +105,8 @@ async function run(c: ReturnType<typeof checkout>, env: NodeJS.ProcessEnv = {}): portBlock: async () => null, portInUse: async () => false, umtoolTools: async () => null, + buildImage: noEngine, + ...more, }); } @@ -211,6 +228,7 @@ test("node older than next's floor fails", async () => { portBlock: async () => null, portInUse: async () => false, umtoolTools: async () => null, + buildImage: noEngine, }); assert.equal(status(r, "node"), "fail"); }); @@ -224,8 +242,171 @@ test("umtool's table and the port block are reported, never failed", async () => portBlock: async () => ({ label: "worktree #3 (offset 300)", ports: { EDITOR_PORT: "3301" } }), portInUse: async (p) => p === 3301, umtoolTools: async () => [{ id: "qrencode", bin: "qrencode", neededBy: ["report-video QR"], required: true }], + buildImage: noEngine, }); assert.equal(status(r, "qrencode"), "warn"); assert.match(r.checks.find((x) => x.id === "EDITOR_PORT")!.detail, /^3301 in use/); assert.equal(r.ok, true); }); + +// ── the build image ───────────────────────────────────────────────────────── +// Build all's per-site containers run the image the settings name. The engine +// is always handed in (`buildImage`); only the Dockerfile's date is ever read +// for real, and then from a temp checkout. + +// A corpus whose tools all pass, so every warning below is the image's. +function corpusCheckout(settings: Record<string, unknown> = {}) { + const c = checkout(); + mkdirSync(path.join(c.paths.channelsDir, "chan"), { recursive: true }); + writeFileSync(path.join(c.paths.channelsDir, "chan", "config.json"), "{}"); + writeFileSync(c.paths.lmdbPath, ""); + for (const b of ["yt-dlp", "ffmpeg", "ffprobe"]) fake(c.bin, b); + writeFileSync(c.paths.settingsFile, JSON.stringify({ workers: [], ...settings })); + writeFileSync(path.join(c.root, "Dockerfile.build"), "FROM scratch\n"); + return c; +} + +const imageLine = (r: DoctorReport) => r.checks.find((x) => x.id === "build-image")!; +const JUL = new Date("2026-07-07T16:04:55Z"); +const SEP = new Date("2026-09-28T17:00:00Z"); +const NOW = new Date("2026-09-29T12:00:00Z"); + +test("the build image: no container engine is one line saying the check was skipped", async () => { + const c = corpusCheckout(); + const r = await run(c); + const lines = r.checks.filter((x) => x.section === "build image"); + assert.equal(lines.length, 1, renderDoctorReport(r)); + assert.equal(lines[0].id, "build-image"); + assert.equal(lines[0].status, "info"); + assert.match(lines[0].detail, /^skipped: `docker version` did not answer/); + assert.equal(r.ok, true, renderDoctorReport(r)); +}); + +test("the build image: absent warns beside a corpus, with the configured tag and the one command — never a failure", async () => { + const c = corpusCheckout({ buildPipeline: { dockerImage: "my-build" } }); + const asked: { bin: string; image: string }[] = []; + const before = tree(c.root); + const r = await run(c, {}, { + buildImage: async (q) => { + asked.push(q); + return { engine: true, image: null }; + }, + }); + assert.deepEqual(asked, [{ bin: "docker", image: "my-build" }]); + const line = imageLine(r); + assert.equal(line.status, "warn", renderDoctorReport(r)); + assert.match(line.detail, /^no image "my-build" — the next Build all builds it first/); + assert.ok( + line.detail.includes(`\nrebuild it now: cd ${c.root} && docker build -f Dockerfile.build -t my-build .`), + line.detail, + ); + assert.equal(r.ok, true, renderDoctorReport(r)); + assert.deepEqual(tree(c.root), before); + + // No corpus, no site to build: the same words, as a note. + const bare = checkout(); + const r2 = await run(bare, {}, { buildImage: async () => ({ engine: true, image: null }) }); + assert.equal(status(r2, "build-image"), "info"); + assert.equal(r2.ok, true); +}); + +test("the build image: older than its Dockerfile's last change warns; newer is ok, with its age", async () => { + const c = corpusCheckout(); + const changed = async () => ({ at: SEP, source: "commit" as const }); + let r = await run(c, {}, { + buildImage: async () => ({ engine: true, image: { created: JUL, sizeBytes: 7_720_000_000 } }), + dockerfileChanged: changed, + now: NOW, + }); + let line = imageLine(r); + assert.equal(line.status, "warn", renderDoctorReport(r)); + assert.match( + line.detail, + /^"yt-dlp-transcript-browser-build" built 2026-07-07 16:04 \(84 days ago\), 7\.72 GB — before Dockerfile\.build's last change \(2026-09-28 17:00, its last commit\)/, + ); + assert.match(line.detail, /\nrebuild it now: cd \S+ && docker build -f Dockerfile\.build -t yt-dlp-transcript-browser-build \.$/); + assert.equal(r.ok, true); + + r = await run(c, {}, { + buildImage: async () => ({ + engine: true, + image: { created: new Date("2026-09-28T18:00:00Z"), sizeBytes: 1_673_611_166 }, + }), + dockerfileChanged: changed, + now: NOW, + }); + line = imageLine(r); + assert.equal(line.status, "ok", renderDoctorReport(r)); + assert.match(line.detail, /built 2026-09-28 18:00 \(18 hours ago\), 1\.67 GB, after Dockerfile\.build's last change/); + assert.doesNotMatch(line.detail, /rebuild/); +}); + +test("the build image: DOCKER_BIN names the engine it asks and the command it prints", async () => { + const c = corpusCheckout(); + const asked: string[] = []; + const r = await run(c, { DOCKER_BIN: "podman" }, { + buildImage: async (q) => { + asked.push(q.bin); + return { engine: true, image: null }; + }, + }); + assert.deepEqual(asked, ["podman"]); + assert.match(imageLine(r).detail, /&& podman build -f Dockerfile\.build -t yt-dlp-transcript-browser-build \.$/); +}); + +test("the build image: a Dockerfile the settings name that is not there warns", async () => { + const c = corpusCheckout({ buildPipeline: { dockerfile: "docker/nope.Dockerfile" } }); + const r = await run(c, {}, { + buildImage: async () => ({ engine: true, image: { created: JUL, sizeBytes: null } }), + now: NOW, + }); + assert.equal(status(r, "dockerfile"), "warn", renderDoctorReport(r)); + assert.match(r.checks.find((x) => x.id === "dockerfile")!.detail, /^docker\/nope\.Dockerfile is not at /); + assert.equal(status(r, "build-image"), "ok"); // here, and nothing to date it against +}); + +test("the build image's Dockerfile dates from its last commit, or its mtime once edited — and git writes nothing", async () => { + const git = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim(); + const c = corpusCheckout(); + symlinkSync(git, path.join(c.bin, "git")); + const at = "2026-08-01T00:00:00Z"; + const g = (...args: string[]) => + execFileSync(git, args, { + cwd: c.root, + env: { + ...process.env, + GIT_AUTHOR_DATE: at, + GIT_COMMITTER_DATE: at, + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@example.invalid", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@example.invalid", + }, + }); + g("init", "-q"); + g("add", "Dockerfile.build"); + g("commit", "-q", "-m", "the build image"); + const image = async (): Promise<BuildImageProbe> => ({ + engine: true, + image: { created: new Date("2026-09-01T00:00:00Z"), sizeBytes: null }, + }); + const before = tree(c.root); + let r = await run(c, {}, { buildImage: image, now: NOW }); + assert.equal(imageLine(r).status, "ok", renderDoctorReport(r)); + assert.match(imageLine(r).detail, /Dockerfile\.build's last change \(2026-08-01 00:00, its last commit\)/); + // .git included: `git status` refreshed no index (GIT_OPTIONAL_LOCKS=0). + assert.deepEqual(tree(c.root), before); + + // An uncommitted edit is newer than any commit: its mtime (now) decides. + writeFileSync(path.join(c.root, "Dockerfile.build"), "FROM scratch\nRUN true\n"); + r = await run(c, {}, { buildImage: image, now: NOW }); + assert.equal(imageLine(r).status, "warn", renderDoctorReport(r)); + assert.match(imageLine(r).detail, /its mtime: uncommitted or no checkout\)/); +}); + +test("an engine's image creation time parses in docker's and podman's spelling", () => { + assert.equal(parseEngineTime("2026-07-07T12:04:55.302907312-04:00")?.toISOString(), "2026-07-07T16:04:55.302Z"); + assert.equal(parseEngineTime("2026-07-07 12:04:55.302907312 -0400 EDT")?.toISOString(), "2026-07-07T16:04:55.302Z"); + assert.equal(parseEngineTime("2026-09-28T16:35:39Z")?.toISOString(), "2026-09-28T16:35:39.000Z"); + assert.equal(parseEngineTime("<no value>"), null); +}); diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts @@ -5,7 +5,11 @@ // this reads and whose probe it shares — lib/toolProbe.mjs) and the port block // (scripts/worktree.mjs over lib/ports.mjs). // -// STRICTLY READ-ONLY. It stats, reads and runs version flags. It never opens +// It also asks the container engine whether Build all's site build image is +// there and older than its Dockerfile (common/publish/build.ts). +// +// STRICTLY READ-ONLY. It stats, reads and runs version flags, plus the engine's +// `image inspect` and a lock-free `git status` / `git log`. It never opens // LMDB (the index is stat'd, not opened), never mkdirs, never writes settings, // and never binds a port (a port is "in use" when a TCP connect succeeds). The // one process-state change is a chdir around umtool's table, which resolves a @@ -70,8 +74,22 @@ export type DoctorDeps = { portBlock?: () => Promise<{ label: string; ports: Record<string, string> } | null>; // umtool's report-pipeline table, or null when there is no umtool here. umtoolTools?: () => Promise<ToolSpec[] | null>; + // The container engine and the site build image. Default: `<bin> version`, + // then `<bin> image inspect <image>` — both read-only. + buildImage?: (q: { bin: string; image: string }) => Promise<BuildImageProbe>; + // When the build image's Dockerfile last changed, or null when it is not + // there. Default: its last commit in a checkout, its mtime when it has + // uncommitted changes or there is no checkout. + dockerfileChanged?: (file: string) => Promise<DockerfileChange | null>; + now?: Date; }; +export type BuildImageProbe = + | { engine: false } + | { engine: true; image: { created: Date | null; sizeBytes: number | null } | null }; + +export type DockerfileChange = { at: Date; source: "commit" | "mtime" }; + const MIN_NODE = [20, 9, 0] as const; // next 16's engines field export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorReport> { @@ -254,6 +272,49 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor } } + // ── build image ────────────────────────────────────────────────────────── + // Build all builds every site in a container whenever the engine answers + // `version`, from the image the settings name (common/publish/build.ts). Never + // a failure — Build all (re)builds the image itself before its fan-out — and, + // like a tool nothing needs yet, only a note while there is no corpus to build. + const B = "build image"; + { + const { buildImageArgs, dockerBin } = await import("../publish/build"); + const { defaultBuildPipeline } = await import("../lib/settingsSchema"); + const pipeline = settings?.buildPipeline ?? defaultBuildPipeline(); + const bin = dockerBin(env); + const probed = await (deps.buildImage ?? ((q) => probeBuildImage(q, env)))({ bin, image: pipeline.dockerImage }); + if (!probed.engine) { + add(B, "build-image", "info", + `skipped: \`${bin} version\` did not answer (DOCKER_BIN names the engine) — Build all builds sites one at a time on the host`); + } else { + const grade: CheckStatus = hasCorpus ? "warn" : "info"; + const now = deps.now ?? new Date(); + const file = path.resolve(root, pipeline.dockerfile); + const changed = await (deps.dockerfileChanged ?? ((f) => dockerfileChangedAt(f, root, env)))(file); + const rebuild = `rebuild it now: cd ${shellQuote(root)} && ${[bin, ...buildImageArgs(pipeline)].map(shellQuote).join(" ")}`; + const img = probed.image; + if (!changed) { + add(B, "dockerfile", grade, `${pipeline.dockerfile} is not at ${file} — Build all's image build will fail (Settings → Build pipeline names it)`); + } + if (!img) { + add(B, "build-image", grade, + `no image "${pipeline.dockerImage}" — the next Build all builds it first, installing every dependency\n${rebuild}`); + } else if (!img.created) { + add(B, "build-image", grade, `"${pipeline.dockerImage}" is here but its creation time did not parse\n${rebuild}`); + } else { + const built = `"${pipeline.dockerImage}" built ${stamp(img.created)} (${ago(img.created, now)})${img.sizeBytes ? `, ${gigabytes(img.sizeBytes)}` : ""}`; + const last = changed ? `${pipeline.dockerfile}'s last change (${stamp(changed.at)}, ${changed.source === "commit" ? "its last commit" : "its mtime: uncommitted or no checkout"})` : ""; + if (changed && img.created < changed.at) { + add(B, "build-image", grade, + `${built} — before ${last}, so the next Build all rebuilds it from the changed step on\n${rebuild}`); + } else { + add(B, "build-image", "ok", changed ? `${built}, after ${last}` : built); + } + } + } + } + // ── ports ──────────────────────────────────────────────────────────────── const P = "ports"; const block = await (deps.portBlock ?? (() => worktreePortBlock(root)))(); @@ -396,6 +457,91 @@ async function worktreePortBlock( } } +// The engine answers `version` (what Build all asks before choosing containers), +// then the image is inspected. Both only read the daemon's state. +async function probeBuildImage( + q: { bin: string; image: string }, + env: NodeJS.ProcessEnv, +): Promise<BuildImageProbe> { + try { + await execFileP(q.bin, ["version"], { env, timeout: 15_000 }); + } catch { + return { engine: false }; + } + try { + const { stdout } = await execFileP(q.bin, ["image", "inspect", "--format", "{{.Created}}|{{.Size}}", q.image], { + env, + timeout: 15_000, + }); + const [created = "", size = ""] = stdout.trim().split("|"); + const bytes = Number.parseInt(size, 10); + return { engine: true, image: { created: parseEngineTime(created), sizeBytes: Number.isFinite(bytes) ? bytes : null } }; + } catch { + return { engine: true, image: null }; + } +} + +// An image's creation time as an engine prints it: docker's RFC 3339 with +// nanoseconds (2026-07-07T12:04:55.302907312-04:00), or podman's Go default +// (2026-07-07 12:04:55.302907312 -0400 EDT). V8 happens to read both, through +// its implementation-defined fallback; normalized here to the ISO form the +// spec defines, so the answer does not rest on that. +export function parseEngineTime(s: string): Date | null { + const m = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.(\d+))?\s*(Z|[+-]\d{2}:?\d{2})?/.exec(s.trim()); + if (!m) return null; + const frac = (m[3] ?? "").slice(0, 3).padEnd(3, "0"); + const tz = (m[4] ?? "Z").replace(/^([+-]\d{2})(\d{2})$/, "$1:$2"); + const d = new Date(`${m[1]}T${m[2]}.${frac}${tz}`); + return Number.isNaN(d.getTime()) ? null : d; +} + +// When the Dockerfile last changed. In a checkout that is its last commit — a +// clone's mtimes are the clone's — unless the file has uncommitted changes, when +// the file on disk is newer than any commit and its mtime is the answer. No +// checkout (or no git): the mtime. GIT_OPTIONAL_LOCKS=0 keeps `git status` from +// refreshing the index, which would be a write. +async function dockerfileChangedAt( + file: string, + root: string, + env: NodeJS.ProcessEnv, +): Promise<DockerfileChange | null> { + const st = statOrNull(file); + if (!st) return null; + const byMtime: DockerfileChange = { at: st.mtime, source: "mtime" }; + const opts = { cwd: root, env: { ...env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000 }; + const rel = path.relative(root, file); + try { + const { stdout: dirty } = await execFileP("git", ["status", "--porcelain", "--", rel], opts); + if (dirty.trim() !== "") return byMtime; + const { stdout } = await execFileP("git", ["log", "-1", "--format=%ct", "--", rel], opts); + const secs = Number.parseInt(stdout.trim(), 10); + return Number.isFinite(secs) ? { at: new Date(secs * 1000), source: "commit" } : byMtime; + } catch { + return byMtime; + } +} + +function shellQuote(s: string): string { + return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`; +} + +// The same minute-precision UTC stamp the index line uses. +function stamp(d: Date): string { + return d.toISOString().slice(0, 16).replace("T", " "); +} + +function ago(then: Date, now: Date): string { + const mins = Math.max(0, Math.round((now.getTime() - then.getTime()) / 60_000)); + if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`; + const hours = Math.round(mins / 60); + if (hours < 48) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + return `${Math.round(hours / 24)} days ago`; +} + +function gigabytes(bytes: number): string { + return bytes >= 1e9 ? `${(bytes / 1e9).toFixed(2)} GB` : `${Math.round(bytes / 1e6)} MB`; +} + // In use = something accepts a TCP connection on 127.0.0.1. Never binds. function tcpPortInUse(port: number): Promise<boolean> { return new Promise((resolve) => { diff --git a/common/publish/build.ts b/common/publish/build.ts @@ -388,8 +388,19 @@ export async function runDeployIntoLog( // The container engine binary. Defaults to `docker`; podman is a CLI drop-in // (and rootless podman yields host-owned outputs without needing `-u`). -function dockerBin(): string { - return process.env.DOCKER_BIN?.trim() || "docker"; +// `archilyzer doctor` asks the same engine, from the environment it was given. +export function dockerBin(env: NodeJS.ProcessEnv = process.env): string { + return env.DOCKER_BIN?.trim() || "docker"; +} + +// The build image's `docker build` argv, run from the monorepo root: the one +// spelling of it. ensureBuildImage runs it; `archilyzer doctor` prints it as the +// command that rebuilds an absent or stale image. +export function buildImageArgs(pipeline: { + dockerImage: string; + dockerfile: string; +}): string[] { + return ["build", "-f", pipeline.dockerfile, "-t", pipeline.dockerImage, "."]; } export type SiteBuildOutcome = { siteId: string; code: number }; @@ -434,17 +445,23 @@ async function runHostScript( }); } -// Build (or reuse cached layers of) the per-site build image. +// Build (or reuse cached layers of) the per-site build image. Runs before every +// Phase B, so a fan-out never meets an image older than the checkout: a code +// change re-runs only `COPY . .` onward, but a Dockerfile or lockfile change +// re-installs every dependency first, inside this job. `archilyzer doctor` +// warns ahead of that when the image is absent or older than the Dockerfile. async function ensureBuildImage( onLog: (line: string) => void, signal: AbortSignal, paths: Paths, ): Promise<number> { - const { dockerImage, dockerfile } = getSettings().buildPipeline; - onLog(`[docker] building image "${dockerImage}" from ${dockerfile} (cached layers reused)`); + const pipeline = getSettings().buildPipeline; + onLog( + `[docker] building image "${pipeline.dockerImage}" from ${pipeline.dockerfile} (cached layers reused)`, + ); return runChildIntoLog(onLog, signal, { command: dockerBin(), - args: ["build", "-f", dockerfile, "-t", dockerImage, "."], + args: buildImageArgs(pipeline), cwd: paths.monorepoRoot, }); }