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.')
|