doxa-maps/internal/check-list-selection.mjs
Doxa Maps d7b6a631b6 Move zone-regions.json + groupings.json to data/tables/, the shared asset pool
GOSPEL'S LEGEND RENDERS IN THE BROWSER and needs these zones at runtime; a
build-time import path would not reach the bundle. data/tables/ is the pool the
tree copies into public/dx-maps/assets/tables/, which is how near-neighbors.json
already reaches a browser, so this makes the zone data a single source the same
way (dx-shepherd, 2026-09-28).

admin-to-list.json deliberately does NOT move: it waits on the Driver's row 87,
the "zones fold" regeneration.

★ TWO ROOTS IN internal/check-list-selection.mjs, NOT ONE. Its readJson helper
reads all three files through a single DATA constant, and only two moved.
Collapsing them to one root silently breaks whichever file is not where you
pointed — so the helper now routes admin-to-list.json to the plugin dir and the
other two to data/tables/, with the reason in the comment.

★ THE ASSET-POOL COPY IS MANUAL, AND IT HAS ALREADY DRIFTED. There is no
automated copier: `git grep` finds no rule in vite.config.js or internal/ that
writes public/dx-maps/assets/tables/, and exp's rings note says the same ("only
the tree copies tables"). Measured before copying: data/tables has 309 json
files, the asset pool 304 — and the five missing were groupings.json,
zone-regions.json AND THREE THAT PREDATE THIS COMMIT (index.json,
open-doors.json, platform-reachability.json). So these two would have joined an
existing gap rather than created one. Both are copied here and verified with
cmp; the other three are reported separately, not silently fixed in a move
commit.

Paths verified to resolve, not assumed: P(GROUPINGS) and the REGIONS join both
resolve to existing files. Gates green after the move: check:list-selection,
check:selector-fields, check:describe-group, region-hue-proof.
2026-09-28 15:52:47 +00:00

194 lines
15 KiB
JavaScript

#!/usr/bin/env node
/**
* check-list-selection — what the vertical legend emits, and the zones a region tree forgets. Headless.
*
* ★ WHY THIS IS IN internal/ AND NOT _qa-cycle/. It began as a _qa-cycle probe, which is GITIGNORED
* in this repo (.gitignore:113) — so it ran green on my disk and would NOT have landed with the
* component. studio caught that while staging: "the 13 assertions are not in either commit". I had
* reported them as evidence for code that was about to ship untested, which is the same failure this
* whole file guards against — a green result measured somewhere other than where it matters.
* A probe proves something once. A registered check keeps proving it.
*/
// What the vertical legend emits, and the zones a region tree forgets.
//
// The second half is the one that matters most: four joinable UPG-100 zones are absent from
// zone-regions.json today, one of them the consumer's largest at 100 groups. A region-grouped tree
// drops them with no error and no empty state — just a shorter tree — so this asserts they survive.
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { leavesOf, zoneIdsIn, withUnassigned, selectedId, UNASSIGNED_ID } from '../library/components/legends/listSelection.js'
// ★ TWO ROOTS, DELIBERATELY, BECAUSE ONLY TWO OF THE THREE FILES MOVED. zone-regions.json and
// groupings.json now live in data/tables/ — the shared asset pool the tree copies into
// public/dx-maps/assets/tables/ — because gospel's legend renders IN THE BROWSER and needs them at
// runtime; a build-time import path would not reach the bundle. admin-to-list.json stays in the
// plugin until the Driver rules row 87 (the "zones fold" regeneration), so it is read from the old
// root. Collapsing these to one constant silently breaks whichever file is not where you pointed.
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..')
const TABLES = join(REPO, 'data', 'tables')
const PLUGIN_DATA = join(REPO, 'plugin-designs', 'my-upg-100-list', 'data')
const readJson = (f) => JSON.parse(readFileSync(join(f === 'admin-to-list.json' ? PLUGIN_DATA : TABLES, f), 'utf8'))
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
const TREE = [{
id: 'asia', label: 'Asia', depth: 0,
children: [
{ id: 'z1', label: 'Zone 1', zoneKey: 'Z1' },
{ id: 'sub', label: 'Sub', depth: 1, children: [{ id: 'z2' }, { id: 'z3' }] },
],
}]
// The real gap, named as it appears in the data.
const MISSING = [
'asia-china-guizhou-province',
'asia-china-xinjiang-uyghur-autonomous-region',
'asia-india-bihar',
]
// asia-india-madhya-pradesh WAS here and is now FILED. upg100 hit this arm while filing it, and
// did the right thing with it: they reported the red instead of editing my expectation to make
// their change green — "editing someone else's pinned expectation to make my own change green is
// precisely the move that hollows out a guard". dx-shepherd then ruled that their data and this
// fixture land TOGETHER, which is the only ordering where neither of us is red in between.
ok(leavesOf(TREE[0]).map((l) => l.id).join(',') === 'z1,z2,z3', 'leavesOf walks NESTED children, not one level')
ok([...zoneIdsIn(TREE)].sort().join(',') === 'z1,z2,z3', 'zoneIdsIn collects every leaf id in the tree')
// ── the emit contract ───────────────────────────────────────────────────────────────────────────
ok(selectedId({ id: 'z1' }) === 'z1', 'a LEAF emits its zone id')
ok(selectedId({ id: 'asia' }) === 'asia', 'a REGION row emits the region id — the consumer expands it, where the join lives')
ok(selectedId(null) === null, 'and a CLEARED selection emits null, so a filter can be dropped')
ok(typeof selectedId({ id: 'z1' }) === 'string',
'the value is a STRING, so it survives ?zone=, profile-config and a deep link')
// ── never drop a zone ───────────────────────────────────────────────────────────────────────────
const all = ['z1', 'z2', 'z3', ...MISSING]
const withBucket = withUnassigned(TREE, all, 'Unassigned')
ok(withBucket.length === TREE.length + 1, 'zones absent from the tree get an explicit bucket')
const bucket = withBucket[withBucket.length - 1]
// Counted from MISSING, never written as a literal: the gap CHANGES (upg100 filed one while I was
// writing this), and a hardcoded 4 here turns every legitimate data fix into a puzzling red in an
// arm that is not even about the data.
ok(bucket.id === UNASSIGNED_ID && bucket.children.length === MISSING.length,
`and all ${MISSING.length} missing zones are in it`)
ok(bucket.children.map((c) => c.id).includes('asia-china-xinjiang-uyghur-autonomous-region'),
'including xinjiang — the consumer\'s largest zone at 100 groups, which a region tree loses silently')
ok(bucket.children.every((c) => c.id && c.zoneKey),
'each is a SELECTABLE leaf, not a lump — a reader wants xinjiang, not "the ones nobody filed"')
ok(withUnassigned(TREE, ['z1', 'z2', 'z3']).length === TREE.length,
'CONTROL: no bucket appears when nothing is missing')
ok(withUnassigned(TREE, null).length === TREE.length && withUnassigned(TREE, []).length === TREE.length,
'CONTROL: omitting the zone set leaves the tree untouched, so this is additive')
// NEG CONTROL: the drop this exists to prevent must be detectable.
const naive = TREE // what a region-grouped tree gives you, unaugmented
ok(!zoneIdsIn(naive).has('asia-china-xinjiang-uyghur-autonomous-region'),
'NEG CONTROL: the unaugmented tree really does omit xinjiang, so the bucket is not decoration')
// ── a region sweep is NOT a zone sweep (gospel, END-GOS-VLG2) ───────────────────────────────────
// ★ The consequence of the bucket that neither of us should leave implicit: ticking every REGION
// still does not select every ZONE, because the unfiled zones have no region BY DEFINITION.
// Nothing on screen would say so — the reader just gets a smaller total, which is the exact
// silent-wrong-answer shape the bucket exists to end. So if a "select all" is ever added it must
// sweep ZONES, not regions, or it inherits the bug. Pinned here so the wrong one cannot ship
// quietly; gospel pins the mirror of it on the expansion side.
const regionRows = withBucket.filter((n) => n.id !== UNASSIGNED_ID)
const byRegionSweep = zoneIdsIn(regionRows)
ok(byRegionSweep.size < all.length,
'selecting every REGION reaches fewer zones than exist — a region sweep is not a zone sweep')
ok(!byRegionSweep.has('asia-china-xinjiang-uyghur-autonomous-region'),
'and the 100 groups it misses are xinjiang\'s, so a "select all" over regions must be over zones')
ok(zoneIdsIn(withBucket).size === all.length,
'sweeping the WHOLE tree, bucket included, does reach all of them — what the bucket buys')
// ── a zone with no members stays SELECTABLE (gospel: 16 of 33 resolve today) ────────────────────
// Of the 33 zones, members resolve for 16; 5 await a wagf field and 9 have no selector anywhere.
// Those zones must render as EMPTY, not vanish — same reason as the bucket. A tree that hides what
// it cannot resolve tells the reader the zone does not exist, when the truth is that we cannot
// currently join it. Nothing here filters on member count, and this fails if anything starts to.
const EMPTYZONE = [{ id: 'asia', label: 'Asia', depth: 0, children: [{ id: 'z9', label: 'Zone 9', zoneKey: 'Z9', members: [] }] }]
ok(leavesOf(EMPTYZONE[0]).map((l) => l.id).join(',') === 'z9',
'a zero-member zone is still a LEAF, so a reader can select it and see an empty result')
ok(selectedId(leavesOf(EMPTYZONE[0])[0]) === 'z9', 'and it still emits its own id')
ok(withUnassigned(EMPTYZONE, ['z9']).length === EMPTYZONE.length,
'presence is by ID, not by member count, so an unresolvable zone is not ALSO dumped in the bucket')
// ── the gap is REAL TODAY, not just real in my fixture (gospel, END-GOS-VLG3) ───────────────────
// ★ EVERY ASSERTION ABOVE RUNS AGAINST A FIXTURE. That proves the MODULE behaves, and it proves the
// fixture models a gap — it does not prove the gap still exists in the data we ship. So the negative
// control above could stay green forever while the bucket quietly became dead code, or while a FIFTH
// zone went missing and nobody heard. That is the same failure in a new costume: evidence that lives
// somewhere other than where the thing it describes lives. gospel named it — find out where your
// evidence lives — after catching the identical shape in their own work.
//
// This does NOT resolve the three disagreeing lists (33 in zone-regions, 12 reachable via
// admin-to-list, 14 in groupings, disagreeing in BOTH directions). That is a data-ownership ruling
// for upg100 and the Driver, and picking a winner here would be exactly the silent join we have both
// refused. It only compares the region tree's own source against the joinable set and RECORDS the
// difference, so the disagreement cannot drift without someone being told.
const regionOf = readJson('zone-regions.json') // the region tree's source: zone id -> region
const adminToList = readJson('admin-to-list.json') // admin name -> zone id; its VALUES are joinable
const filed = new Set(Object.keys(regionOf))
const joinable = new Set(Object.values(adminToList).map(String))
const realGap = [...joinable].filter((id) => !filed.has(id)).sort()
// ★ WHAT THIS GAP COUNTS, AND WHAT IT DOES NOT. `joinable` is admin-to-list's VALUES — zones
// reachable by an admin-area join. That is NOT the same as every printable zone, so this arm can
// only ever report a LOWER BOUND on unfiled zones. upg100 proved the difference costs something:
// checking the property rather than the four names they were handed, they found deaf-old-world,
// deaf-new-world-asia-pacific, asia-india-uttarakhand and asia-india-west-bengal also unfiled,
// and none of the four is an admin-to-list value, so none of them appears in realGap. Two were
// then filed; the deaf pair look deliberate (there is a "deaf": "exempt" umbrella key). Stated
// here because a gap number that is quietly a lower bound reads exactly like a complete one.
ok(realGap.length > 0,
realGap.length > 0
? `the region tree really does omit ${realGap.length} joinable zone(s) TODAY — the bucket is load-bearing in the shipped data, not only in the fixture`
: 'INERT ARM: every joinable zone is now filed under a region, so this check no longer measures anything. That is not a pass. Re-derive the bucket against the current data or DELETE it and its component — do not leave a green arm guarding nothing.')
ok(realGap.join(',') === MISSING.join(','),
realGap.join(',') === MISSING.join(',')
? 'and it is exactly the four this check names, so the fixture above models the shipped data rather than a story about it'
: `THE GAP MOVED — re-derive. shipped now: [${realGap.join(', ')}] but this check asserts: [${MISSING.join(', ')}]. A zone ADDED here is groups that just became unreachable; a zone REMOVED got filed and the fixture is stale. Either way the numbers in the commit body are no longer true.`)
// ── the zones that got FILED stay filed (dx-shepherd RG12) ─────────────────────────────────────
// The gap arms watch the BAD direction. Filing is the good one, and it is exactly as revertible:
// drop a key from zone-regions and those groups leave the region tree again, silently, with the
// bucket the only thing still carrying them. Two of these three were never visible to the gap arms
// at all (see the coverage note below), so without this they could be unfiled with nothing to say so.
const FILED = ['asia-india-madhya-pradesh', 'asia-india-uttarakhand', 'asia-india-west-bengal']
for (const z of FILED) {
ok(filed.has(z), filed.has(z)
? `${z} is filed under a region, so it reaches readers through the tree and not only the bucket`
: `${z} IS NO LONGER FILED — it was given a region and has lost it, so its groups are back to being reachable only through the unassigned bucket. That is the silent direction: no error, just a shorter region tree.`)
}
ok(joinable.has('asia-china-xinjiang-uyghur-autonomous-region') && !filed.has('asia-china-xinjiang-uyghur-autonomous-region'),
'xinjiang specifically: joinable in admin-to-list, absent from zone-regions — its 100 groups are reachable ONLY through the bucket')
// ── the id ambiguity that is masked by a data coincidence (gospel, END-GOS-VLGC) ────────────────
// ★ REGION IDS AND ZONE IDS SHARE ONE NAMESPACE. selectedId emits one bare string with no kind, and
// the two sets OVERLAP in the shipped data. gospel found it while wiring and I re-measured it here
// rather than taking the report: 33 zone ids, 7 region ids, intersection exactly {europe}, whose
// region is itself. So `europe` cannot tell a consumer whether the Europe REGION row was clicked
// (expand to every European zone) or the Europe ZONE leaf (filter to that one).
//
// It is not live today ONLY because region `europe` contains exactly one zone, so both readings give
// the same set. That is a coincidence in the data, not a property of the design, and it is the same
// shape as the inert arm above: something green because reality currently happens to agree with it.
// The arm below is therefore the alarm, not the fix — the consumer builds the tree, so only the
// consumer can namespace its region rows, and gospel is doing that.
const zonesAll = new Set(Object.keys(regionOf))
const regionsAll = new Set(Object.values(regionOf).map(String))
const collide = [...zonesAll].filter((id) => regionsAll.has(id)).sort()
ok(collide.join(',') === 'europe',
collide.join(',') === 'europe'
? 'region ids and zone ids share one namespace and collide on exactly {europe} — measured, not assumed'
: `THE COLLIDING IDS CHANGED — re-derive. shipped now: [${collide.join(', ') || 'none'}], this check asserts: [europe]. Every id in both sets is one a consumer cannot disambiguate from the emitted string alone.`)
for (const id of collide) {
const members = Object.keys(regionOf).filter((z) => String(regionOf[z]) === id)
ok(members.length === 1,
members.length === 1
? `and region '${id}' still contains exactly one zone, so both readings of '${id}' give the same set — the ambiguity is MASKED, not absent`
: `THE '${id}' AMBIGUITY IS NOW LIVE: region '${id}' contains ${members.length} zones (${members.join(', ')}), so emitting the bare id '${id}' means either that whole region or the single zone of the same name, and the two now differ. Nothing on screen would say which — the reader just gets a smaller set. The consumer that BUILDS the tree must namespace its region rows (region:${id}) and leave leaves bare; this component cannot know which rows are regions.`)
}
console.log(fails ? `\nLIST-SELECTION: ${fails} FAIL(s)` : '\nLIST-SELECTION: serialisable ids out, and no joinable zone can be dropped')
process.exit(fails ? 1 : 0)