Archilyzer · Source

archilyzer

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

commit b4a06f8c37128d6ca8657454b74fb884ee98e7d2
parent a37d3329a0342ef80938fdd45ea68d798f2648e8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 17:39:23 -0400

common: compose never ships a posts tree the index build withheld, and trusts its cache only over its own site's last compose

- The posts tree is reconciled over the members the site's posts manifest
  lists (the index build's word), so a channel config compose cannot read is
  not read as visible.
- The per-site compose cache is trusted only when public/site.json names this
  site (public/ is shared by every site in the basic build): after another
  site's compose, a skipped stage shipped that site's summaries under this
  site's name. site.json is cleared at the start of a compose and written at
  its end.

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

Diffstat:
Mcommon/bin/compose-site.postsVisibility.test.ts | 32++++++++++++++++++++++++++++++--
Mcommon/bin/compose-site.ts | 46++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 74 insertions(+), 4 deletions(-)

diff --git a/common/bin/compose-site.postsVisibility.test.ts b/common/bin/compose-site.postsVisibility.test.ts @@ -241,12 +241,40 @@ test("X private: a public site carries no X channel; a private site carries all /^Site "priv" is private \(audience: private\)/, ); - // Compose the public site again over the private one's public/: the X - // channel it carried is pruned from every tree. + // Compose the public site again over the private one's public/, with + // nothing changed since its last compose: the X channel the private site + // carried is pruned from every tree, and the summaries are the public + // site's again — its compose cache is not trusted over another site's + // compose (the summaries used to be skipped as "unchanged" and shipped the + // private site's channel list). const again = await compose("pub"); assert.deepEqual(again.postTrees, [SKY]); assert.deepEqual(again.transcriptTrees, [VIDEOS, SKY]); + assert.deepEqual(slugs(again.siteJson.channels), [SKY, VIDEOS]); + assert.deepEqual(slugs(again.corpus.channels), [SKY, VIDEOS]); assert.equal(again.corpus.site.audience, undefined); + // And over its own last compose the cache is trusted as before. + const third = await compose("pub"); + assert.deepEqual(slugs(third.corpus.channels), [SKY, VIDEOS]); +}); + +test("a config compose cannot read does not ship the posts tree the index build withheld", async () => { + // The index build (X private) withheld X from pub's posts manifest; compose + // then fails to read X's config, so its own rule reads X as visible. The + // site posts manifest is the index build's word: no X posts tree ships. + writeSettings("private"); + await index(); + const cfg = path.join(paths.channelsDir, X, "config.json"); + const saved = readFileSync(cfg, "utf8"); + rmSync(cfg); + try { + const pub = await compose("pub"); + assert.deepEqual(pub.postTrees, [SKY]); + assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]); + assert.deepEqual(slugs(pub.corpus.channels), [SKY, VIDEOS]); + } finally { + writeFileSync(cfg, saved); + } }); test("X public again: the next build puts X back on the public site", async () => { diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts @@ -61,6 +61,7 @@ import { type ArchiveManifestEntry, } from "../lib/archiveOptions"; import { readChannelConfig } from "../controller/channels"; +import { builtSiteIdIn } from "../lib/builtExport"; import { publishedMemberSlugs } from "../lib/postsVisibility"; import { isPrivateSite } from "../lib/siteSchema"; import { runIfEntryPoint } from "./_cli"; @@ -494,6 +495,17 @@ async function replaceDir(src: string, dest: string): Promise<void> { } } +// The channel slugs a site posts manifest lists, or null when there is no +// readable manifest. +async function readPostsManifestSlugs(file: string): Promise<Set<string> | null> { + try { + const pm = JSON.parse(await readFile(file, "utf8")) as PostsManifest; + return new Set((pm.channels ?? []).map((c) => c.slug)); + } catch { + return null; + } +} + // --- Incremental compose cache ------------------------------------------------ // Per-site record of what we last materialized into public/, keyed by a cheap // content signature of each source. When the signature is unchanged and the @@ -698,8 +710,29 @@ export async function main( } // Incremental compose: skip stages whose source is unchanged since last build. + // + // ONLY OVER THIS SITE'S OWN LAST COMPOSE. The cache is per site but public/ + // is one directory every site composes into in turn (the basic build), so + // after another site's compose a skipped stage would ship THAT site's files — + // its summaries, its whole channel list — under this site's name: composing + // a private site and then a public one shipped the private site's summaries + // as the public site's. public/site.json names the site composed into it + // last (it is written below, every compose); another name, or none, and the + // cache is not trusted. const cachePath = composeCachePath(paths, siteId); - const cache = await readComposeCache(cachePath); + const lastComposed = builtSiteIdIn(paths.exportPublicDir); + const cache: ComposeCache = + lastComposed === siteId + ? await readComposeCache(cachePath) + : { transcripts: {}, subs: {}, posts: {}, digests: {} }; + if (lastComposed !== siteId && lastComposed !== null) { + console.log( + `[compose] public/ was last composed for "${lastComposed}": composing every stage for "${siteId}".`, + ); + } + // Until this compose writes its own, public/ names no site: a compose cut + // short part-way leaves the next one nothing to trust. + await rm(path.join(paths.exportPublicDir, "site.json"), { force: true }); // --- per-site aggregates (whole-dir swaps), gated on the source signature --- const summariesSrc = path.join(paths.exportSitesIndexDir, siteId, "summaries"); @@ -743,11 +776,20 @@ export async function main( // The social-post corpus: same shared-tree shape, same incremental reconcile. // Only social member channels have a source dir; reconcileChannelTree treats a // missing one as "nothing to copy", so passing every member slug is correct. + // + // NEVER A TREE THE SITE'S POSTS MANIFEST DOES NOT LIST. The index build wrote + // that manifest by the same visibility rule as memberSlugs above, so the two + // agree — unless a channel's config could not be read here (it then reads as + // visible): the manifest is the index build's word, and a tree it withheld is + // not shipped on a failed read. A site with no manifest yet keeps the old rule. + const postsListed = await readPostsManifestSlugs( + path.join(paths.exportSitesIndexDir, siteId, "posts", "manifest.json"), + ); cache.posts = await reconcileChannelTree( "posts", paths.exportSharedPostsDir, paths.exportPostsDir, - memberSlugs, + postsListed ? memberSlugs.filter((slug) => postsListed.has(slug)) : memberSlugs, cache.posts ?? {}, console.log, );