doxa-maps/internal/check-composite-freshness.mjs
2026-09-21 06:06:29 +00:00

133 lines
6 KiB
JavaScript

#!/usr/bin/env node
/**
* check-composite-freshness.mjs — catch a COMPOSITE that shipped a STALE copy of a child.
*
* THE BUG THIS EXISTS TO PREVENT (real, 2026-09-21)
* A fix landed in `people-groups-grid`; that ONE bundle was rebuilt, verified locally AND
* 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.
*
* HOW IT WORKS
* 1. read every plugin-designs/<b>/<b>.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.
*
* 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.
*/
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs'
import { join, dirname, resolve } 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')
if (!existsSync(MANIFEST)) {
console.log('COMPOSITE-FRESHNESS: SKIP — no manifest yet (run `bun run build` first).')
process.exit(0)
}
const manifest = JSON.parse(readFileSync(MANIFEST, '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)
}
}
/** 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) }
}
}
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\`.`
)
}
}
}
console.log(`COMPOSITE-FRESHNESS: ${composites} composite(s), ${edges} parent->child edge(s) checked.`)
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:`)
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.')