doxa-maps/internal/check-plural-forms.mjs
Doxa Maps 7067c0ae96 Fix what an adversarial review of the print standard confirmed: 21 defects, each re-checked against the code
Ten agents read today's branch (five finders, one skeptic per area who had to confirm each finding
against the current code); 21 distinct defects survived. All are fixed here.

Generator and its proof
- generateCountrySheet fed only ADM0, so a country beside India or China printed without their
  provinces (Nepal: 251 KB against Node's 921 KB). It loads the full boundary set, as the driver does.
- check:poster-browser-equiv rebuilt its own feed, so it could not see that. It now calls what the
  browser calls (makeZoneSheet, generateCountrySheet — Nepal), deletes the target before the Node run,
  runs Node with a clean environment, and FAILS (not "not measured") when the Node generator breaks.
  Proven two-sided: it fails with the ADM1 fix reverted.
- The facts table's country list sorted in the reader's browser locale; pinned to en-US (Node's
  default, so Node's output still hashes to the baseline).
- zone-sheet-print.js imports the library relatively, so bun, Node and the bundler resolve it alike.

Print seams and wiring
- The wall's Poster Map button used its own stored-file fetch and never the generated sheet, so every
  country poster 404'd on the published tree; it now prints the generated sheet first.
- Country Posters wall: card stocks passed (its Card Deck could never print), iso3-names passed (zh/ar
  page 1 kept English names), the stored fallback reads previews/ (where the tree serves them).
- Card-stock labels render at render time, so a locale change relabels them.
- The records provider caches by language AND list, and does not keep a failed membership load.
- The UPG-100 legend's Card Deck returns quietly on a parent row instead of an evaluator error; the
  selection fetch uses PRINT_FIELDS.

Packet
- The centered-set caption counts what is DRAWN (a ring too full to separate reports its remainder).
- Each ring's number lane is sized to its own radius (ring 1 put a dot under its badge).
- Per-box line keeps a decimal below 10 people ("≈ 0" for small lists); worksheet rows name the country;
  the localized poster kicker is fitted against the poster's real 19px, not 22.

Plurals and gates
- regions.countryCount / regionCount were authored in English zero|one|other order in de, es, fr, hi, it
  and pt, so CLDR picked "27 Land", "1 países". Rewritten in each locale's own order; check:plural-forms
  now fails a string in English order (proven on the old German string).
- check:optional-t-equivalence (gates:post) crashed on the new plural fallback's imports; it injects the
  real cldrChoiceIndex and asserts the plural path (ru 21/3/5, en 0/1/7).
- check:packets-all and check-packet-render counted people as cards only, so every lightweight packet
  read as "a packet of nobody"; worksheet rows count, and the browser gate checks the grid's top fill
  (the renamed .lc-frame) instead of the retired scatter. check:packet-variants now reads the grid itself
  — 5,200 boxes, Jesus' 1,716 from the top, the % shading from the top — proven: it fails on a renderer
  that shades the last boxes.
gates:pre 68/68, check:optional-t-equivalence PASS, both bundles build.
2026-09-29 06:23:19 +00:00

115 lines
6.3 KiB
JavaScript

/**
* check-plural-forms.mjs — the three things a CLDR pluralRules map can silently break.
*
* Installing a pluralRules map changes how EVERY existing pipe string is indexed, so this gate is
* the precondition for that map and the guard afterwards. Three assertions, all of which can fail:
*
* 1. FORM COUNT. A locale's pipe string must supply as many forms as its CLDR categories, or the
* pick runs off the end of the list and silently clamps — ru needs 3+, ar needs 6.
* 2. THE tStatic PATH. Seven bundles install no vue-i18n at all and read strings through tStatic,
* which does no plural selection whatsoever: a pipe string reaches the page as the literal
* "a | b | c". So no pipe key may be read through tStatic/useOptionalT.
* 3. ZERO. vue-i18n's 3-form default is zero|one|many, but CLDR for en is one|other — so an
* English string written "none | one | {n}" changes MEANING at n=0 under the map. Every pipe
* key is rendered at 0 and must not regress.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const LOC_DIR = new URL('../library/i18n/locales/', import.meta.url).pathname
const SRC_DIRS = [new URL('../plugin-designs/', import.meta.url).pathname, new URL('../library/', import.meta.url).pathname]
const LOCALES = readdirSync(LOC_DIR).filter((d) => !d.startsWith('.'))
const fail = []
// Collect every pipe-bearing catalogue value, as ns.key -> { locale: value }
const pipes = {}
for (const loc of LOCALES) {
const dir = join(LOC_DIR, loc)
if (!existsSync(dir)) continue
for (const f of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
const ns = f.slice(0, -5)
let doc
try { doc = JSON.parse(readFileSync(join(dir, f), 'utf8')) } catch { continue }
const walk = (o, path) => {
if (o && typeof o === 'object') { for (const [k, v] of Object.entries(o)) walk(v, path ? `${path}.${k}` : k) }
else if (typeof o === 'string' && o.includes('|')) {
// A literal pipe inside prose is not a plural form; require the vue-i18n " | " shape.
if (!/\s\|\s/.test(o)) return
;(pipes[`${ns}.${path}`] ||= {})[loc] = o
}
}
walk(doc, '')
}
}
// ── 1. FORM COUNT vs CLDR categories ──────────────────────────────────────────────────────────
const catCount = (loc) => {
try { return new Intl.PluralRules(loc).resolvedOptions().pluralCategories.length } catch { return 2 }
}
for (const [key, byLoc] of Object.entries(pipes)) {
for (const [loc, val] of Object.entries(byLoc)) {
const forms = val.split(/\s\|\s/).length
const need = catCount(loc)
if (forms < need) fail.push(`FORM COUNT ${key} @${loc}: ${forms} form(s) for ${need} CLDR categories — the pick clamps`)
}
}
// ── 1b. AUTHORING ORDER — the forms must be in the locale's CLDR order, not English's ──────────
// vue-i18n's own rule for English reads zero|one|other; every other locale is picked by CLDR into its
// OWN categories (de one|other, es one|many|other). A string copied in English order therefore picks the
// wrong form while its form COUNT looks fine — measured: German printed "27 Land", Spanish "1 países".
// Two signatures of English order, both impossible in a correctly ordered string:
// · 3 forms in a locale with 2 categories (de, hi): the extra form is English's zero
// · 3 forms in a 3-category locale where the first equals the last and the middle differs: "N plural |
// N singular | N plural" is English's zero|one|other, never CLDR's one|many|other
for (const [key, byLoc] of Object.entries(pipes)) {
for (const [loc, val] of Object.entries(byLoc)) {
if (loc === 'en') continue
const f = val.split(/\s\|\s/)
const need = catCount(loc)
if ((f.length === 3 && need === 2) || (f.length === 3 && need === 3 && f[0] === f[2] && f[1] !== f[0])) {
fail.push(`ORDER ${key} @${loc}: authored in English zero|one|other order — CLDR picks the wrong form (write it as the locale's own categories: ${(() => { try { return new Intl.PluralRules(loc).resolvedOptions().pluralCategories.join('|') } catch { return '?' } })()})`)
}
}
}
// ── 2. no pipe key on the tStatic / useOptionalT path ─────────────────────────────────────────
const sources = []
const collect = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
const p = join(dir, e.name)
if (e.isDirectory()) collect(p)
else if (/\.(vue|js|mjs)$/.test(e.name)) sources.push(p)
}
}
for (const d of SRC_DIRS) if (existsSync(d)) collect(d)
for (const key of Object.keys(pipes)) {
for (const p of sources) {
const src = readFileSync(p, 'utf8')
if (new RegExp(`(?:tStatic|optionalT|useOptionalT)\\s*\\(\\s*['"\`]${key.replace('.', '\\.')}`).test(src)) {
fail.push(`tStatic PATH ${key} read in ${p.split('/').slice(-2).join('/')} — tStatic does no plural selection, the page would show "a | b | c"`)
}
}
}
// ── 3. ZERO is rendered for every pipe key ────────────────────────────────────────────────────
// Asserted structurally: a locale whose CLDR set has an explicit `zero` category must author one,
// and any string relying on a bare 0-form must say so rather than lean on vue-i18n's old index.
for (const [key, byLoc] of Object.entries(pipes)) {
for (const [loc, val] of Object.entries(byLoc)) {
let cats = []
try { cats = new Intl.PluralRules(loc).resolvedOptions().pluralCategories } catch { cats = [] }
if (cats.includes('zero') && val.split(/\s\|\s/).length < cats.length) {
fail.push(`ZERO ${key} @${loc}: locale has an explicit CLDR 'zero' category and the string does not author it`)
}
}
}
if (fail.length) {
console.error('PLURAL-FORMS: FAIL')
for (const f of fail) console.error(` ✗ ${f}`)
process.exit(1)
}
const n = Object.keys(pipes).length
console.log(`PLURAL-FORMS: PASS — ${n} plural key(s) across ${LOCALES.length} locale(s): form counts cover their CLDR categories and are written in the locale's own order, none is read through tStatic, and every zero-category locale authors its zero form.`)