162 lines
10 KiB
JavaScript
162 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// check-packets-all — run check-packet-render over EVERY generated packet.
|
|
//
|
|
// ★ WHY THIS WRAPPER EXISTS. check-packet-render takes a path, and with no path it
|
|
// SKIPs at exit 0 — deliberately, so a build is not failed on a file that may not have
|
|
// been generated. The consequence, measured 2026-09-24: the gate was in package.json,
|
|
// all 11 locale packets passed it, and it was in NEITHER gates:pre NOR gates:post. It
|
|
// only ever ran when a human remembered to type a filename. A gate nobody invokes
|
|
// protects nothing — the same "present but unwired" state the two i18n gates were in.
|
|
//
|
|
// So this sweeps the directory and gates the WHOLE SET. That matters beyond wiring:
|
|
// checking one hand-named packet proves one locale. The packet ships in 11, and the
|
|
// failures that actually occur are locale-specific — a script font that does not load,
|
|
// an RTL document laid out LTR, a translated page header whose word order breaks a
|
|
// parser. Those are invisible if the gate is pointed at laos.html and nothing else.
|
|
//
|
|
// SKIP (exit 0) only when the directory is genuinely absent or empty: packets are
|
|
// generated on demand and a clean checkout has none. A skip says so out loud, with the
|
|
// count, so "0 packets checked" can never read as "all packets passed".
|
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
import { resolve, dirname, join } from 'node:path'
|
|
// pathToFileURL, not a bare path: a dynamic import() of an absolute POSIX path works by luck on
|
|
// linux and throws ERR_UNSUPPORTED_ESM_URL_SCHEME the moment anyone runs this on windows.
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import { spawnSync } from 'node:child_process'
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
// Repo-relative, never an author's worktree: this gate lives in two repos and the
|
|
// packets are generated in the one that owns the poster scripts.
|
|
const REPO = process.env.DOXA_REPO || resolve(HERE, '..')
|
|
const DIR = process.env.PACKET_DIR
|
|
|| resolve(REPO, 'library/scripts/posters/out/packets')
|
|
|
|
if (!existsSync(DIR)) {
|
|
console.log(`PACKETS-ALL: SKIP — no packet directory at ${DIR}`)
|
|
console.log(' Packets are generated on demand (node library/scripts/posters/packet.mjs);')
|
|
console.log(' a clean checkout has none, so this is not a failure.')
|
|
process.exit(0)
|
|
}
|
|
|
|
const files = readdirSync(DIR).filter((f) => f.endsWith('.html')).sort()
|
|
if (!files.length) {
|
|
console.log(`PACKETS-ALL: SKIP — ${DIR} contains no .html packets`)
|
|
process.exit(0)
|
|
}
|
|
|
|
const gate = join(HERE, 'check-packet-render.mjs')
|
|
|
|
// ★ THE FUNNEL PAGE IS REQUIRED HERE, and this line is the half of the fix that was missing.
|
|
// check-packet-render gained --expect-funnel after a merge deleted the call site supplying the funnel
|
|
// data and every gate stayed green (regions-plugin, 2026-09-26). But a DECLARABLE expectation that
|
|
// nothing declares is not a closed hole — it is an invisible defect traded for a visible obligation.
|
|
// I said as much to regions and then owed the second half.
|
|
//
|
|
// ⚠ WHY IT CAN BE UNCONDITIONAL HERE. Every packet in out/ is produced by
|
|
// library/scripts/posters/packet.mjs, which always passes FUNNEL to buildPacket. So a packet in this
|
|
// directory WITHOUT page 2 was either generated before the funnel existed or generated by a caller
|
|
// that dropped the argument — and both are things this sweep should say out loud. Verified
|
|
// 2026-09-26 across all 16 packets in out/ (4 fixtures, the ar tibet, and all 11 laos locales
|
|
// regenerated): every one has page 2, so this requires a property the pipeline actually holds rather
|
|
// than one it aspires to.
|
|
// ⚠ ESCAPE HATCH, deliberately awkward to reach: PACKETS_ALL_NO_FUNNEL=1. If a legitimate host ever
|
|
// generates funnel-less packets, that is a real state and this flag records it — but it must be a
|
|
// decision someone takes, not a default that quietly tolerates a missing page.
|
|
const EXPECT_FUNNEL = process.env.PACKETS_ALL_NO_FUNNEL !== '1'
|
|
if (!EXPECT_FUNNEL) {
|
|
console.log('PACKETS-ALL: ⚠ PACKETS_ALL_NO_FUNNEL=1 — page 2 is NOT required this run.')
|
|
}
|
|
|
|
const failed = []
|
|
for (const f of files) {
|
|
const args = EXPECT_FUNNEL ? [gate, join(DIR, f), '--expect-funnel'] : [gate, join(DIR, f)]
|
|
const res = spawnSync(process.execPath, args, { encoding: 'utf8' })
|
|
const ok = res.status === 0
|
|
console.log(` ${ok ? '✓' : '✗'} ${f}`)
|
|
if (!ok) {
|
|
failed.push(f)
|
|
// Print the failing gate's own reasons rather than a generic "it failed" —
|
|
// the detail is what makes a red build actionable.
|
|
const detail = `${res.stdout || ''}${res.stderr || ''}`
|
|
.split('\n').filter((l) => l.trim().startsWith('-') || l.includes('FAIL'))
|
|
for (const line of detail) console.log(` ${line.trim()}`)
|
|
}
|
|
}
|
|
|
|
// ★ SAY HOW MANY ZONES THIS ACTUALLY COVERED. "every generated packet renders" is true and reads
|
|
// like "every packet is fine" — but only the zones someone generated are ever here. MEASURED
|
|
// 2026-09-24: 11 packets, ALL of them the SAME zone (laos) in 11 locales, against 28 poster zones
|
|
// in out/posters. So the suite proved one zone's layout 11 times and had never rendered the other
|
|
// 27, any of which could carry a different data shape (fewer groups, a missing photo, an
|
|
// unresolvable map). A silent cap reads as full coverage; naming it is the difference between
|
|
// "verified" and "verified what happened to be lying around".
|
|
// NOT a failure — generating 28 zones x 11 locales means a network fetch per zone and ~100MB of
|
|
// artefacts, which is a deliberate trade, not an oversight. The gate's job here is to stop the
|
|
// PASS line from overclaiming.
|
|
const zoneOf = (f) => f.replace(/\.html$/, '').replace(/\.[a-z]{2}$/, '')
|
|
const zones = [...new Set(files.map(zoneOf))].sort()
|
|
|
|
// ★ THE DENOMINATOR NOW COMES FROM THE SAME PLACE THE NUMERATOR DOES.
|
|
//
|
|
// ⚠ IT DID NOT, AND THAT HID A BUG FOR THREE DAYS. This counted out/posters/*.html — POSTER files —
|
|
// while the numerator counts PACKET zones. Two different populations, printed either side of the word
|
|
// "of". out/posters is also itself GENERATED, so on a clean checkout the denominator was null and the
|
|
// shortfall warning silently vanished: the one line whose job was to stop an overclaim could not fire
|
|
// precisely when nothing had been rendered at all.
|
|
// MEASURED 2026-09-27: posters = 28, packet-able zones = 27, and the missing one was `deaf` — a legend
|
|
// row and a poster with NO reachable packet, because packet.mjs's allZones() looped three cluster keys
|
|
// and clusters.json carries zones in a fourth (deaf_lists, collapsed by legend-tree.js into one leaf).
|
|
// So "2 of 28" was not 26-to-go: it was 25-to-go plus one that could not be generated by any command.
|
|
// Fixed in packet.mjs (it now enumerates via leafZones, deaf included); this line stops the two sides
|
|
// from ever drifting again by reading the SAME seam rather than a directory listing of a side effect.
|
|
let totalZones = null
|
|
let zoneIds = null
|
|
try {
|
|
const DATA = resolve(REPO, 'plugin-designs/my-upg-100-list/data')
|
|
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'))
|
|
const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(DATA, 'legend-tree.js')).href)
|
|
zoneIds = leafZones(buildLegendNodes(clusters, { records: null })).map((z) => z.id).sort()
|
|
totalZones = zoneIds.length
|
|
} catch (e) {
|
|
// The denominator is a nicety and its absence must not fail the gate — but SAY so, because a
|
|
// silently-absent denominator is exactly how the shortfall warning disappeared before.
|
|
console.log(` (zone total unavailable: ${e.message})`)
|
|
}
|
|
|
|
console.log(`\nPACKETS-ALL: ${files.length} packet(s) checked, ${files.length - failed.length} passed`)
|
|
// ★ COUNT AND LIST FROM ONE SET, derived the same way. My first version printed
|
|
// `totalZones - zones.length` next to a list built by filtering zoneIds, and they disagreed on sight:
|
|
// "21 zone(s)" above a list of 26. zones[] holds every FILE stem, which includes things that are not
|
|
// zones at all — the four fixture-* packets and the `<zone>.frontier` ?list= variant — so it overcounts
|
|
// coverage against a real-zone denominator. Two numbers describing one quantity, computed two ways, is
|
|
// how a report contradicts itself in public; the derived list is now the single source and the count
|
|
// is just its length.
|
|
const covered = (zoneIds || []).filter((z) => zones.includes(z))
|
|
const missing = (zoneIds || []).filter((z) => !zones.includes(z))
|
|
const extras = zones.filter((z) => zoneIds && !zoneIds.includes(z))
|
|
console.log(` zones covered : ${totalZones ? `${covered.length} of ${totalZones}` : zones.length}`
|
|
+ ` — ${(totalZones ? covered : zones).join(', ') || '(none)'}`)
|
|
// The fixture and ?list= packets are real coverage of the RENDERER and no coverage at all of a zone's
|
|
// data shape. Reported separately rather than folded into the numerator, which is what made the old
|
|
// line read "7 of 28" when only 2 real zones had ever been rendered.
|
|
if (extras.length) {
|
|
console.log(` also checked : ${extras.length} non-zone packet(s) — ${extras.join(', ')}`)
|
|
console.log(' (fixtures and ?list= variants: they exercise the renderer, not a zone\'s data shape)')
|
|
}
|
|
if (totalZones && missing.length) {
|
|
// ★ NAME THE MISSING ZONES, do not just count them. "26 zone(s) have no packet" tells a reader the
|
|
// size of the hole; it does not tell them what to type, so nobody ever closed it. The list is the
|
|
// actionable form — and it is what surfaced `deaf` as unreachable rather than merely ungenerated.
|
|
console.log(` ⚠ ${missing.length} zone(s) have NO packet here and were not rendered by`)
|
|
console.log(` anything: ${missing.join(', ')}`)
|
|
console.log(' Generate all of them (committed fixture, NO network):')
|
|
console.log(' node internal/build-packet-fixtures.mjs # the fixture zones')
|
|
console.log(' ONLY=<zone> node library/scripts/posters/packet.mjs')
|
|
}
|
|
if (failed.length) {
|
|
console.error(`PACKETS-ALL: FAIL — ${failed.length} packet(s): ${failed.join(', ')}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`PACKETS-ALL: PASS — every generated packet renders, across `
|
|
+ `${totalZones ? `${covered.length} of ${totalZones}` : zones.length} zone(s) and every locale `
|
|
+ `present.${missing.length ? ` This does NOT cover the ${missing.length} zone(s) named above.` : ''}`)
|