useDashboardAggregations has a split personality, and only half of it could ever
move. `lbl` and `aggregate` are pure counting — eleven lines, a Map, a sort, a
topN rollup, no facet name and no colour. Everything else in that file is
research: `colorFor` with its hardcoded religion / wagf_region / affinity_bloc
branches, the religion palettes, the donut and bar builders. Those stay, and
DashboardChartsRail keeps importing them.
The generic pair was what blocked kit landing 4c: the facet rail needs only that
half, and while it lived beside the palettes the rail could not follow it into the
library. Same split, same one-cycle re-export as 4a. Bodies verbatim.
★ AND THE ROLLUP BUCKET SAID "Other" IN EVERY LOCALE. aggregate() pushes
`{ key: 'Other', other: true }` for the tail, and that English word was rendered
directly by the facet rail and the charts rail. check:i18n-keys-exist could not
see it, because there was no T() call to inspect — the string was never a lookup,
it was a KEY being used as a LABEL. I found it while ASSESSING this landing rather
than while doing it, and it is fixed here rather than later for one reason: the
moment this module becomes kit code, an unfixed bug in it is exported to every
dashboard built from the kit.
THE TWO HALVES PULL IN OPPOSITE DIRECTIONS, which is the whole difficulty. The KEY
must stay English: the rail tests it to make the row inert, and the charts rail
refuses to filter on it, so a localized key would make the bucket CLICKABLE in
Arabic and filter on a value no row carries — an empty board, reading as a data
problem. The LABEL must not stay English. So OTHER_KEY is the sentinel,
otherLabel() reads dashboard.other, and they live next to the function that
produces the bucket so they cannot drift. Eleven locales, catalogue first.
This is the value/label trap for the third time — religion families, the language
tiers, and now a bucket in generic code. The third is the worst place for it,
which is why the rule now travels WITH the code that creates the value rather than
being remembered at each render site.
check:legend-mirrors grows arm 4, asserting both directions: translate the
sentinel and it fails; render the key instead of the label and it fails. Both
mutants run.
check-one-palette now scans the new module as well. Its subject — private palette
declarations — stayed in the plugin half, so nothing moved; adding the library
path means a palette smuggled into kit code later is caught by the same rule
instead of escaping into the library.
Library index regenerated in-commit. Source only. STUDIO: needs a rebuild of
research-dashboard and dashboard-plus-grid. The internal/ hunk is four lines in
check-one-palette.mjs.
203 lines
11 KiB
JavaScript
203 lines
11 KiB
JavaScript
#!/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'
|
|
import { TREE_NAME } from './_tree-name.mjs'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const COLORS = join(ROOT, 'library/colors')
|
|
const BUILT = join(ROOT, `public/${TREE_NAME}`)
|
|
|
|
if (!existsSync(BUILT)) {
|
|
console.error(`ONE-PALETTE: SKIP — no built tree at public/${TREE_NAME}. 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 hexes of the PALETTE export ONLY: from `export const PALETTE` to its matching closing brace, and
|
|
* only inside string literals. It used to match every hex from the export to the END OF THE FILE,
|
|
* comments included, so a later note such as "REJECTED #AF7AC5, scored 2.95, under the 3.0 floor"
|
|
* (affinity-block.js) obliged every consumer to ship a colour rejected on WCAG grounds.
|
|
* Returns null when the module has no PALETTE export.
|
|
*/
|
|
export function paletteHexes(src) {
|
|
const at = src.indexOf('export const PALETTE')
|
|
if (at < 0) return null
|
|
const open = src.indexOf('{', at), openArr = src.indexOf('[', at)
|
|
const start = [open, openArr].filter((i) => i >= 0).sort((a, b) => a - b)[0]
|
|
if (start === undefined) return []
|
|
const close = { '{': '}', '[': ']' }[src[start]]
|
|
let depth = 0, i = start, quote = null
|
|
const strings = []
|
|
let cur = ''
|
|
for (; i < src.length; i++) {
|
|
const c = src[i], n = src[i + 1]
|
|
if (quote) {
|
|
if (c === '\\') { cur += c + n; i++; continue }
|
|
if (c === quote) { strings.push(cur); cur = ''; quote = null; continue }
|
|
cur += c; continue
|
|
}
|
|
if (c === '/' && n === '/') { i = src.indexOf('\n', i); if (i < 0) break; continue }
|
|
if (c === '/' && n === '*') { i = src.indexOf('*/', i + 2) + 1; if (i <= 0) break; continue }
|
|
if (c === "'" || c === '"' || c === '`') { quote = c; continue }
|
|
if (c === src[start]) depth++
|
|
else if (c === close && --depth === 0) break
|
|
}
|
|
return [...new Set(strings.flatMap((t) => [...t.matchAll(/#[0-9a-fA-F]{6}\b/g)].map((m) => m[0].toLowerCase())))]
|
|
}
|
|
|
|
if (process.argv.includes('--self-test')) {
|
|
const src = [
|
|
"export const PALETTE = {",
|
|
" a: '#112233', // not #445566 (a comment inside the export)",
|
|
" /* nor #778899 */ b: \"#aabbcc\",",
|
|
"}",
|
|
"// REJECTED #af7ac5, scored 2.95, under the 3.0 floor",
|
|
"export const OTHER = { c: '#ddeeff' }",
|
|
].join('\n')
|
|
const got = paletteHexes(src).join(',')
|
|
if (got !== '#112233,#aabbcc') { console.error(`ONE-PALETTE: SELF-TEST FAIL — read ${got}, want #112233,#aabbcc`); process.exit(1) }
|
|
console.log('ONE-PALETTE: SELF-TEST PASS — only the PALETTE export\'s string values are read; comments and later code are not.')
|
|
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')
|
|
const hexes = paletteHexes(src)
|
|
if (hexes && 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',
|
|
// kit landing 4c-1 split the generic counting half out of the file above. Its palettes STAYED, so
|
|
// this gate's subject did not move — but the new module is scanned too, so a private palette
|
|
// added to kit code in future is caught on the same rule rather than escaping into the library.
|
|
'library/composables/useFacetAggregation.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.')
|