doxa-maps/internal/check-plural-forms.mjs
Doxa Maps d3cdc2c3bc Two gates the branch backlog turned red
Seven Driver citations came back with the merged branches. Each keeps its
engineering reason and its date, and loses the speaker: the overlap report, the
poster's legend rule, the network-change defect, the chart's provenance.

check:plural-forms crashed rather than failed. It built its locale list by
listing the catalogue directory, so a README.md moved in beside the locale
folders became a 'locale', passed the exists check because a file does exist,
and crashed readdirSync on a non-directory. It now filters to directories. The
gate on development has the same line and only survives because that README is
not there yet -- so this is a fix for development, not a merge artefact.

Both found by running the full 103-gate chain; the shortened chain the merge
produced ran neither.
2026-09-29 14:55:35 +00:00

118 lines
6.7 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, statSync } 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]
// DIRECTORIES ONLY. This listed every entry, so a README.md beside the locale folders entered
// the locale list, passed the existsSync below (a file does exist) and crashed readdirSync on a
// non-directory. A note explaining a catalogue belongs beside it; the gate has to tolerate one.
const LOCALES = readdirSync(LOC_DIR).filter((d) => !d.startsWith('.') && statSync(join(LOC_DIR, d)).isDirectory())
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.`)