Archilyzer · Source

archilyzer

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

commit 2fea61e240b83155fdd9498fd12b77c58eaba5c8
parent bb7b28e3c2ee72794ef5be030e15c905b835b1cd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 13:13:28 -0400

common: builtBundleProblem — a per-site bundle is the site's own only when site.json and corpus.json both name it

Stricter than builtSiteProblem, for the bundle a container build hands back:
both identity files compose writes must be there and both must name the site.
A container build once published the public/ baked into its image instead of
the composed one, so its out/ carried no data, or another site's. The check
names the directory and which file disagreed. 3 tests.

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

Diffstat:
Mcommon/lib/builtExport.test.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/builtExport.ts | 42++++++++++++++++++++++++++++++++++++++++++
2 files changed, 92 insertions(+), 0 deletions(-)

diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node: import { tmpdir } from "node:os"; import path from "node:path"; import { + builtBundleProblem, builtHomepageAt, builtHomepageProblem, builtHubProblem, @@ -160,3 +161,52 @@ test("builtHomepageAt is index.html's mtime, and null with no build", () => { empty.cleanup(); } }); + +// A per-site container build publishes its own out/ (docker/build-site.sh). It +// once published the public/ baked into the image instead of the one compose +// had just written — so out/ carried no data, or another site's. Both identity +// files must be there and both must name the site. +function bundle(files: { site?: unknown; corpus?: unknown }): { dir: string; cleanup: () => void } { + const t = tempOut(files.site === undefined ? undefined : JSON.stringify(files.site)); + if (files.corpus !== undefined) { + writeFileSync(path.join(t.dir, "corpus.json"), JSON.stringify(files.corpus)); + } + return t; +} + +test("builtBundleProblem passes a bundle whose site.json and corpus.json both name the site", () => { + const t = bundle({ site: { siteId: "anilyzer" }, corpus: { spec: 4, kind: "site", site: { id: "anilyzer" } } }); + try { + assert.equal(builtBundleProblem(t.dir, "anilyzer"), null); + assert.equal(builtBundleProblem(t.dir, " anilyzer "), null); + } finally { + t.cleanup(); + } +}); + +test("builtBundleProblem refuses another site's bundle, by either file", () => { + const other = bundle({ site: { siteId: "jeralyzer" }, corpus: { site: { id: "jeralyzer" } } }); + const torn = bundle({ site: { siteId: "anilyzer" }, corpus: { site: { id: "jeralyzer" } } }); + try { + assert.match(builtBundleProblem(other.dir, "anilyzer")!, /holds a build of "jeralyzer", not "anilyzer" \(site\.json\)$/); + assert.match(builtBundleProblem(torn.dir, "anilyzer")!, /describes "jeralyzer", not "anilyzer" \(corpus\.json\)$/); + } finally { + other.cleanup(); + torn.cleanup(); + } +}); + +test("builtBundleProblem refuses a bundle missing either identity file, naming the directory", () => { + const none = bundle({}); + const noCorpus = bundle({ site: { siteId: "anilyzer" } }); + const unnamed = bundle({ site: { siteId: "anilyzer" }, corpus: { site: {} } }); + try { + assert.equal(builtBundleProblem(none.dir, "anilyzer"), `${none.dir} has no site.json naming a site — it is not a build of "anilyzer"`); + assert.match(builtBundleProblem(noCorpus.dir, "anilyzer")!, /has no corpus\.json naming a site/); + assert.match(builtBundleProblem(unnamed.dir, "anilyzer")!, /has no corpus\.json naming a site/); + } finally { + none.cleanup(); + noCorpus.cleanup(); + unnamed.cleanup(); + } +}); diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts @@ -60,6 +60,48 @@ export function builtSiteProblem(outDir: string, siteId: string): string | null } /** + * Why the bundle in a per-site container's `outDir` is not `siteId`'s own, as + * one sentence naming the directory — or null when it is. + * + * Stricter than builtSiteProblem: both identity files compose writes must be + * there and both must name the site — `site.json`'s `siteId` and + * `corpus.json`'s `site.id`. A container build once published the public/ + * baked into its image instead of the one compose had just written, so its + * out/ carried whatever the image's build context held: no data at all, or a + * DIFFERENT site's. docker/build-site.sh refuses to hand back such an out/, + * and the container deploy phase refuses to ship one (publish/build.ts). + */ +export function builtBundleProblem(outDir: string, siteId: string): string | null { + const asked = siteId.trim(); + const built = builtSiteIdIn(outDir); + if (built === null) { + return `${outDir} has no site.json naming a site — it is not a build of "${asked}"`; + } + if (built !== asked) { + return `${outDir} holds a build of "${built}", not "${asked}" (site.json)`; + } + const described = corpusSiteIdIn(outDir); + if (described === null) { + return `${outDir} has no corpus.json naming a site — it is not a complete build of "${asked}"`; + } + if (described !== asked) { + return `${outDir} describes "${described}", not "${asked}" (corpus.json)`; + } + return null; +} + +// corpus.json's `site.id`, or null when there is no readable one. +function corpusSiteIdIn(outDir: string): string | null { + try { + const parsed: unknown = JSON.parse(readFileSync(path.join(outDir, "corpus.json"), "utf8")); + const site = (parsed as { site?: { id?: unknown } } | null)?.site; + return typeof site?.id === "string" && site.id.trim() ? site.id.trim() : null; + } catch { + return null; + } +} + +/** * Why `outDir` may not be deployed as the HUB, as one sentence — or null when * it holds a hub build. *