Archilyzer · Source

archilyzer

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

commit 6e395f005c779f4516f23ae0da98f86f6b1fb579
parent ca5bbaa634a3bde79587e24ef242b63ea4cde67c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 10:03:15 -0400

common: a hub bundle is refused for report DATA, not for the export app's own reports/ and m/ routes

HUB_FORBIDDEN_TREES listed `reports` and `m`, but the export app renders
its report and moment routes into every build (reports/index.html, the
`_none` placeholders), so every hub bundle was refused. They are off the
list; hubReportDataIn refuses what a site's reports stage writes there
instead — reports/index.json, m/index.json, reports/<id>/page.json, its
citations, exports and history (history.json, history/repo/), and any
m/**/moment.json — names taken from lib/report/views.ts. `media` stays; a
tombstone-only posts/ still passes, a real post does not.

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

Diffstat:
Mcommon/lib/builtExport.test.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/builtExport.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 136 insertions(+), 4 deletions(-)

diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts @@ -19,6 +19,7 @@ import { publishFileSizeProblem, reportHistoryProblem, isTombstonePostsTree, + hubReportDataIn, } from "./builtExport"; function tempOut(siteJson?: string): { dir: string; cleanup: () => void } { @@ -162,6 +163,70 @@ test("builtHubProblem accepts only a hub bundle", () => { } }); +// Found on main 2026-10-05: the export app renders its report and moment +// routes into EVERY build — `reports/index.html`, the `_none` placeholders — +// so a hub bundle always has `reports/` and `m/`, and listing them as site +// data refused every hub. What is refused now is the report DATA a site's +// compose writes there. +test("builtHubProblem: the shell's reports/ and m/ pages pass; report data, moment data and a real post do not", () => { + const hub = tempOut(); + try { + writeFileSync(path.join(hub.dir, "hub-sites.json"), "[]"); + const put = (rel: string, body = "x") => { + mkdirSync(path.dirname(path.join(hub.dir, rel)), { recursive: true }); + writeFileSync(path.join(hub.dir, rel), body); + }; + // What `next build` renders for the export app's own routes. + for (const rel of [ + "reports/index.html", + "reports/index.txt", + "reports/_none/index.html", + "reports/_none/history/index.html", + "m/_none/index.html", + "m/_none/index.txt", + ]) { + put(rel); + } + assert.equal(hubReportDataIn(hub.dir).length, 0); + assert.equal(builtHubProblem(hub.dir), null); + + const refusedFor = (rel: string, body?: string) => { + put(rel, body); + const problem = builtHubProblem(hub.dir); + rmSync(path.join(hub.dir, rel)); + return problem; + }; + for (const rel of [ + "reports/index.json", + "reports/r1/page.json", + "reports/r1/citations.json", + "reports/r1/citations.csv", + "reports/r1/evidence-pack.zip", + "reports/r1/history/history.json", + "reports/r1/history/repo/HEAD", + "m/index.json", + "m/jer/v1/0-10/moment.json", + ]) { + const problem = refusedFor(rel); + assert.match(problem ?? "", /still carries a site's data/, rel); + assert.ok(problem!.includes(rel.endsWith("/HEAD") ? "reports/r1/history/repo/" : rel), `${rel}: ${problem}`); + } + // (The history clone is a directory: removing its HEAD leaves repo/.) + rmSync(path.join(hub.dir, "reports", "r1"), { recursive: true }); + assert.equal(builtHubProblem(hub.dir), null, "nothing left behind"); + + // A tombstone-only posts/ is the hub's own; one real post is a site's. + put("posts/manifest.json", '{"channels":[]}'); + put("posts/jer-x/manifest.json", '{"pageCount":0,"slugToPage":{}}'); + put("posts/jer-x/page-0000.json", "[]"); + assert.equal(builtHubProblem(hub.dir), null); + put("posts/jer-x/page-0000.json", '[{"id":"1","text":"a post"}]'); + assert.match(builtHubProblem(hub.dir) ?? "", /still carries a site's data \(posts\)/); + } finally { + hub.cleanup(); + } +}); + // The homepage package builds into its own homepage/out, which nothing else // writes, so "built" is one question: is there an index.html? The deploy-only // action refuses before any job on exactly the file deployHomepage checks diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts @@ -17,6 +17,14 @@ import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "no import path from "node:path"; import { isCitedSite } from "./siteSchema"; import { REPORT_HISTORY_REPO_FILE_RE } from "./report/revisions"; +import { + MOMENTS_INDEX_PATH, + REPORTS_INDEX_PATH, + REPORT_EXPORT_FILENAMES, + momentViewPath, + reportCitationsDownloadPath, + reportViewPath, +} from "./report/views"; /** * The site id of the build sitting in `outDir`, or null when there is no @@ -424,11 +432,16 @@ export function builtHubProblem(outDir: string): string | null { // that site's posts — a private site's included. Its one posts tree is the // tombstones compose-hub writes for withdrawn X posts (release 18), and a // tree holding anything but tombstones is a site's. - const carried = HUB_FORBIDDEN_TREES.filter( + const carried: string[] = HUB_FORBIDDEN_TREES.filter( (tree) => existsSync(path.join(outDir, tree)) && !(tree === "posts" && isTombstonePostsTree(path.join(outDir, tree))), ); + // reports/ and m/ are the export app's own routes, rendered into EVERY + // build (the hub's included: /reports/, /reports/_none/, /m/_none/) — their + // shell pages are not data. What a site's reports stage writes is. + const reportData = hubReportDataIn(outDir); + if (reportData.length > 0) carried.push(...reportData); if (carried.length > 0) { return ( `export/out holds a hub build that still carries a site's data (${carried.join(", ")}) — ` + @@ -489,7 +502,11 @@ export function isTombstonePostsTree(postsDir: string): boolean { // The per-site data trees a hub bundle must never carry (the trees of // compose-hub's SITE_ONLY_PUBLIC_ENTRIES). A `posts/` of tombstones only is -// the hub's own (isTombstonePostsTree). +// the hub's own (isTombstonePostsTree). `reports/` and `m/` are NOT here: the +// export app renders its report and moment routes into every build, the hub's +// too (`reports/index.html`, the `_none` placeholders), so their presence says +// nothing — hubReportDataIn looks for the report DATA inside them instead. +// (Listing them refused every hub bundle from 2026-10-05.) const HUB_FORBIDDEN_TREES = [ "summaries", "transcripts", @@ -498,11 +515,61 @@ const HUB_FORBIDDEN_TREES = [ "digests", "stats", "archives", - "reports", - "m", "media", ]; +// The files only a site's reports stage writes (lib/report/views.ts names +// them; publish/composeReports.ts writes them), by name within reports/<id>/ +// and m/…: the report index, each report's page view, its citations, its +// exports and its revision history; the moment index and each moment view. +const REPORT_DATA_FILES = new Set<string>([ + path.posix.basename(reportViewPath("x")), + path.posix.basename(reportCitationsDownloadPath("x", "json")), + path.posix.basename(reportCitationsDownloadPath("x", "csv")), + ...Object.values(REPORT_EXPORT_FILENAMES), + // reportHistory.ts: `history/history.json` beside a report's page. + "history.json", +]); +const MOMENT_DATA_FILE = path.posix.basename(momentViewPath("x")); + +/** + * The report and moment DATA in a bundle's `reports/` and `m/` (root-relative + * paths, at most a few): what a site's compose writes there, never what the + * export app's own route pages are. Empty for a hub bundle, which carries the + * shell pages only. + */ +export function hubReportDataIn(outDir: string, limit = 5): string[] { + const found: string[] = []; + for (const index of [REPORTS_INDEX_PATH, MOMENTS_INDEX_PATH]) { + if (existsSync(path.join(outDir, index))) found.push(index.replace(/^\//, "")); + } + const walk = (rel: string, isData: (name: string, depth: number) => boolean, depth: number) => { + let entries: Dirent[]; + try { + entries = readdirSync(path.join(outDir, rel), { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (found.length >= limit) return; + const child = `${rel}/${e.name}`; + if (e.isDirectory()) { + // A report's revision history clone (history/repo/) is data whatever it holds. + if (rel.startsWith("reports/") && e.name === "repo" && rel.endsWith("/history")) { + found.push(`${child}/`); + continue; + } + walk(child, isData, depth + 1); + } else if (isData(e.name, depth)) { + found.push(child); + } + } + }; + walk("reports", (name, depth) => depth >= 1 && REPORT_DATA_FILES.has(name), 0); + walk("m", (name) => name === MOMENT_DATA_FILE, 0); + return found.slice(0, limit); +} + /** * Why `outDir` — the homepage package's `homepage/out` — may not be deployed as * the homepage, as one sentence, or null when it holds a build.