133 lines
6 KiB
JavaScript
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.')
|