doxa-maps/internal/check-kpi-unit-once.mjs
doxa-maps-88655c70 b4a0387495 Translate the chrome that was English in all eleven locales
Measured under ?lang=ru on both composites: every Latin-script string left on the
page was ours. The map above was fully translated; underneath it the grid, the
map's own side menu and filter panel, and the whole research dashboard were
English in Arabic, Chinese, Hindi, Russian and everything else.

Three surfaces, three reasons, none of them a missing translation:

  the grid          read labels from a host-supplied `t` object that nothing ever
                    filled, with an English literal at each call site as fallback
  side menu +
  filter panel      plain string literals
  dashboard         labels declared as literals in dashboard.config.js — facet
                    rail, chart titles, KPI strip, table headers, segmented
                    control, search placeholder

New catalogues for all 11 locales (grid, controls, dashboard), REUSING the
catalogue's existing wording wherever the word already existed — Регион, Блок
родства, Население, Вовлечён, Графики — so two panels never name one thing two
ways, and nothing is coined that the project already has a word for.

Read through a new plugin-free tStatic() rather than useI18n, because these
components are also mounted by entries that install no i18n plugin. An unguarded
useI18n() throws and a thrown setup() removes the subtree with no build error —
the defect that had just blanked people-groups-grid everywhere (6603dc17).

★ THE CONFIG LABELS ARE GETTERS, AND A SPREAD WOULD HAVE LOOKED IDENTICAL.
dashboard.config.js is imported once at load; the locale is only known later. My
first version spread a getter object, and a spread COPIES THE RESOLVED VALUE, not
the accessor — every label would have frozen at whichever locale loaded first,
and it would have looked perfect in English. `get label() {…}` resolves on each
read.

MEASURED after, served built tree, identity checked first: Big Picture ru/ar and
Simple Research ru/zh have ZERO Latin-script chrome strings. What remains is
Mapbox's licence attribution (never translate), country and people-group names
(data), and 'UUPGs' — which every locale file deliberately keeps as the acronym.

Three gates went red on the way, two of them mine and worth recording:
  check:locale-reachable  slices this i18n module between two markers and EVALS
                          the slice; I had put tStatic between them, so the gate
                          hit an `export`. Moved to the end of the file.
  check:library-index     my import+helper above a component's docblock became
                          that component's DERIVED description. Moved below it.
  check:kpi-unit          its parser assumed single-brace entries, so getters made
                          it find ZERO pct KPIs — and it failed rather than
                          printing a green PASS about nothing, which is the gate
                          working. Parse now understands getters AND checks the
                          translated labels, so a '%' reintroduced in any of the
                          11 catalogues fails by name. Both controls fired.
2026-09-25 06:10:27 +00:00

107 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
/**
* check-kpi-unit-once.mjs — a KPI must print its unit exactly once.
*
* THE DEFECT THIS CATCHES. The dashboard rendered "99% % UNREACHED" and "1% ADOPTED %".
* `kpiText()` returns `k.value` unchanged when `kind === 'pct'`, and that value already carries its
* percent sign; the labels in dashboard.config.js carried a second one ('% unreached', 'Adopted %').
*
* ★ WHY IT SURVIVED REVIEW, which is the point of gating it. Each half is correct on its own. A
* label reading '% unreached' is a perfectly reasonable label. A formatter that returns a
* ready-formatted percentage is a perfectly reasonable formatter. Only the COMPOSITION is wrong,
* and nothing in either file mentions the other. It was found by reading the rendered screenshot —
* no amount of reading either source file would have shown it.
*
* WHAT IT ASSERTS. No `kind: 'pct'` KPI label contains a '%'. That is the whole rule, and it is
* one-sided on purpose: the VALUE is the right place for the unit (it is produced by the formatter
* that knows the number), so the label must not repeat it. If a future KPI kind formats without a
* unit, add that kind here rather than relaxing the rule.
*
* Parses the config as TEXT rather than importing it. dashboard.config.js is plain data, but the
* research-dashboard module graph reaches library/colors/_registry.js, which uses Vite's
* `import.meta.glob` and throws under plain node — the trap documented in
* documentation/reference.md. Text parsing keeps the gate hermetic: no build, no browser, no
* network, so it can live in build:unlocked.
*
* NEGATIVE-TESTED: restoring either label ('% unreached' or 'Adopted %') fails by name with exit 1.
*/
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 CFG = join(ROOT, 'plugin-designs/research-dashboard/dashboard.config.js')
if (!existsSync(CFG)) {
console.error('KPI-UNIT: FAIL — dashboard.config.js not found at ' + CFG.replace(ROOT + '/', ''))
process.exit(1)
}
const src = readFileSync(CFG, 'utf8')
// Strip comments before scanning. This file now DOCUMENTS the defect in a comment right above the
// fixed line, and a naive scan matches that prose and fails on the explanation of the fix — the
// same self-reference that has bitten three gates in this repo already.
// The translated labels live in the catalogue, so the rule has to reach them too.
const LOC_DIR = resolve(ROOT, 'library/i18n/locales')
const LOCALES = existsSync(LOC_DIR) ? readdirSync(LOC_DIR) : []
const catalogue = (loc) => {
try { return JSON.parse(readFileSync(resolve(LOC_DIR, loc, 'dashboard.json'), 'utf8')) } catch { return {} }
}
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
// Each KPI entry is an object literal: { id: '…', label: '…', kind: '…', … }.
// ★ IT MAY NOW CONTAIN ONE LEVEL OF NESTED BRACES. The labels became GETTERS when the dashboard was
// translated (`get label() { return tStatic('dashboard.unreached') || 'unreached' }`), and the old
// single-brace pattern stopped matching every entry — the gate found ZERO pct KPIs and failed
// exactly as it was built to, rather than printing a green PASS about nothing. That refusal is the
// gate working; this is the parse catching up with the config, not a relaxation of the rule.
const entries = [...code.matchAll(/\{(?:[^{}]|\{[^{}]*\})*\bid:\s*'([^']+)'(?:[^{}]|\{[^{}]*\})*\}/g)]
const offenders = []
let pctSeen = 0
for (const m of entries) {
const body = m[0]
const kind = (body.match(/\bkind:\s*'([^']+)'/) || [])[1]
if (kind !== 'pct') continue
pctSeen++
// Two label forms: a plain literal, or a getter that falls back to one.
const plain = (body.match(/\blabel:\s*'([^']*)'/) || [])[1]
const viaGetter = body.match(/get label\(\)\s*\{[^}]*?tStatic\('([^']+)'\)\s*\|\|\s*'([^']*)'/)
const label = plain ?? (viaGetter ? viaGetter[2] : undefined)
if (label && label.includes('%')) offenders.push({ id: m[1], label })
// ★ AND CHECK WHAT A NON-ENGLISH READER SEES. The rule is about the rendered label carrying a
// second percent sign, and a translation can reintroduce one where the English fallback has none.
if (viaGetter) {
const key = viaGetter[1].replace(/^dashboard\./, '')
for (const loc of LOCALES) {
const v = catalogue(loc)[key]
if (typeof v === 'string' && v.includes('%')) offenders.push({ id: m[1] + ' [' + loc + ']', label: v })
}
}
}
if (!pctSeen) {
// A gate that finds nothing to check must say so rather than printing a green PASS. If the
// config is refactored so this regex stops matching, silence would read as success forever.
console.error("KPI-UNIT: FAIL — found no `kind: 'pct'` KPI entries to check.")
console.error(' Either the config changed shape or the parse broke. A gate with nothing to')
console.error(' assert is not passing; it is not running.')
process.exit(1)
}
if (offenders.length) {
console.error(`KPI-UNIT: FAIL — ${offenders.length} percent KPI label(s) carry a redundant '%'.\n`)
for (const o of offenders) {
console.error(` id "${o.id}" has label '${o.label}'`)
console.error(` renders as "<value>% ${o.label.toUpperCase()}" — the unit twice.`)
}
console.error("\n kpiText() returns k.value unchanged for kind:'pct', and that value already")
console.error(' carries its percent sign. The VALUE owns the unit; the LABEL says what is being')
console.error(' measured. Drop the % from the label.')
process.exit(1)
}
console.log(`KPI-UNIT: PASS — ${pctSeen} percent KPI(s), none repeating the unit in its label.`)
console.log(' Each half of this bug looks correct alone; only the composition was wrong, which is')
console.log(' why it was found by reading a screenshot rather than by reading either source file.')