diff --git a/internal/check-composite-freshness.mjs b/internal/check-composite-freshness.mjs index ffd72053b..27e68e402 100644 --- a/internal/check-composite-freshness.mjs +++ b/internal/check-composite-freshness.mjs @@ -7,127 +7,100 @@ * live in a fresh context, and published. All true — and insufficient. `big-picture` (cap-4, * tab-1 of the cap-5 SPA) and `dashboard-plus-grid` NEST that grid and COMPILE IT IN, so both * kept shipping the PRE-FIX code. The surface that was proved is not the surface anyone opens. - * * ★ A COMPOSITE IS A COPY TAKEN AT ITS OWN BUILD TIME, NOT A POINTER TO ITS CHILD. - * So a child's source newer than a parent's bundle means the parent is serving stale code. * - * WHY NOT GREP FOR A MARKER CLASS - * That is how the bug was originally found, but it does not generalise: it needs a human to - * remember a string unique to each fix. Worse, the OBVIOUS choice misleads — grepping the - * pre-existing `filters__toggle` reported "present" on all three bundles and HID the staleness; - * only the fix-introduced `filters__toggle-count` exposed it. A gate that depends on choosing - * the right marker will eventually be handed the wrong one. MTIME needs no such choice. + * ★ WHY IT NOW REBUILDS AND COMPARES BYTES, NOT MTIMES (2026-09-25). The first version compared + * the newest child SOURCE mtime with the parent BUNDLE mtime. File times are not build facts: + * a git merge or checkout rewrites sources with checkout-time mtimes, so rega's publish of + * beb13da3 stopped on "doxa-maps-spa / plugin-studio / stack-demo STALE vs gospel-resources / + * research-dashboard" although a rebuild reproduced the committed bytes exactly — and two of + * those parents mount their children BY TAG at runtime, so they compile none of the child in + * and CANNOT be stale with respect to it. It was also gameable the other way: checking out the + * parents makes their mtimes new and the gate passes on stale code. + * The question is "does the committed bundle equal what its source builds TODAY?", so that is + * what is asked: each composite is rebuilt into a temp dir (vite.config.js DOXA_OUT_ROOT — never + * public/) and its sha256 compared with the committed bundle. Builds are deterministic (two runs + * → identical bytes, verified), so a difference is real: the committed parent predates a source + * change it compiles in — its own, or a child's. A child it only mounts by tag cannot move it. * * HOW IT WORKS - * 1. read every plugin-designs//.meta.json `composes: []` — the AUTHORITATIVE declaration - * of nesting (the manifest has no `composes` field). - * 2. resolve each entry to a bundle: it is either a bundle DIRECTORY or an ELEMENT TAG owned by - * one (manifest `bundles[].elements`). A tag that resolves to nothing is REPORTED, never - * silently skipped — an unresolved edge is an unchecked edge. - * 3. for each parent→child edge compare the NEWEST child source mtime against the parent's - * built bundle mtime. Child newer ⇒ FAIL. + * 1. every plugin-designs//.meta.json with `composes: [...]` is a composite parent (entries + * are still resolved and an unresolvable one is REPORTED — an unresolved edge is an unchecked + * edge); + * 2. BUNDLE= DOXA_OUT_ROOT= vite build, then sha256(tmp) vs sha256(public/…/.js); + * 3. differ ⇒ STALE (FAIL); no committed bundle ⇒ reported, not built ⇒ FAIL. + * Usage: node internal/check-composite-freshness.mjs [--only a,b] (~5-15 s per composite) * - * SELF-EDGES ARE SKIPPED: big-picture composes `bp-research-header`, which resolves back to - * big-picture. A bundle cannot be stale with respect to itself. - * - * NEGATIVE-TESTED: `touch plugin-designs/people-groups-grid/people-groups-grid.vue` makes this - * FAIL naming big-picture + dashboard-plus-grid + doxa-maps-spa + plugin-studio + stack-demo; - * a full `bun run build` returns it to PASS. A gate without a negative test is not a gate. + * NEGATIVE-TESTED 2026-09-25: an edit to people-groups-grid.vue with no rebuild fails the + * composites that compile it in; `touch` on every child source (no content change) still PASSES on + * a fresh tree — the mtime false alarm is gone. */ -import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs' -import { join, dirname, resolve } from 'node:path' +import { readFileSync, existsSync, readdirSync, mkdtempSync, rmSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const DESIGNS = join(ROOT, 'plugin-designs') -const BUILT = join(ROOT, 'public', 'doxa-maps') -const MANIFEST = join(BUILT, 'manifest.json') +const TREE = join(ROOT, 'public', 'doxa-maps') +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 -if (!existsSync(MANIFEST)) { - console.log('COMPOSITE-FRESHNESS: SKIP — no manifest yet (run `bun run build` first).') +if (!existsSync(join(TREE, 'manifest.json'))) { + console.log('COMPOSITE-FRESHNESS: SKIP — no built tree (public/doxa-maps/manifest.json).') process.exit(0) } - -const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')) - +const manifest = JSON.parse(readFileSync(join(TREE, 'manifest.json'), 'utf8')) // tag -> owning bundle, so a composes entry naming an ELEMENT still resolves. -const tagToBundle = new Map() -for (const b of manifest.bundles || []) { - for (const e of b.elements || []) { - const tag = typeof e === 'string' ? e : e?.tag - if (tag) tagToBundle.set(tag, b.bundle) - } -} +const byTag = new Map() +for (const b of manifest.bundles || []) for (const t of b.elements || []) byTag.set(t, b.bundle) +const bundles = new Set(readdirSync(DESIGNS).filter((d) => existsSync(join(DESIGNS, d, 'index.js')))) +// A bare PROFILE name of the same bundle (gospel-resources composes "resources-map", "data-tables" — +// its own sibling profiles) resolves to that bundle, as the studio resolves it. +const profilesOf = new Map((manifest.bundles || []).map((b) => [b.bundle, new Set((b.profiles || []).map((p) => p.profile))])) +const resolveEntry = (e, parent) => { const s = String(e); const head = s.split('/')[0] + if (bundles.has(head)) return head + if (parent && profilesOf.get(parent) && profilesOf.get(parent).has(s)) return parent + return byTag.get(s) || null } -/** Newest mtime under a bundle's SOURCE dir (its .vue/.js/.json — not node_modules). */ -function newestSource(bundle) { - const dir = join(DESIGNS, bundle) - if (!existsSync(dir)) return null - let newest = 0, file = null - const walk = (d) => { - for (const ent of readdirSync(d, { withFileTypes: true })) { - if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue - const p = join(d, ent.name) - if (ent.isDirectory()) { walk(p); continue } - if (!/\.(vue|js|mjs|json|css)$/.test(ent.name)) continue - const m = statSync(p).mtimeMs - if (m > newest) { newest = m; file = p.slice(ROOT.length + 1) } +const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex') +const tmp = mkdtempSync(join(tmpdir(), 'composite-freshness-')) +const failures = [], unresolved = [], fresh = [] +let composites = 0 +try { + for (const bundle of [...bundles].sort()) { + if (ONLY && !ONLY.has(bundle)) continue + let composes = null + 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 */ } } - } - walk(dir) - return newest ? { mtime: newest, file } : null -} - -function builtBundle(bundle) { - const p = join(BUILT, bundle, `${bundle}.js`) - return existsSync(p) ? { mtime: statSync(p).mtimeMs, file: p.slice(ROOT.length + 1) } : null -} - -const failures = [] -const unresolved = [] -let edges = 0, composites = 0 - -for (const ent of readdirSync(DESIGNS, { withFileTypes: true })) { - if (!ent.isDirectory()) continue - const bundle = ent.name - const meta = join(DESIGNS, bundle, `${bundle}.meta.json`) - if (!existsSync(meta)) continue - let composes - try { composes = JSON.parse(readFileSync(meta, 'utf8')).composes } catch { continue } - if (!Array.isArray(composes) || !composes.length) continue - composites++ - - const parentBuilt = builtBundle(bundle) - if (!parentBuilt) continue // never built — not this gate's concern - - for (const rawEntry of composes) { - // a composes entry may be "bundle", "bundle/profile", or an element tag - const head = String(rawEntry).split('/')[0] - const child = existsSync(join(DESIGNS, head)) ? head : tagToBundle.get(head) - if (!child) { unresolved.push(`${bundle} composes "${rawEntry}" — resolves to no bundle`); continue } - if (child === bundle) continue // self-edge (own element); cannot be stale vs itself - edges++ - const src = newestSource(child) - if (!src) continue - if (src.mtime > parentBuilt.mtime) { - const lagS = Math.round((src.mtime - parentBuilt.mtime) / 1000) - failures.push( - `${bundle} is STALE vs its child ${child}\n` + - ` child source : ${src.file}\n` + - ` parent bundle: ${parentBuilt.file}\n` + - ` child is ${lagS}s newer — the parent compiled an OLD copy. Run a FULL \`bun run build\`.` - ) + if (!composes) 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/doxa-maps/${bundle}/${bundle}.js`); continue } + const out = join(tmp, bundle) + const r = spawnSync('bun', ['x', 'vite', 'build'], { cwd: ROOT, env: { ...process.env, BUNDLE: bundle, DOXA_OUT_ROOT: out }, encoding: 'utf8' }) + const built = join(out, bundle, `${bundle}.js`) + if (r.status !== 0 || !existsSync(built)) { + failures.push(`${bundle}: the rebuild FAILED (exit ${r.status}) — ${String(r.stderr || r.stdout).trim().split('\n').slice(-2).join(' / ').slice(0, 200)}`) + continue } + if (sha(built) !== sha(committed)) { + failures.push(`${bundle} is STALE — the committed bundle is not what its source builds today (committed ${sha(committed).slice(0, 12)}, rebuilt ${sha(built).slice(0, 12)}). Rebuild it: BUNDLE=${bundle} bun x vite build, then bun run fix:sri.`) + } else fresh.push(bundle) } -} +} finally { rmSync(tmp, { recursive: true, force: true }) } -console.log(`COMPOSITE-FRESHNESS: ${composites} composite(s), ${edges} parent->child edge(s) checked.`) +console.log(`COMPOSITE-FRESHNESS: ${composites} composite(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) { - console.log(`\nCOMPOSITE-FRESHNESS: FAIL — ${failures.length} composite(s) carry a stale child:`) + console.log(`\nCOMPOSITE-FRESHNESS: FAIL — ${failures.length} composite(s):`) for (const f of failures) console.log(` ✗ ${f}`) - console.log('\n A composite is a COPY taken at its own build time, not a pointer to its child.') process.exit(1) } -console.log('COMPOSITE-FRESHNESS: PASS — every composite was built after the children it nests.') +console.log('COMPOSITE-FRESHNESS: PASS — every committed composite is exactly what its source builds today.') diff --git a/vite.config.js b/vite.config.js index 906fc4aae..9a0e184d3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -44,7 +44,9 @@ function readMfeName() { const MFE_NAME = readMfeName() // Default output tree. generate-tree.mjs reads the same name + copies it onward. -const OUT_ROOT = resolve(__dirname, 'public', MFE_NAME) +// DOXA_OUT_ROOT redirects a build's output WITHOUT touching public/: check:composite rebuilds each +// composite into a temp dir and compares bytes with the committed bundle. Unset = unchanged behaviour. +const OUT_ROOT = process.env.DOXA_OUT_ROOT ? resolve(process.env.DOXA_OUT_ROOT) : resolve(__dirname, 'public', MFE_NAME) function discoverBundles() { if (!existsSync(profilesDir)) return {}