From dca7d83b5301dc571c5426fc15d9415178cbc3b6 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:54:48 +0000 Subject: [PATCH] Freshness covers every bundle a landing can have staled; SRI repairs keep the gallery's manifest copy Two gaps in the build-owner tooling, found by rega while landing 13f5f3a3: - check:composite-freshness judged only COMPOSITES. A library change (bd75f609's Arabic catalogue: library/i18n eager-loads every locale into every bundle that uses it) left 12 of 25 bundles stale on main, most of them not composites; my consumer rebuild d1af039a saw none of it. The check takes --all and --bundles a,b; the landing precondition scopes it by what the landing changed (shared code -> every bundle; otherwise composites + the bundles whose own folder changed); the build-owner landing always uses --all. Proven: at d1af039a it names prayer-walk-planner STALE (0b695f70 vs 0042dda2, rega's numbers); on main 13f5f3a3 --all finds the 12. - fix:sri repaired manifest.json but not the Research Gallery's byte-identical copy (doxa-maps-spa/manifest.json, read first on a foreign origin), so an embedded gallery refused a rebuilt bundle; it now rewrites the copy too (tested: a changed bundle -> both repaired, identical, check:sri PASS). And generate-tree now writes the front page's integrity itself, so `bun run tree` after fix:sri no longer strips it (tree alone: 115 agreed, 0 added). --- internal/check-composite-freshness.mjs | 12 +++++++++--- internal/fix-sri.mjs | 10 ++++++++++ internal/generate-tree.mjs | 8 +++++--- internal/landing-preconditions.mjs | 12 +++++++++++- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/internal/check-composite-freshness.mjs b/internal/check-composite-freshness.mjs index f618ec3f0..378f3670b 100644 --- a/internal/check-composite-freshness.mjs +++ b/internal/check-composite-freshness.mjs @@ -49,6 +49,12 @@ const TREE = join(ROOT, 'public', TREE_NAME) const argv = process.argv.slice(2) const oi = argv.indexOf('--only') const ONLY = oi >= 0 && argv[oi + 1] ? new Set(argv[oi + 1].split(',')) : null +// NOT ONLY COMPOSITES (rega, 2026-09-25): a library change merged into main reached prayer-walk-planner, +// which composes nothing, so this gate could not see it was stale. --all checks EVERY bundle; --bundles a,b +// checks those (the landing precondition passes the ones a landing can have changed). +const ALL = argv.includes('--all') +const bi = argv.indexOf('--bundles') +const EXTRA = bi >= 0 && argv[bi + 1] ? new Set(argv[bi + 1].split(',').filter(Boolean)) : new Set() if (!existsSync(join(TREE, 'manifest.json'))) { console.log(`COMPOSITE-FRESHNESS: SKIP — no built tree (public/${TREE_NAME}/manifest.json).`) @@ -78,8 +84,8 @@ try { for (const f of readdirSync(join(DESIGNS, bundle)).filter((f) => f.endsWith('.meta.json'))) { try { const c = JSON.parse(readFileSync(join(DESIGNS, bundle, f), 'utf8')).composes; if (Array.isArray(c) && c.length) composes = (composes || []).concat(c) } catch { /* not JSON: not a declaration */ } } - if (!composes) continue - for (const e of composes) if (!resolveEntry(e, bundle)) unresolved.push(`${bundle} composes "${e}" — resolves to no bundle`) + if (!composes && !ALL && !EXTRA.has(bundle)) continue + for (const e of composes || []) if (!resolveEntry(e, bundle)) unresolved.push(`${bundle} composes "${e}" — resolves to no bundle`) composites++ const committed = join(TREE, bundle, `${bundle}.js`) if (!existsSync(committed)) { failures.push(`${bundle}: no committed bundle at public/${TREE_NAME}/${bundle}/${bundle}.js`); continue } @@ -96,7 +102,7 @@ try { } } finally { rmSync(tmp, { recursive: true, force: true }) } -console.log(`COMPOSITE-FRESHNESS: ${composites} composite(s) rebuilt into a temp dir and compared byte for byte with the committed bundle.`) +console.log(`COMPOSITE-FRESHNESS: ${composites} bundle(s) rebuilt into a temp dir and compared byte for byte with the committed bundle.`) if (fresh.length) console.log(` ✓ ${fresh.join(', ')}`) for (const u of unresolved) console.log(` ! UNRESOLVED ${u}`) if (failures.length) { diff --git a/internal/fix-sri.mjs b/internal/fix-sri.mjs index 5a10a9301..5dbbab79a 100644 --- a/internal/fix-sri.mjs +++ b/internal/fix-sri.mjs @@ -174,6 +174,16 @@ function fixTree(root) { if (!DRY) writeFileSync(mf, after) st.filesWritten.push(mf) } + // The Research Gallery reads a BYTE-IDENTICAL copy of the manifest first when it runs on a foreign + // origin (generate-tree writes it; see doxa-maps-spa.vue). Repairing the root manifest alone left the + // copy with the OLD integrity, so an embedded gallery refused the rebuilt bundle (rega, 2026-09-25). + for (const rel of ['doxa-maps-spa/manifest.json']) { + const copy = join(root, rel) + if (existsSync(copy) && readFileSync(copy, 'utf8') !== after) { + if (!DRY) writeFileSync(copy, after) + st.filesWritten.push(copy) + } + } } return st } diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index ebde0a745..8d8157ce3 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -693,7 +693,7 @@ function rootHtml(bundles, manifest) { * is precisely why this was nearly missed. "The custom element mounted" is a WEAKER claim than * "the plugin works"; a mounted element with no data is the failure that looks like success. */ -function studioRootHtml(bakedToken) { +function studioRootHtml(bakedToken, integrity = '') { // THE TREE'S FRONT PAGE (/index.html) — Driver decision, card // BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE: the output is ONE folder, and opening it opens the // studio. It used to sit one level ABOVE the tree (public/index.html) with the staging-forge chooser @@ -714,7 +714,7 @@ ${bakedToken ? ` + ` @@ -1366,7 +1366,9 @@ function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) { } // THE FRONT PAGE IS THE STUDIO (see studioRootHtml). The staging-forge chooser that used to be // written here (rootHtml) is retired with it; nothing is written above the tree any more. - writeFileSync(join(root, 'index.html'), studioRootHtml(bakedToken)) + // …with the studio bundle's integrity, written HERE: it used to be added only by fix:sri, so running + // `bun run tree` after fix:sri stripped it again (rega, 2026-09-25). + writeFileSync(join(root, 'index.html'), studioRootHtml(bakedToken, (bundles.find((b) => b.bundle === 'plugin-studio') || {}).integrity || '')) bundles.forEach((b) => { diff --git a/internal/landing-preconditions.mjs b/internal/landing-preconditions.mjs index c86fec388..37cd526a3 100644 --- a/internal/landing-preconditions.mjs +++ b/internal/landing-preconditions.mjs @@ -63,7 +63,17 @@ console.log('LANDING-PRECONDITIONS: 3 sri — every leaf loads the bundle its in if (process.env.DOXA_SKIP_FRESHNESS === '1') { console.log('LANDING-PRECONDITIONS: 4 fresh — SKIPPED (DOXA_SKIP_FRESHNESS=1; dry check only, never set by main-capture scan)') } else { - const fresh = run(process.execPath, ['internal/check-composite-freshness.mjs']) + // SCOPE: what can this landing have made stale? A change to shared code (library/, the build config, the + // dependencies) reaches ANY bundle — measured 2026-09-25: one Arabic catalogue edit in library/i18n left 12 + // of 25 bundles stale, most of them not composites, and a composite-only check saw none of it. Otherwise: + // every composite, plus each bundle whose own folder the landing touched. + const base = run('git', ['merge-base', 'HEAD', `origin/${process.env.MAIN_CAPTURE_BRANCH || 'main'}`]).stdout.trim() + const changed = base ? run('git', ['diff', '--name-only', base, 'HEAD']).stdout.split('\n').filter(Boolean) : [] + const shared = !base || changed.some((f) => /^(library\/|vite\.config\.js$|package\.json$|bun\.lockb?$|vite\.doxa-maps-build-config\.json$)/.test(f)) + const own = [...new Set(changed.map((f) => (f.match(/^plugin-designs\/([^/]+)\//) || [])[1]).filter(Boolean))] + const scope = shared ? ['--all'] : (own.length ? ['--bundles', own.join(',')] : []) + console.log(`LANDING-PRECONDITIONS: 4 fresh — scope: ${shared ? 'EVERY bundle (shared code changed' + (base ? '' : '; no merge-base') + ')' : 'composites' + (own.length ? ' + ' + own.join(', ') : '')}`) + const fresh = run(process.execPath, ['internal/check-composite-freshness.mjs', ...scope]) if (fresh.status !== 0) refuse( 'a committed composite is not what its source builds (stale consumers).\n' + 'README rule 5: ONE agent owns the full build. If your change touches library/ or anything compiled into\n' +