From ec974b4c635a458c16318a6fbe7793bfb814e176 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:07:17 +0000 Subject: [PATCH 1/2] fix(posters): ask the legend which zones are reachable, not clusters.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish-html.mjs enumerated the raw cluster keys under a comment that said "the zone ids the app can actually reach" — which was exactly what it did not do. It then warned that deaf-old-world and deaf-new-world-asia-pacific were MISSING on every run, when nothing was missing. Those two are SUPERSEDED by the unified `deaf` row (affinity bloc A017), not absent. Verified against live records instead of trusting the comment that says so: 63 + 60 = 123 = A017 exactly, zero groups on either side of the difference. They stay in clusters.json deliberately — three isolation-proof scripts assert deaf_lists.length === 2 — but the legend does not expose them, so no user can select them and a poster for them is unreachable by construction. It now derives zone ids from leafZones(buildLegendNodes(...)), the same source the app itself uses, so the two can never disagree again. Result: 28 posters published, zero warnings, matching the legend's 28 leaves. records:null, not records:[] — buildLegendNodes treats an empty array as truthy and then demands population fields that are not available here. A recurring warning that is always wrong is worse than no warning: it trains everyone to ignore the one run where something really is missing. --- library/scripts/posters/publish-html.mjs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/library/scripts/posters/publish-html.mjs b/library/scripts/posters/publish-html.mjs index 04af6803e..204c37c5a 100644 --- a/library/scripts/posters/publish-html.mjs +++ b/library/scripts/posters/publish-html.mjs @@ -42,7 +42,7 @@ // ONLY=laos node publish-html.mjs # one import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join, resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -56,13 +56,21 @@ if (!existsSync(SRC)) { process.exit(1); } -// The zone ids the app can actually reach. A poster for no zone is not publishable. +// ★ THE ZONE IDS THE APP CAN ACTUALLY REACH — ask the LEGEND, not clusters.json. +// Enumerating the raw cluster keys is what this used to do, and it is wrong: +// clusters.json carries 30 list definitions but the legend exposes 28 leaves. +// deaf-old-world and deaf-new-world-asia-pacific are SUPERSEDED by the unified +// `deaf` row (affinity bloc A017) — verified against live data, 63 + 60 = 123 = +// A017 exactly, with zero groups on either side of the difference. They are kept +// in clusters.json deliberately (three isolation-proof scripts assert +// deaf_lists.length === 2), but no user can select them, so a poster for them is +// unreachable. Enumerating them made this script warn that two zones were +// "MISSING" when nothing was missing at all. const clusters = JSON.parse(readFileSync( join(REPO, 'plugin-designs', 'my-upg-100-list', 'data', 'clusters.json'), 'utf8')); -const zoneIds = new Set(); -for (const key of ['lists', 'deaf_lists', 'asia_lists', 'uncovered_lists']) { - for (const z of clusters[key] || []) zoneIds.add(z.id); -} +const { buildLegendNodes, leafZones } = await import( + pathToFileURL(join(REPO, 'plugin-designs', 'my-upg-100-list', 'data', 'legend-tree.js')).href); +const zoneIds = new Set(leafZones(buildLegendNodes(clusters, { records: null })).map((z) => z.id)); mkdirSync(DEST, { recursive: true }); From d050f7e21693d4bd6c002e40d5d83775f0c2f2cc Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:07:04 +0000 Subject: [PATCH 2/2] test(zones): census every SELECTABLE zone against the posters built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failure this catches is a short count with no error. generate.mjs used to abort on the first bad zone — 16 of 29 written, the rest silently unbuilt — and separately, tooling that enumerated clusters.json rather than the LEGEND warned two zones were "MISSING" when nothing was. Both look like success from a distance; only a census shows them. ★ ASK THE LEGEND, NOT THE DATA FILE. clusters.json carries 30 list definitions; the legend exposes 28 leaves. deaf-old-world and deaf-new-world-asia-pacific are collapsed into the unified `deaf` row (affinity bloc A017) — VERIFIED against live records rather than taken from a comment: 63 + 60 = 123 = A017 exactly, with zero groups on either side of the difference. They remain in clusters.json deliberately, because three isolation-proof scripts assert deaf_lists.length === 2, but no user can select them. So they are reported as CONTEXT ("superseded, not a gap"), never as a failure. Counting the file instead of the legend manufactures two phantom failures and then hides real ones behind them — which is how a genuinely missing poster would get lost in noise everyone had learned to ignore. Passing: 28 selectable zones, 28 posters. Confirmed to fail before being accepted: removing one poster reports that zone by name. --- internal/check-zone-census.mjs | 58 ++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 59 insertions(+) create mode 100644 internal/check-zone-census.mjs diff --git a/internal/check-zone-census.mjs b/internal/check-zone-census.mjs new file mode 100644 index 000000000..2b5bd6129 --- /dev/null +++ b/internal/check-zone-census.mjs @@ -0,0 +1,58 @@ +// check-zone-census — every zone a USER CAN SELECT must have a poster. +// +// THE BUG CLASS THIS EXISTS FOR: a short count with no error. generate.mjs used to +// abort on the first bad zone (16 of 29 written, the rest silently unbuilt), and +// separately, tooling that enumerated clusters.json rather than the LEGEND warned +// that two zones were "MISSING" when nothing was missing — they are superseded and +// unreachable. Both look like success from a distance; only a census shows them. +// +// ★ ASK THE LEGEND, NOT THE DATA FILE. clusters.json carries 30 list definitions; +// the legend exposes 28 leaves. deaf-old-world and deaf-new-world-asia-pacific are +// collapsed into the unified `deaf` row (affinity bloc A017) — verified against +// live records: 63 + 60 = 123 = A017, zero groups on either side of the +// difference. They stay in clusters.json deliberately (three isolation-proof +// scripts assert deaf_lists.length === 2), but no user can reach them, so a poster +// for them is not a gap. Counting the file instead of the legend manufactures two +// phantom failures and hides real ones behind them. +// +// Usage: node internal/check-zone-census.mjs [postersDir] +import { readFileSync, existsSync, readdirSync } from 'node:fs' +import { resolve, join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const REPO = process.env.DOXA_REPO + || resolve('/home/lk10x/Workspace/eco-dx/root-doxa-maps/wt-packet') +const DATA = join(REPO, 'plugin-designs', 'my-upg-100-list', 'data') +const POSTERS = resolve(process.argv[2] + || join(REPO, 'library', 'scripts', 'posters', 'out', 'posters')) + +const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8')) +const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(DATA, 'legend-tree.js')).href) +const leaves = leafZones(buildLegendNodes(clusters, { records: null })) + +if (!existsSync(POSTERS)) { + console.log(`ZONE-CENSUS: SKIP — no posters at ${POSTERS} (run generate.mjs first).`) + process.exit(0) +} +const built = new Set(readdirSync(POSTERS).filter((f) => f.endsWith('.html')).map((f) => f.slice(0, -5))) +const missing = leaves.map((z) => z.id).filter((id) => !built.has(id)) + +// Definitions that exist in the data but are NOT reachable — reported as context, +// never as a failure, so the difference between "unbuilt" and "superseded" stays +// visible instead of being argued about every time someone counts. +const declared = new Set() +for (const k of ['lists', 'deaf_lists', 'asia_lists', 'uncovered_lists']) { + for (const z of clusters[k] || []) declared.add(z.id) +} +const unreachable = [...declared].filter((id) => !leaves.some((z) => z.id === id)) + +console.log(` selectable zones : ${leaves.length}`) +console.log(` posters built : ${built.size}`) +console.log(` unreachable defs : ${unreachable.length}${unreachable.length ? ` (${unreachable.join(', ')}) — superseded, not a gap` : ''}`) + +if (missing.length) { + console.error(`\nZONE-CENSUS: FAIL — ${missing.length} selectable zone(s) have NO poster:`) + for (const id of missing) console.error(` - ${id}`) + process.exit(1) +} +console.log(`\nZONE-CENSUS: PASS — every one of the ${leaves.length} selectable zones has a poster.`) diff --git a/package.json b/package.json index f97bad1cf..95a266029 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery", "check:card-deck": "node internal/check-card-deck.mjs", "check:poster-raster": "node internal/check-poster-raster.mjs", + "check:zone-census": "node internal/check-zone-census.mjs", "check:print-geometry": "node internal/check-print-geometry.mjs", "check:packet-browser": "node internal/check-packet-browser.mjs", "check:packet-render": "node internal/check-packet-render.mjs",