Generated zone titles ship for one-country zones only

Measured all 29 zones in en/es/ar with a real country-name supplier
before wiring anything, and the result split cleanly.

A one-country zone generates a correct title in every locale at zero
translation cost: Sudan / Sudán / السودان.

A multi-country zone generates a title WORSE than the hand-written
label it would replace, in English too, so this was never a
localization trade-off:

  "Middle East Core"      would become  "Iran + Yemen + 11 more"
  "Scattered / Diaspora"  would become  "Andorra + Cocos (Keeling)
                                         Islands + 2 more"

and no maxParts rescues it -- 2, 3, 4, 13 just walks toward a full
13-country enumeration. A multi-country zone has a name that is not a
list of its members, so no assembly of country names can produce it.
The generator is correct; the approach does not fit that tier.

The guard is in zone-title.js, not at the caller, because six product
call sites supply this across three surfaces (the legend three times,
posters, packets). A caller-side test would be repeated six times and
would drift the first time one was missed, silently regressing one
surface while the others stayed right.

Tier B now keeps its label through the same null-means-keep-yours path
tier C already used, so this widens an existing contract rather than
adding a new one.

check-zone-title-tier-a.mjs is two-sided on purpose: a gate that only
checked "the 3 generate" would pass a build that also regressed the
other 26. It asserts the 3 produce non-code titles that differ across
locales, AND that every other entry returns null so its label survives
byte-identical -- by value, not by count, since "26 declined" would
pass even if the wrong 26 declined.

Shown capable of failing four ways: removing the guard (the forbidden
regression), declining tier A too, a supplier returning raw codes, and
a supplier ignoring locale. The third exits 75 NOT MEASURED rather than
failing, because a broken supplier makes every zone decline and would
otherwise look like a clean pass.
This commit is contained in:
Doxa Maps 2026-09-28 23:39:57 +00:00
parent bad51cef2b
commit a4330484a4
2 changed files with 153 additions and 0 deletions

View file

@ -0,0 +1,127 @@
#!/usr/bin/env node
/**
* check-zone-title-tier-a — generated zone titles ship for ONE-COUNTRY zones and nothing else.
*
* zone-title.js can compose a localized zone title out of country names. MEASURED over all 29 zones
* in en/es/ar before wiring it, the result split cleanly:
* · a ONE-country zone generates a correct title in every locale, free (Sudan / Sudán / السودان)
* · a MULTI-country zone generates a title WORSE than the hand-written label it replaces, in
* ENGLISH TOO ("Middle East Core" → "Iran + Yemen + 11 more")
* so the ruling (dx-shepherd, 2026-09-28) is tier A only. This gate holds that line.
*
* ★ IT IS TWO-SIDED, AND THAT IS THE ENTIRE POINT. A gate that only checked "the 3 zones generate"
* would pass a build that ALSO regressed the other 25 — the exact failure being guarded against. So
* it asserts BOTH:
* (1) POSITIVE — the 3 one-country zones produce a non-null, non-code title in en/es/ar, and the
* three locales DIFFER (identical output across locales means the supplier is not localizing
* and every "translation" is really English).
* (2) NEGATIVE — every other zone returns null, so its caller keeps a BYTE-IDENTICAL label. The
* comparison is against the label in clusters.json, not against a remembered string.
*
* ⚠ The negative side is checked by VALUE, not by count. "25 zones declined" would pass even if the
* wrong 25 declined; each zone is named.
*
* Exit 0 pass · 1 fail · 75 NOT MEASURED (the supplier could not be built, so neither side was
* actually exercised — never reported as a pass).
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const PLUGIN = join(REPO, 'plugin-designs/my-upg-100-list')
const LOCALES = ['en', 'es', 'ar']
const clusters = JSON.parse(readFileSync(join(PLUGIN, 'data/clusters.json'), 'utf8'))
const zones = Object.values(clusters).filter(Array.isArray).flat()
const { zoneTitle, zoneCountryCodes } = await import(join(PLUGIN, 'data/zone-title.js'))
// ── the supplier, built the way a product caller builds it ───────────────────────────────────────
// ⚠ countryName.js CANNOT be imported here: it does `import table from '...json'` with no import
// attribute, which is fatal under Node. That is precisely why zone-title.js takes countryName
// INJECTED rather than importing it — so the same function runs under Node and under Vite. This
// gate reproduces the injection rather than working around it.
const A2_PATH = join(REPO, 'library/data/iso3-to-alpha2.json')
if (!existsSync(A2_PATH)) {
console.error(`ZONE-TITLE-A: NOT MEASURED (exit 75) — no alpha-2 table at ${A2_PATH}, so no title`)
console.error(' can be generated and neither side of this check was exercised.')
process.exit(75)
}
// ⚠ The table is { _meta, map } — the codes live under .map. Reading it one level too shallow makes
// EVERY lookup undefined, so every zone declines and the gate reports a clean "25 declined" while
// silently testing nothing. That happened during the measurement; it is why the supplier is
// sanity-checked below before any zone is judged.
const alpha2 = JSON.parse(readFileSync(A2_PATH, 'utf8')).map
const countryName = (iso3, locale) => {
const two = alpha2[String(iso3 || '').toUpperCase()]
if (!two) return null
try { return new Intl.DisplayNames([locale], { type: 'region' }).of(two) } catch { return null }
}
// ★ PROVE THE SUPPLIER CAN SUCCEED BEFORE TRUSTING ANY DECLINE. An empty result from a probe never
// shown capable of a non-empty one is not evidence of absence.
const probe = countryName('SDN', 'en')
if (probe !== 'Sudan') {
console.error(`ZONE-TITLE-A: NOT MEASURED (exit 75) — the country-name supplier is broken:`)
console.error(` countryName('SDN','en') returned ${JSON.stringify(probe)}, expected "Sudan".`)
console.error(' Every zone would decline and this gate would look green while testing nothing.')
process.exit(75)
}
const strings = { zone_join: '{a} + {b}', zone_join_more: '{a} + {n} more' }
const titleFor = (zone, locale) => zoneTitle(zone, strings, { countryName, locale })
const fails = []
const notes = []
// ── (1) POSITIVE: one-country zones generate, in every locale, and the locales differ ────────────
const single = zones.filter((z) => zoneCountryCodes(z.selector).length === 1)
if (!single.length) {
console.error('ZONE-TITLE-A: NOT MEASURED (exit 75) — no one-country zone exists to exercise the')
console.error(' positive case, so a green here would mean only that nothing was tested.')
process.exit(75)
}
for (const z of single) {
const got = {}
for (const lc of LOCALES) {
const t = titleFor(z, lc)
got[lc] = t
if (!t) { fails.push(`${z.id}: generates NOTHING in ${lc} — a one-country zone must produce a title`); continue }
if (t === z.id || /^[A-Z]{3}$/.test(t)) fails.push(`${z.id}: ${lc} title is a raw code (${t}), not a name`)
}
const distinct = new Set(Object.values(got).filter(Boolean)).size
if (distinct < 2 && Object.values(got).every(Boolean)) {
// Laos legitimately reads the same in en and es; require only that ar differs from en.
if (got.ar === got.en) fails.push(`${z.id}: ar and en are identical (${got.en}) — the supplier is not localizing`)
}
notes.push(` ok ${z.id.padEnd(22)} ${LOCALES.map((lc) => `${lc}=${got[lc]}`).join(' ')}`)
}
// ── (2) NEGATIVE: every other zone declines, so its label survives BYTE-IDENTICAL ────────────────
const multi = zones.filter((z) => zoneCountryCodes(z.selector).length !== 1)
for (const z of multi) {
for (const lc of LOCALES) {
const t = titleFor(z, lc)
if (t !== null) {
const n = zoneCountryCodes(z.selector).length
fails.push(`${z.id}: generated "${t}" in ${lc}, but a ${n}-country zone must DECLINE and keep its label "${z.label}"`)
}
}
}
// ⚠ COUNTED IN cluster ENTRIES, which is 29 — NOT the 28 leaf zones the rest of the product counts.
// The two deaf_lists entries collapse into one `deaf` leaf, so "26 decline" here and "25 decline"
// measured over leaves are the same fact at different granularity. Both deaf entries decline either
// way, so the ruling holds at both. Stated because the 28-vs-29 gap has already produced two real
// errors, and a reader who expects 25 should be able to see why this says 26 without re-deriving it.
notes.push(` ok ${String(multi.length).padEnd(22)} cluster entries decline in all ${LOCALES.length} locales, keeping their labels byte-identical`)
notes.push(` ${''.padEnd(22)} (= ${multi.length - 1} leaf zones; the 2 deaf entries collapse to 1 leaf and both decline)`)
// ── report ───────────────────────────────────────────────────────────────────────────────────────
for (const n of notes) console.log(n)
if (fails.length) {
console.error('ZONE-TITLE-A: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
console.error('\n Tier A (one country) generates; everything else keeps its hand-written label.')
process.exit(1)
}
console.log(`ZONE-TITLE-A: PASS — ${single.length} one-country zones generate in ${LOCALES.join('/')}; the other ${multi.length} decline and keep their labels unchanged.`)

View file

@ -136,6 +136,32 @@ export function countryLabel(iso3, opts = {}) {
export function zoneTitle(zone, strings, opts = {}) {
const codes = zoneCountryCodes(zone?.selector)
if (!codes.length) return null // tier C — declined, caller keeps its label
// ★ ONE COUNTRY ONLY (dx-shepherd, 2026-09-28). A multi-country zone DECLINES, exactly as tier C
// does, and keeps its hand-written label.
//
// This is not a limitation of the joiner below — the joiner works. It is that A MULTI-COUNTRY ZONE
// HAS A NAME THAT IS NOT A LIST OF ITS MEMBERS, so no assembly of country names can produce it.
// MEASURED over all 29 zones in en/es/ar before wiring anything, and the generated title was WORSE
// than the hand-written one it would replace — in ENGLISH TOO, so this was never a localization
// trade-off:
// "Middle East Core" would become "Iran + Yemen + 11 more" (2 named of 13)
// "Scattered / Diaspora" would become "Andorra + Cocos (Keeling) Islands + 2 more"
// and no maxParts rescues it: 2 → 3 → 4 → 13 just walks toward a full 13-country enumeration.
// "Middle East Core" names a REGION; "Scattered / Diaspora" names a KIND of zone. Country names
// cannot say either.
//
// ⚠ THE GUARD IS HERE, NOT AT THE CALLER, AND THAT IS DELIBERATE. Six product call sites supply
// this across three surfaces (the legend in upg-100-list.vue x3, posters, packets) — a caller-side
// `codes.length === 1` test would have to be repeated six times and would drift the first time one
// was missed, silently regressing one surface while the others stayed right. One guard, one
// behaviour, every surface.
//
// What still works: tier A generates in all 11 locales at zero translation cost (Sudan / Sudán /
// السودان). Tier B keeping its label is the SAME null-means-keep-yours contract tier C uses, so
// this widens an existing path rather than adding a new one.
if (codes.length > 1) return null
const maxParts = Math.max(1, opts.maxParts ?? 2)
const named = codes.map((c) => countryLabel(c, opts))