Gate that map, legend and charts share one palette
Some checks are pending
build-gate / build (push) Waiting to run

The dashboard aggregations module used to hold two private palettes the map
had never seen, and picked chart colours with
CATEGORICAL[i % CATEGORICAL.length]. Two failures came out of that: near-miss
regions (Africa #e74c3c in a chart, #ff2e2e on the map) and religion coloured
by array index. No religion key existed in the private table, so every
religion fell through to its position in a list sorted BY COUNT -- and counts
move when you cross-filter, so a religion changed colour as you filtered.
Islam and Hinduism swapped, Buddhism and Christianity swapped.

That is already fixed: both private palettes deleted, every hex now read from
the same strategy modules the map paints with. But the fix is a deletion plus
a convention, and nothing enforces a convention. The next person adding a
facet writes a lookup table because it is the obvious thing to do, and the
divergence returns with no error and no crash -- just two surfaces disagreeing
about what blue means.

Reads the BUILT bundles rather than the source because the registry discovers
strategies with import.meta.glob, which plain node cannot evaluate. That makes
it a test of the glob too: if the registry ever silently discovers nothing,
the bundles lose the palette and this fails, where a source-only check would
still pass.

Comments are stripped before scanning, because both consumer files describe
this defect at length in their headers and a naive scan matches the
documentation of the fix -- the same self-reference that made two gates report
on their own prose this week.

Negative-tested three ways: a reintroduced private palette, an [i % len]
lookup, and corrupting four of ten religion hexes in a built bundle, which
fails as a partial palette and names the missing hexes.
This commit is contained in:
doxa-maps-88655c70 2026-09-22 18:00:51 +00:00
parent b2fc0e4dda
commit 68c1686cf5
2 changed files with 156 additions and 1 deletions

View file

@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* check-one-palette.mjs — map, legend and charts must colour the same value the same way.
*
* THE DEFECT THIS PROTECTS AGAINST. `useDashboardAggregations.js` used to declare two PRIVATE
* palettes the map had never seen, and picked chart colours with:
*
* colorFor(key, i) => REGION_COLORS[key] || CATEGORICAL[i % CATEGORICAL.length]
*
* Two failures came out of that, and the second is a correctness bug rather than a taste one:
* 1. near-misses — 'Africa' was #e74c3c in a chart and #ff2e2e on the map; Latin America was
* orange in one and yellow in the other. A near-miss reads as a rendering artefact, which is
* worse than an obvious mismatch because nobody files it;
* 2. religion coloured BY ARRAY INDEX. No religion key existed in the private table, so every
* religion fell through to CATEGORICAL[i] — its position in a list sorted BY COUNT. Counts
* move when you cross-filter, so a religion CHANGED COLOUR AS YOU FILTERED. Measured: Islam
* and Hinduism swapped, Buddhism and Christianity swapped. Colour carrying no meaning.
*
* That was fixed by deleting both private palettes and reading every hex from the same colour
* strategy modules the map paints with (`library/colors/`, auto-discovered by `_registry.js`).
*
* ★ SO WHY A GATE, IF IT IS ALREADY FIXED. Because the fix is a DELETION plus a convention, and
* nothing enforces a convention. The next person adding a facet writes a lookup table because it is
* the obvious thing to do, and the divergence returns silently — no error, no crash, just two
* surfaces disagreeing about what blue means. The failure mode of this bug class is that it looks
* fine.
*
* ★ WHY IT READS THE BUILT BUNDLES AND NOT THE SOURCE. `library/colors/_registry.js` discovers
* strategies with `import.meta.glob`, a VITE BUILD-TIME feature — plain `node` cannot import it (it
* throws "(intermediate value).glob is not a function"; see the gate-authoring section in
* documentation/reference.md). So this gate parses the palettes out of the SOURCE modules as the
* expected set, then asserts those exact hexes survived into the SHIPPED bundles. That also makes
* it a test of the glob resolution itself: if the registry ever silently discovers nothing, the
* bundles lose the palette and this fails, where a source-only check would still pass.
*
* WHAT IT ASSERTS:
* 1. every strategy module's palette hexes appear in the built bundle of each consumer, so map,
* legend and chart are all drawing from the same set;
* 2. the consumers declare NO private palette of their own — a literal hex table inside a
* dashboard composable is the exact shape that caused this;
* 3. no consumer colours a categorical value by ARRAY INDEX (`[i % …]`, `[idx % …]`), the defect
* that made a religion change colour under cross-filtering.
*
* PRECONDITION: a built tree. Skips loudly (not silently) when `public/doxa-maps` is absent, the way
* CI sees it — a gate that passes when it did not run is the failure mode this repo keeps hitting.
*
* NEGATIVE-TESTED: reintroducing a private `const CATEGORICAL = ['#...']` into the aggregations
* composable fails assertion 2 by name; an `[i % CATEGORICAL.length]` lookup fails assertion 3.
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const COLORS = join(ROOT, 'library/colors')
const BUILT = join(ROOT, 'public/doxa-maps')
if (!existsSync(BUILT)) {
console.error('ONE-PALETTE: SKIP — no built tree at public/doxa-maps. Run `bun run build` first.')
console.error(' Reported as a SKIP with a non-silent message rather than a pass: a gate that')
console.error(' reports success when it never ran is worse than no gate.')
process.exit(0)
}
// ── the expected palettes, read from the strategy modules the MAP uses ────────────────────────
const strategies = {}
for (const f of readdirSync(COLORS)) {
if (!f.endsWith('.js') || f.startsWith('_') || f.startsWith('.')) continue
const src = readFileSync(join(COLORS, f), 'utf8')
// Only the PALETTE export — a module may also contain incidental hexes (a default, a comment)
// that no consumer is obliged to ship.
const at = src.indexOf('export const PALETTE')
if (at < 0) continue
const hexes = [...new Set([...src.slice(at).matchAll(/#[0-9a-fA-F]{6}/g)].map((m) => m[0].toLowerCase()))]
if (hexes.length) strategies[f.replace(/\.js$/, '')] = hexes
}
// ── the consumers that must agree ─────────────────────────────────────────────────────────────
const CONSUMERS = [
{ name: 'research-dashboard', bundle: 'research-dashboard/research-dashboard.js' },
{ name: 'dashboard-plus-grid', bundle: 'dashboard-plus-grid/dashboard-plus-grid.js' },
]
const SOURCES = [
'plugin-designs/research-dashboard/src/composables/useDashboardAggregations.js',
'plugin-designs/research-dashboard/src/composables/useTreeLegendColors.js',
]
const errs = []
// 1 — the shipped bundles carry the strategy palettes
let checkedPairs = 0
for (const c of CONSUMERS) {
const p = join(BUILT, c.bundle)
if (!existsSync(p)) { errs.push(`bundle missing: ${c.bundle} — build is incomplete, so this gate cannot judge it.`); continue }
const js = readFileSync(p, 'utf8').toLowerCase()
for (const [strat, hexes] of Object.entries(strategies)) {
const missing = hexes.filter((h) => !js.includes(h))
checkedPairs++
// A consumer need not use EVERY strategy, but if it uses one at all it must use the whole
// palette — a partial match is the near-miss case (some values shared, others re-invented).
if (missing.length && missing.length !== hexes.length) {
errs.push(
`${c.name} carries only ${hexes.length - missing.length}/${hexes.length} of the "${strat}" palette.\n` +
` missing: ${missing.slice(0, 6).join(' ')}${missing.length > 6 ? ' …' : ''}\n` +
' A PARTIAL palette is the near-miss case: some values agree with the map and others\n' +
' were re-invented locally, which reads as a rendering artefact rather than a bug.'
)
}
}
}
// 2 + 3 — the consumers declare no palette and key no colour by index
for (const rel of SOURCES) {
const p = join(ROOT, rel)
if (!existsSync(p)) continue
const src = readFileSync(p, 'utf8')
// Strip comments first: this very defect is DESCRIBED at length in those files' headers, and a
// naive scan matches the description and fails on the documentation of the fix. (Two gates this
// week reported on their own prose; see check-no-orphan-gates.)
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
const table = code.match(/const\s+[A-Z_]*(?:PALETTE|COLORS|CATEGORICAL)[A-Z_]*\s*=\s*[[{][^;]*#[0-9a-fA-F]{6}/)
if (table) {
errs.push(
`${rel} declares a PRIVATE palette.\n` +
` ${table[0].replace(/\s+/g, ' ').slice(0, 84)}…\n` +
' Every hex must come from library/colors/ via the registry, so the map, the legend and\n' +
' the charts cannot drift apart. This is the exact shape that produced the original bug.'
)
}
const byIndex = code.match(/\[\s*(?:i|idx|index)\s*%[^\]]*\]/)
if (byIndex) {
errs.push(
`${rel} picks a colour by ARRAY INDEX: ${byIndex[0]}\n` +
' A categorical colour must be keyed by the VALUE. Index position depends on sort order,\n' +
' and these lists are sorted BY COUNT — so cross-filtering silently reassigns colours and\n' +
' the colour stops meaning anything.'
)
}
}
if (errs.length) {
console.error('ONE-PALETTE: FAIL\n')
errs.forEach((e) => console.error(' ' + e + '\n'))
process.exit(1)
}
const names = Object.keys(strategies)
console.log(`ONE-PALETTE: PASS — ${names.length} strategy palette(s) (${names.join(', ')}) checked across`)
console.log(` ${CONSUMERS.length} built bundle(s) and ${SOURCES.length} source consumer(s); ${checkedPairs} bundle/palette pair(s).`)
console.log(' No private palettes, no index-keyed colours. Read from the BUILT bundles on purpose:')
console.log(' the registry uses import.meta.glob, so this also proves the glob actually resolved —')
console.log(' a source-only check would still pass if the registry silently discovered nothing.')

View file

@ -96,7 +96,8 @@
"verify:browser-all:plugin-gallery": "node internal/verify-browser-all.mjs --plugin-gallery",
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
"check:shell-scroll": "node internal/verify-shell-scroll.mjs",
"check:no-orphan-gates": "node internal/check-no-orphan-gates.mjs"
"check:no-orphan-gates": "node internal/check-no-orphan-gates.mjs",
"check:one-palette": "node internal/check-one-palette.mjs"
},
"devDependencies": {
"@capacitor/android": "^7.6.9",