doxa-maps/internal/check-optional-t-equivalence.mjs
Doxa Maps bca8ce73ad
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 4ca2dcf9
2026-09-27 02:07:55 +00:00

249 lines
18 KiB
JavaScript

#!/usr/bin/env node
/* check-optional-t-equivalence.mjs — `bun run check:optional-t-equivalence`. Source-only, headless.
EVERY LATER C4 WAVE EXTENDS THIS FILE (dx-shepherd, REGC4G): a wave that migrates a site without
adding its row here is a wave whose behaviour nobody re-checks. Sections 1-6 are the helper's own
contract; section 7 is the per-site register. */
// C4: does useOptionalT() behave like the twenty hand-written guarded translators it replaces?
//
// Same condition the shepherd set for C2 and C3: old pattern vs new, every branch, negative-tested.
// The old shape, verbatim from usePplrT.js before this migration:
// const enLookup = (key) => String(key).replace(/^pplr\./,'').split('.').reduce(...enPplr)
// const fill = (str, params) => params ? str.replace(/\{(\w+)\}/g, ...) : str
// try { const i18n = useI18n(); return (k,p) => { const v = i18n.t(k,p||{}); return v===k ? fill(enLookup(k) ?? k, p) : v } }
// catch (_) { return (k,p) => fill(enLookup(k) ?? String(k), p) }
//
// ★ WHY THIS CANNOT JUST IMPORT THE MODULE. useOptionalT.js imports vue-i18n and @map/i18n/index.js,
// and that index calls `import.meta.glob` — a Vite transform plain Node cannot run. So the source is
// EXTRACTED AND EXECUTED with tStatic and useI18n stubbed, the same approach check-provider-for.mjs
// uses for engineGlobal, and for the same reason: re-typing the function into the gate would test the
// copy rather than the thing that ships.
import { readFileSync } from 'node:fs'
import { codeOnly } from '../plugin-designs/regions-dashboard/scripts/strip-code.mjs'
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
const src = readFileSync(new URL('../library/composables/useOptionalT.js', import.meta.url), 'utf8')
const body = src
.replace(/^import .*$/gm, '') // drop the two un-runnable imports
.replace(/^export default .*$/gm, '') // and the default re-export (it is not a declaration)
.replace(/^export /gm, '')
const make = (tStatic, useI18n) => new Function('tStatic', 'useI18n',
body + '\nreturn { useOptionalT, useOptionalI18n, fillParams, qualify };')(tStatic, useI18n)
// The English catalogue the old code imported, as a stub tree: messages.en.pplr equivalent.
const EN = { greeting: 'Hello {name}', nested: { deep: 'Deep value' } }
const tStatic = (path) => String(path).replace(/^pplr\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN)
const throwingI18n = () => { throw new Error('Need to install with `app.use` function') }
const hostI18n = (table) => () => ({ t: (k) => (k in table ? table[k] : k) })
// ── 1. no i18n on the host — the case seven bundles are actually in ─────────────────────────────
{
const { useOptionalT } = make(tStatic, throwingI18n)
const t = useOptionalT('pplr')
ok(t('greeting', { name: 'Ada' }) === 'Hello Ada', 'bare host: falls back to the catalogue AND interpolates')
ok(t('nested.deep') === 'Deep value', 'bare host: resolves a nested key')
ok(t('missing.key') === 'missing.key', 'bare host: a missing key renders as the KEY, never blank')
ok(t('pplr.greeting', { name: 'Ada' }) === 'Hello Ada', 'bare host: an already-qualified key is not double-prefixed')
}
// ── 2. host HAS i18n and has the string — the host must win ─────────────────────────────────────
{
const { useOptionalT } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
ok(useOptionalT('pplr')('greeting') === 'Bonjour', 'host i18n wins when it has the key -- a host managing locale keeps control')
}
// ── 3. host HAS i18n but LACKS the string: i18n.t returns the key unchanged ─────────────────────
{
const { useOptionalT } = make(tStatic, hostI18n({}))
const t = useOptionalT('pplr')
ok(t('greeting', { name: 'Ada' }) === 'Hello Ada',
'host i18n present but missing the key falls through to the catalogue -- the case every old copy handled with `v === key`')
}
// ── 4. the pure helpers ────────────────────────────────────────────────────────────────────────
{
const { fillParams, qualify } = make(tStatic, throwingI18n)
ok(fillParams('a {x} b', { x: 1 }) === 'a 1 b', 'fillParams interpolates')
ok(fillParams('a {x} b', {}) === 'a {x} b', 'fillParams leaves an unknown placeholder visible rather than blanking it')
ok(fillParams('a {x} b', { x: null }) === 'a {x} b',
'fillParams leaves the placeholder for a NULL value -- a key-presence test would render the string "null" into the sentence')
ok(fillParams('a {x} b', { x: 0 }) === 'a 0 b',
'but 0 is a real value and must still substitute (a truthiness test would drop it)')
ok(fillParams('plain') === 'plain', 'fillParams with no params is a no-op')
ok(qualify('pplr', 'x') === 'pplr.x' && qualify('pplr', 'pplr.x') === 'pplr.x', 'qualify prefixes once, never twice')
}
// ── 5. EQUIVALENCE with the old hand-written implementation, branch for branch ──────────────────
{
const fill = (s, p) => (p ? String(s).replace(/\{(\w+)\}/g, (m, k) => (k in p ? p[k] : m)) : s)
const enLookup = (key) => String(key).replace(/^pplr\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN)
const oldBare = (k, p) => fill(enLookup(k) ?? String(k), p)
const { useOptionalT } = make(tStatic, throwingI18n)
const neu = useOptionalT('pplr')
for (const [k, p] of [['greeting', { name: 'Ada' }], ['nested.deep', null], ['missing.key', null], ['greeting', null]]) {
ok(oldBare(k, p) === neu(k, p), `EQUIVALENT on the bare-host path for ${JSON.stringify(k)}: both give ${JSON.stringify(oldBare(k, p))}`)
}
}
// ── 6. NEG CONTROLS ────────────────────────────────────────────────────────────────────────────
{
const { useOptionalT } = make(() => undefined, throwingI18n)
ok(useOptionalT('pplr')('greeting') === 'greeting',
'NEG CONTROL: with a catalogue that knows nothing, the key comes back -- so the passes above were really reading the catalogue')
const { useOptionalT: u2 } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
ok(u2('pplr')('greeting') !== 'Hello Ada', 'NEG CONTROL: the host path and the catalogue path are distinguishable')
}
ok(/import \{ tStatic \}/.test(src), 'CONTROL: the shipped module really does route through tStatic (not a catalogue import of its own)')
// ★ codeOnly, because this assertion failed on its OWN DOCUMENTATION: useOptionalT.js's header QUOTES
// '@map/i18n/locales/en/pplr.json' as the import it removed, and a whole-file grep counted the prose as
// the thing it forbids. Fifth time this session that a detector has read a comment as code.
ok(!/locales\/en\//.test(codeOnly(src)), 'CONTROL: and it imports NO per-area English JSON in CODE -- the redundant second copy is gone')
// ── 7. THE PER-SITE REGISTER. One row per migrated site; later waves append here. ───────────────
// Each row asserts the two things a migration can get wrong: that the site actually routes through
// the shared guard, and that it still carries ITS OWN fallback rather than silently inheriting the
// catalogue. The second matters more than it looks: routing these through tStatic would change what
// seven bare-host bundles render, and would start translating strings that are English today.
import { readFileSync as rf } from 'node:fs'
const SITES = [
// file, helper it must use, a fragment of the fallback it must still pass
['library/components/legends/ApproxPinNote.vue', 'useOptionalT', 'APPROX_FALLBACK[key]'],
['library/components/search/FilterDropdown.vue', 'useOptionalT', 'FALLBACK[key]'],
['library/components/legends/SemanticTreeLegend.vue', 'useOptionalI18n', 'humanizeKey(k)'],
['library/components/poster/PosterWall.vue', 'useOptionalI18n', 'humanizeKey(k)'],
['library/components/hints/PinHint.vue', 'useOptionalI18n', 'PINHINT_FALLBACK[key]'],
['plugin-designs/pplr-data-maps/src/composables/usePplrT.js', 'useOptionalT', null],
['plugin-designs/upg-neighbor/src/composables/useNeighborT.js', 'useOptionalT', null],
// wave 3a — gospel-resources (owner: gospel-plugin). All four fell back to enLookup, i.e. the
// SHIPPED ENGLISH, not the locale-aware catalogue; each passes that enLookup in, so a bare host
// still renders English. Switching them to the catalogue default would make a bare host honour
// ?lang=ar — a real improvement, and gospel-plugin's to make, not this migration's.
// ★ gospel-plugin CHOSE the locale-honouring catalogue fallback (2026-09-26) rather than keeping
// enLookup's English: "most partners will drop a custom element or an iframe and never install
// vue-i18n ... an Arabic ministry site embedding this map serves its readers English while the
// bundle already carries all 11 catalogues. That is a bug, not a decision." So these four take NO
// fallback, and row 7b proves the Arabic actually arrives.
['plugin-designs/gospel-resources/charts.vue', 'useOptionalT', null],
['plugin-designs/gospel-resources/data-tables.vue', 'useOptionalT', null],
['plugin-designs/gospel-resources/resources-list.vue', 'useOptionalT', null],
// ★ resources-map passes NO NAMESPACE: it uses keys in TWO areas (gospel.* and pins.*) and its
// enLookup walks the catalogue ROOT to see both. useOptionalT('gospel', ...) would have qualified
// pins.approximate into gospel.pins.approximate and broken the pins sentence — studio caught that
// as a semantic conflict on main before it landed. The row asserts the '' explicitly so a later
// edit cannot quietly re-add a namespace.
['plugin-designs/gospel-resources/resources-map.vue', "useOptionalT(''", null],
// wave 3b — cities-internet. All three were byte-identical in the guard block; each keeps its own
// enLookup fallback, so a bare host renders the same English. Their local fill() used `k in params`,
// so the shared fillParams also removes a way for them to print the literal "null".
// ★ exp-shepherd, as cities-internet's owner, took the locale-honouring default (2026-09-26): "the
// bundle already carries all 11 catalogues, so a bare host getting English would be a bug". So these
// three pass NO fallback, and their English-only enLookup + per-area catalogue import are deleted.
['plugin-designs/cities-internet/connectivity-list.vue', 'useOptionalT', null],
['plugin-designs/cities-internet/connectivity-map.vue', 'useOptionalT', null],
['plugin-designs/cities-internet/data-tables.vue', 'useOptionalT', null],
// wave 3d — all-people-groups. Treated as UNOWNED (studio agreed), so the conservative reading wins:
// its inline EN map stays the fallback rather than switching to the catalogue, because nobody is
// available to decide that this bundle should start honouring the locale on a bare host. An unowned
// file is the wrong place to make a product decision by default.
// First site on the {t, te, locale} form: it needs locale for toLocaleString.
['plugin-designs/all-people-groups/all-people-groups.vue', 'useOptionalI18n', 'fallback: (full) =>'],
// wave 3c — my-upg-100-list (owner: upg100). Both used the humanizeKey fallback and no namespace,
// since callers pass full keys. Each keeps humanizeKey, so a bare host reads the same title-cased
// last segment it always did.
['plugin-designs/my-upg-100-list/poster-wall.vue', 'useOptionalT', 'humanizeKey(k)'],
['plugin-designs/my-upg-100-list/upg-100-mfe.vue', 'useOptionalT', 'humanizeKey(k)'],
]
// ★ ONE FILE KEEPS TWO BARE useI18n CALLS ON PURPOSE, and the count is asserted rather than waived.
// resources-map.vue reads the LOCALE twice (`useI18n().locale.value`) to decide isEnglish and the
// engine's language tag. That is a different fact from translating text: `locale` arrives as a ref OR
// a plain string depending on the host (PosterWall.vue records the same variance), so normalising it
// belongs in a deliberate helper change, not in a translator migration. Asserting EXACTLY 2 means a
// third bare call — a new unguarded translator — still fails this gate.
// ★ DELIBERATELY NOT IN THIS REGISTER, so nobody "completes" C4 by converting it:
// plugin-designs/pplr-data-maps/src/components/SemanticTreeLegend.vue
// It still hand-rolls the guard, and exp-shepherd asked for it to stay that way: the file is carded for
// retirement (RETIRE-PPLR-SEMANTICTREELEGEND) in favour of the library component, so converting it is
// work that gets thrown away. A migration whose value expires before it lands is not worth the diff.
// Two more sites are their owners' to cut, not absent by oversight: country-posters-mfe.vue
// (exp-shepherd, into its single posters landing) and key-cities/cities-map.vue (exp-shepherd, gated).
const ALLOWED_BARE = { 'plugin-designs/gospel-resources/resources-map.vue': 2 }
for (const [file, helper, fallbackFrag] of SITES) {
// ★ STRIP HTML COMMENTS TOO, for .vue files. codeOnly is a JS scanner: it knows /* */ and //, not
// <!-- -->. resources-map.vue carries a long template comment that MENTIONS useI18n(), and counting
// it made this register read 3 bare calls where the code has 2. Same lesson as the CSS-in-a-template
// -literal case: the stripper has to match the LANGUAGE of the text it is reading.
const raw = rf(new URL('../' + file, import.meta.url), 'utf8')
const code = codeOnly(raw.replace(/<!--[\s\S]*?-->/g, ' '))
const short = file.split('/').pop()
ok(helper.includes('(') ? code.includes(helper) : new RegExp(`\\b${helper}\\(`).test(code),
`${short}: routes through ${helper}${helper.includes('(') ? '' : '()'}`)
const bare = (code.match(/useI18n\(/g) || []).length
const allowed = ALLOWED_BARE[file] || 0
ok(bare === allowed, allowed
? `${short}: exactly ${allowed} bare useI18n() left, the documented locale read(s) -- found ${bare}`
: `${short}: no bare useI18n() left in CODE (comments explaining it are fine)`)
if (fallbackFrag) {
ok(code.includes(fallbackFrag),
`${short}: still passes its OWN fallback (${fallbackFrag}) -- migrating it to the catalogue would change what a bare host renders`)
} else {
// ★ NOT "as it always did" for all of them. The two composables always resolved through the
// catalogue; gospel's four used to resolve through enLookup's ENGLISH and were MOVED here by their
// owner's decision. Saying "as it always did" for those four would be a false statement inside a
// gate message, which is the one place a reader has no reason to doubt.
const moved = file.includes('gospel-resources')
ok(!/fallback:/.test(code), moved
? `${short}: takes the locale-honouring CATALOGUE fallback (gospel-plugin moved it here from enLookup's English)`
: `${short}: takes the CATALOGUE fallback, as it always did`)
}
}
// ── 7b. gospel-plugin's ASKED-FOR ROW, against the REAL catalogue ───────────────────────────────
// Their words: "a BARE host (no vue-i18n) with ?lang=ar must render pins.approximate in Arabic — not
// the key, not gospel.pins.approximate, not English." It covers their conditions 1 and 2 together: a
// namespace would corrupt the key, and an EN-only fallback would serve English to an Arabic reader.
// This reads the SHIPPED ar/ and en/ pins.json rather than a stub, and rebuilds tStatic's own
// resolution order (messages[want] ?? messages.en) over them, so it measures the real catalogue.
{
const arPins = JSON.parse(rf(new URL('../library/i18n/locales/ar/pins.json', import.meta.url), 'utf8'))
const enPins = JSON.parse(rf(new URL('../library/i18n/locales/en/pins.json', import.meta.url), 'utf8'))
const messages = { ar: { pins: arPins }, en: { pins: enPins } }
const realish = (want) => (path) => {
const walk = (loc) => String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), messages[loc])
const v = walk(want); return typeof v === 'string' ? v : walk('en')
}
const { useOptionalT } = make(realish('ar'), throwingI18n)
const t = useOptionalT('')
const got = t('pins.approximate')
ok(got === arPins.approximate,
'GOSPEL ROW: a BARE host under ar renders pins.approximate in ARABIC from the real catalogue')
ok(got !== 'pins.approximate' && got !== 'gospel.pins.approximate',
'and it is neither the raw key nor a namespace-mangled key')
ok(got !== enPins.approximate,
'and it is NOT the English -- which is exactly what enLookup used to serve every partner host')
// the same call must still reach English when the locale has no entry
const tEnOnly = useOptionalT('')
ok(typeof arPins.approximate === 'string' && typeof enPins.approximate === 'string',
'CONTROL: both catalogues really do carry the key, so the three assertions above are measuring a difference that exists')
const { useOptionalT: uMissing } = make(realish('zz'), throwingI18n)
ok(uMissing('')('pins.approximate') === enPins.approximate,
'ENGLISH REMAINS THE LAST RESORT: an unknown locale falls back to en, as gospel required')
}
// ── 8. the fallback option and te/locale, behaviourally ────────────────────────────────────────
{
const { useOptionalT, useOptionalI18n } = make(tStatic, throwingI18n)
const mine = useOptionalT('pplr', { fallback: (k) => (k === 'pplr.greeting' ? 'MINE {name}' : undefined) })
ok(mine('greeting', { name: 'Ada' }) === 'MINE Ada',
'opts.fallback WINS over the catalogue -- that is what keeps each migrated site rendering its own English')
ok(mine('nested.deep') === 'nested.deep',
'and when the site fallback has nothing, the KEY shows (it does NOT silently fall through to the catalogue)')
const { te, locale } = useOptionalI18n('pplr')
ok(te('greeting') === false, 'te() is FALSE on a bare host -- PinHint and SemanticTreeLegend depend on that to use their own defaults')
ok(locale === 'en', 'locale defaults to en on a bare host, as PosterWall had it')
}
{
const { useOptionalI18n } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
const { te } = useOptionalI18n('pplr')
ok(typeof te === 'function', 'te() is a function when the host HAS i18n')
}
console.log(fails ? `\nC4-OPTIONAL-T: ${fails} FAIL(s)` : '\nC4-OPTIONAL-T: the shared translator matches the hand-written guards on every branch')
process.exit(fails ? 1 : 0)