doxa-maps/internal/check-country-names.mjs
Doxa Maps 1d3a929d51 Gate the country-name join, and record that the wiring was already done
Two cards, BUILD-LOCALIZED-COUNTRY-NAMES-ISO3-TO-ISO2 and
BUILD-ISO3-TABLE-COVERS-ALL-242-COUNTRIES. I measured before building and most
of both already exists: countryName(iso3, lang) is in library/utils, and the
bridge is already 242 entries with an exact correspondence to countries.json —
0 codes missing, 0 orphans, verified in both directions rather than by trusting
the table's own _meta. All 242 resolve in en, ar and zh today.

So what was actually missing is the thing that keeps it true. Writing the table
and keeping it correct are separate acts, and only the first one looks like work
— which is the same reason this repo already has a gate for orphaned gates.

check:country-names asserts both directions of drift (a data code with no
alpha-2, an alpha-2 the data never uses) and that every code resolves to a real
NAME rather than to its own input, which is what Intl returns for a well-formed
code it does not know.

★ IT CARRIES TWO ARMS THAT MUST FAIL. A coverage gate that only counts hits
passes identically on 242 correct entries and on a table where every lookup
silently returns its input, so the last block feeds it an unassigned code and a
malformed one and FAILS if either is reported as resolved. Mutation-tested in
both directions before committing: deleting TCD from the bridge fails with
"1 code(s) with no alpha-2"; pointing TCD at a bogus QQ fails with three
unresolved lookups, one per locale. Table restored byte-identical after.

ar and zh are not decoration: ar is our only RTL locale, and zh uses a script
where an untranslated Latin code is unmistakable on sight.

NOT WIRED INTO THE REGIONS TABLES AND POPUPS, deliberately, against the brief.
All four regions fetches already send &lang=, so the API returns country labels
in the reader's language and summarizeRegions names each country from that
label — those surfaces are already localized. Swapping countryName() in would
replace a correct localized name with a differently-spelled one: Intl's English
differs from our canonical names for 42 of 242 countries ("Cabo Verde" ->
"Cape Verde", "Bonaire, Sint Eustatius, and Saba" -> "Caribbean Netherlands").
I re-measured that 42 rather than trusting the header that claims it. Changing
which name is canonical is a product decision and it would break clone parity
against live doxa.life, so it is not mine to make inside a localization card.

public/README.md's bundle count moves 24 -> 25: generated by bun run tree when
the country-map bundle was added on the parent commit, not hand-edited.
2026-09-29 01:38:07 +00:00

70 lines
3.6 KiB
JavaScript

/**
* check-country-names.mjs — every ISO3 our data uses resolves to a localized country name.
*
* Guards the join, not the library. Intl.DisplayNames is the platform's; what can rot here is
* OURS: library/data/iso3-to-alpha2.json (the alpha-3 -> alpha-2 bridge) drifting from
* library/data/countries.json (the codes the data actually uses). A country added to one and not
* the other renders as a bare code — a silent defect, because a code still looks like a value.
*
* ★ WHY IT ASSERTS THE ARMS THAT MUST FAIL TOO. A coverage gate that only counts hits passes just
* as happily on a table of 242 correct entries as on one where every lookup quietly returns its
* input. So the last block feeds it a code that does not exist and a code that is structurally
* invalid, and FAILS if either is reported as resolved. Without that, this file proves nothing
* about the 242 above it.
*/
import { readFileSync } from 'node:fs'
const read = (p) => JSON.parse(readFileSync(new URL(p, import.meta.url), 'utf8'))
const table = read('../library/data/iso3-to-alpha2.json').map
const countries = read('../library/data/countries.json')
// ar and zh are the two the brief names, and they are the two worth naming: ar is our only RTL
// locale and zh uses a script where an untranslated Latin code is unmistakable on sight.
const LOCALES = ['en', 'ar', 'zh']
const fail = []
// ── 1. the bridge covers every code the data uses, in both directions ──────────────────────────
const dataCodes = countries.map((c) => c.value)
const missing = dataCodes.filter((c) => !table[c])
const orphans = Object.keys(table).filter((c) => !dataCodes.includes(c))
if (missing.length) fail.push(`${missing.length} code(s) in countries.json with no alpha-2: ${missing.slice(0, 8).join(', ')}`)
if (orphans.length) fail.push(`${orphans.length} code(s) in the table that the data never uses: ${orphans.slice(0, 8).join(', ')}`)
// ── 2. every code resolves to a real NAME, in every locale we check ────────────────────────────
const dn = {}
for (const l of LOCALES) {
try { dn[l] = new Intl.DisplayNames([l], { type: 'region' }) } catch { fail.push(`Intl.DisplayNames unavailable for '${l}'`) }
}
let checked = 0
const unresolved = []
for (const iso3 of dataCodes) {
const a2 = table[iso3]
if (!a2) continue
for (const l of LOCALES) {
if (!dn[l]) continue
let name = null
try { name = dn[l].of(a2) } catch { name = null }
// An unknown-but-well-formed region comes back as the INPUT. That is not a name.
if (!name || name === a2) unresolved.push(`${iso3}->${a2} @${l}`)
checked++
}
}
if (unresolved.length) fail.push(`${unresolved.length} unresolved: ${unresolved.slice(0, 8).join(', ')}`)
// ── 3. THE ARMS THAT MUST FAIL — without these, everything above is unfalsifiable ──────────────
const mustNotResolve = [
['ZZZ', 'a code that is well-formed but assigned to nothing'],
['QQ', 'a two-letter code with no country behind it'],
]
for (const [code, why] of mustNotResolve) {
let name = null
try { name = dn.en ? dn.en.of(code) : null } catch { name = null }
if (name && name !== code) fail.push(`NEGATIVE ARM BROKEN: '${code}' (${why}) resolved to "${name}" — this gate cannot distinguish a hit from a miss`)
}
if (fail.length) {
console.error('COUNTRY-NAMES: FAIL')
for (const f of fail) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log(`COUNTRY-NAMES: PASS — ${dataCodes.length} code(s) resolve in ${LOCALES.join('/')} (${checked} lookups), and both negative arms still fail.`)