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:
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.