doxa-maps/internal/check-packets-all.mjs
Doxa Maps 18d70de5dd The coverage line counted posters against packets and contradicted itself
Three defects in one report, all of them the kind that makes a number read as verified when it is not.

1. THE DENOMINATOR CAME FROM A DIFFERENT POPULATION THAN THE NUMERATOR. It counted
   out/posters/*.html — POSTER files — while the numerator counts PACKET zones, and the two are not
   the same set: 28 posters against 27 packet-able zones, because `deaf` had a poster and no
   obtainable packet (fixed in 3ef164c0). So "2 of 28" was not 26 to go, it was 25 to go plus one
   that no command could produce. Both sides now read the SAME seam — leafZones(buildLegendNodes(...)),
   which is what the generator itself enumerates — so they cannot drift apart again.

2. out/posters IS 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. Its absence is now stated out loud.

3. THE COUNT AND THE LIST DISAGREED ON SIGHT — my own bug, caught by reading the output rather than
   trusting it: "21 zone(s)" printed above a list of 26. zones[] holds every FILE stem, which
   includes things that are not zones (the four fixture-* packets and the <zone>.frontier ?list=
   variant), so it overcounted coverage against a real-zone denominator and made "7 of 28" out of
   2 real zones — a 3.5x overstatement. Count and list are now one derived set, and the non-zone
   packets are reported separately as what they are: coverage of the RENDERER, not of a zone's data
   shape.

And the warning now NAMES the missing zones instead of counting them. "26 zone(s) have no packet"
tells a reader the size of the hole and not what to type, which is why nobody ever closed it; the
named list is what surfaced `deaf` as unreachable rather than merely ungenerated.

Coverage now measured at 22 of 28, every one PASSing with --expect-funnel, up from 2 of 28. The six
still open are named in the output (central-africa, deaf, east-africa, europe,
southern-africa-islands, west-africa) and all six need a records source carrying the selector fields
3ef164c0 added to the query.
2026-09-27 01:28:02 +00:00

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.` : ''}`)