The legend's region sections, its Deaf row and its one-country zones, the wall's card names, the print progress and error messages, the map's status badge and the Population column all rendered English in every locale. None of them was visible to check:hardcoded-english: they were single words, .js modules, Error() arguments, library components or number formatting. - Legend: region rows through one REGION_KEY table shared with the wall (poster.region_*, new region_deaf); zones through zoneTitle (tier A by country, the Deaf row by its bloc name); node ids stay English-derived. - Wall: card labels through zoneTitle, so the Show-on-map name, alt text, tooltip, HUD title and printed title agree with the legend and packet. - Errors a reader sees are keyedError(key, English, params); errorText(e, t) shows them translated, and an unkeyed error still shows its own message. Developer detail for a failed data load goes to the console; the badge says upgMap.mapDataFailed. - Population: Intl compact notation in the app's locale, same precision. - Country Posters: the unsupported card's reason is messages.countryPosterTooMany, not the data file's English note. - Strings only the UPG-100 map shows move to a new upgMap area, so other bundles do not carry them (country-posters 1,490,907 bytes). - check:hardcoded-english judges keyedError's English as a fallback (quiet when the key resolves everywhere, a finding when it does not); check:label-key-coverage follows REGION_KEY to legend-tree.js and covers the legend's table and its regionFor producer. Both self-tests two-sided.
190 lines
11 KiB
JavaScript
190 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// check-label-key-coverage — an English-to-key lookup table must cover every label its companion
|
|
// table can produce.
|
|
//
|
|
// ★ WHAT THIS CAUGHT. poster-wall.vue translates its section headings by LOOKING THE KEY UP BY THE
|
|
// ENGLISH:
|
|
// const REGION_OF = { 'brazil': 'Americas', 'europe': 'Europe', ... } // id -> English
|
|
// const REGION_KEY = { 'Middle East': 'region_middle_east', ... } // English -> key
|
|
// const regionLabel = (english) => { const key = REGION_KEY[english]
|
|
// return key ? t(`poster.${key}`) : english }
|
|
// REGION_KEY had no entry for 'Americas' or 'Europe', and the fall-through arm returns the RAW
|
|
// ENGLISH. Both were reachable — four zone ids map to them — so two headings on the poster wall
|
|
// printed in English in all eleven locales. (upg100 found and fixed this in their own plugin; the
|
|
// gate exists so the next table cannot repeat it.)
|
|
//
|
|
// ⚠ WHY THE HARDCODED-ENGLISH GATE CANNOT SEE THIS. There it is one string that is a LOOKUP INDEX,
|
|
// not a rendered label, and the key is COMPUTED — `t('poster.' + key)` — so a scan that reads keys
|
|
// written literally at the call site finds nothing to check. Resolving arbitrary key expressions is
|
|
// a static-analysis problem. This gate does the one decidable part instead: two object literals in
|
|
// one file, where the values of the first are the keys of the second, and coverage is a set
|
|
// difference. Narrow on purpose.
|
|
//
|
|
// ⚠ THE VALUES, NOT THE KEYS, ARE WHAT MUST BE COVERED. Reading REGION_OF's keys (the zone ids)
|
|
// against REGION_KEY finds nothing wrong and feels like a check. The ids are never translated; it is
|
|
// the English on the right-hand side that reaches a reader.
|
|
//
|
|
// ⚠ AND A LITERAL ARGUMENT COUNTS AS PRODUCED. Three of poster-wall's nine headings never appear in
|
|
// REGION_OF at all — they are passed straight in, `regionLabel('China + Mongolia')`. A gate that
|
|
// reads only the table misses a third of the surface, and 'Other' is exactly the kind of catch-all
|
|
// that gets added last and keyed never.
|
|
//
|
|
// Reads real source. Headless, no browser, no network. Exit 0 pass · 1 fail.
|
|
import { resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const read = (p) => readFileSync(resolve(ROOT, p), 'utf8')
|
|
|
|
const fails = []
|
|
const ok = (cond, msg) => { if (!cond) fails.push(msg) }
|
|
|
|
// ── the tables this gate knows about ───────────────────────────────────────────────────────────
|
|
// Declared rather than discovered: a heuristic that hunted for "any two objects that look related"
|
|
// would be guessing about code it has never seen, and a wrong guess here reports a defect that is
|
|
// not there. Each entry names the file, the table that PRODUCES labels, the table that maps a label
|
|
// to a key, the helper that reads them, and the area those keys live in.
|
|
// `mapsFile` names the file the key table lives in when it is not `file` (the wall and the legend share
|
|
// one REGION_KEY in legend-tree.js). `producerFn` names a function whose ternary arms return labels
|
|
// directly (legend-tree's regionFor returns 'India' / 'China + Mongolia' / 'Other' without a helper call).
|
|
const TABLES = [
|
|
{
|
|
file: 'plugin-designs/my-upg-100-list/poster-wall.vue',
|
|
produces: 'REGION_OF',
|
|
mapsFile: 'plugin-designs/my-upg-100-list/data/legend-tree.js',
|
|
maps: 'REGION_KEY',
|
|
via: 'regionLabel',
|
|
area: 'poster',
|
|
},
|
|
{
|
|
// the legend's region rows: the same REGION_KEY, through buildLegendNodes' opts.regionTitle
|
|
file: 'plugin-designs/my-upg-100-list/data/legend-tree.js',
|
|
produces: 'REGION_OF',
|
|
maps: 'REGION_KEY',
|
|
via: 'regionTitleOf',
|
|
producerFn: 'regionFor',
|
|
area: 'poster',
|
|
},
|
|
]
|
|
|
|
/** The body of `const NAME = { ... }`, to its MATCHING closing brace. Brace-balanced rather than "the first
|
|
* `}` at column 0": a table declared inside a function (legend-tree's REGION_OF) is indented, and a
|
|
* column-0 match would run on to the end of the function and read every later `: '…'` as a label. */
|
|
function objectBody(src, name) {
|
|
const m = new RegExp(`const ${name}\\s*=\\s*\\{`).exec(src)
|
|
if (!m) return null
|
|
let depth = 1
|
|
const start = m.index + m[0].length
|
|
for (let i = start; i < src.length; i++) {
|
|
if (src[i] === '{') depth++
|
|
else if (src[i] === '}' && --depth === 0) return src.slice(start, i)
|
|
}
|
|
return null
|
|
}
|
|
/** The labels a producer function returns from its ternary arms: `? 'India'` / `: 'Other'`. */
|
|
function producerLabels(src, fn) {
|
|
const m = new RegExp(`const ${fn}\\s*=\\s*\\([^)]*\\)\\s*=>([\\s\\S]*?);\\n`).exec(src)
|
|
return m ? [...m[1].matchAll(/[?:]\s*'([^']+)'/g)].map((x) => x[1]) : null
|
|
}
|
|
/** Quoted values on the right of a colon — the English labels a table produces. */
|
|
const valuesOf = (body) => new Set([...body.matchAll(/:\s*'([^']+)'/g)].map((m) => m[1]))
|
|
/** Quoted keys on the left of a colon — the English labels a table covers. */
|
|
const keysOf = (body) => new Set([...body.matchAll(/'([^']+)'\s*:/g)].map((m) => m[1]))
|
|
|
|
for (const t of TABLES) {
|
|
if (!existsSync(resolve(ROOT, t.file))) {
|
|
ok(false, `${t.file} is missing — a table this gate watches moved or was renamed`)
|
|
continue
|
|
}
|
|
const src = read(t.file)
|
|
if (t.mapsFile && !existsSync(resolve(ROOT, t.mapsFile))) {
|
|
ok(false, `${t.mapsFile} is missing — the key table this gate watches moved or was renamed`)
|
|
continue
|
|
}
|
|
const produces = objectBody(src, t.produces)
|
|
const maps = objectBody(t.mapsFile ? read(t.mapsFile) : src, t.maps)
|
|
// ★ A MISSING TABLE IS A BLIND GATE, NOT A PASS. If either object is renamed away, every set below
|
|
// is empty and the coverage check passes while measuring nothing — the failure mode a gate must
|
|
// never have. So absence is the loud case.
|
|
if (!produces) { ok(false, `${t.file}: no \`const ${t.produces} = {\` — this gate has gone blind`); continue }
|
|
if (!maps) { ok(false, `${t.file}: no \`const ${t.maps} = {\` — this gate has gone blind`); continue }
|
|
|
|
const fromTable = valuesOf(produces)
|
|
const fromCalls = new Set([...src.matchAll(new RegExp(`${t.via}\\('([^']+)'\\)`, 'g'))].map((m) => m[1]))
|
|
let fromFn = []
|
|
if (t.producerFn) {
|
|
const got = producerLabels(src, t.producerFn)
|
|
// the same blind-gate rule as the tables: a renamed producer must fail, not contribute nothing
|
|
if (!got || !got.length) { ok(false, `${t.file}: no \`const ${t.producerFn} = (…) =>\` returning labels — this gate has gone blind`); continue }
|
|
fromFn = got
|
|
}
|
|
const produced = new Set([...fromTable, ...fromCalls, ...fromFn])
|
|
const covered = keysOf(maps)
|
|
|
|
ok(produced.size > 0, `${t.file}: ${t.produces} yielded no labels at all — the shape it is parsed with no longer matches`)
|
|
ok(covered.size > 0, `${t.file}: ${t.maps} yielded no keys at all — the shape it is parsed with no longer matches`)
|
|
|
|
const uncovered = [...produced].filter((label) => !covered.has(label)).sort()
|
|
ok(uncovered.length === 0,
|
|
`${t.file}: ${t.maps} has no entry for ${uncovered.map((s) => `'${s}'`).join(', ')} — `
|
|
+ `${t.via}() falls through to the raw English, so ${uncovered.length === 1 ? 'that heading prints' : 'those headings print'} `
|
|
+ `in English in every locale`)
|
|
|
|
// Covering a label with a key that does not exist is the same defect one step later: the lookup
|
|
// succeeds, t() gets a key the catalogue has never heard of, and what the reader sees is a raw
|
|
// key or the English again depending on the fallback.
|
|
const locales = 'library/i18n/locales'
|
|
const localeDirs = existsSync(resolve(ROOT, locales))
|
|
? readdirSync(resolve(ROOT, locales), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort()
|
|
: []
|
|
for (const [label, key] of [...maps.matchAll(/'([^']+)'\s*:\s*'([^']+)'/g)].map((m) => [m[1], m[2]])) {
|
|
const missing = localeDirs.filter((loc) => {
|
|
const p = resolve(ROOT, locales, loc, `${t.area}.json`)
|
|
if (!existsSync(p)) return true
|
|
try { return JSON.parse(readFileSync(p, 'utf8'))[key] === undefined } catch { return true }
|
|
})
|
|
ok(missing.length === 0,
|
|
`${t.file}: ${t.maps}['${label}'] points at ${t.area}.${key}, which is missing in ${missing.join(', ')}`)
|
|
}
|
|
}
|
|
|
|
// ── SELF-TEST ──────────────────────────────────────────────────────────────────────────────────
|
|
// ★ THE CHECK MUST BITE. A coverage test built on two regexes passes loudly when either stops
|
|
// matching — it compares two empty sets and calls it agreement. So the parsing is exercised on
|
|
// planted source whose answer is known, including the shape that actually shipped.
|
|
{
|
|
const t = []
|
|
const probe = (src, via) => {
|
|
const p = objectBody(src, 'OF'), m = objectBody(src, 'KEY')
|
|
if (!p || !m) return null
|
|
const produced = new Set([...valuesOf(p), ...[...src.matchAll(new RegExp(`${via}\\('([^']+)'\\)`, 'g'))].map((x) => x[1])])
|
|
return [...produced].filter((l) => !keysOf(m).has(l)).sort()
|
|
}
|
|
const COVERED = `const OF = {\n 'a': 'Africa', 'b': 'Asia',\n}\nconst KEY = {\n 'Africa': 'k_africa',\n 'Asia': 'k_asia',\n}\nlabel('Africa')\n`
|
|
const GAP = `const OF = {\n 'a': 'Africa', 'b': 'Americas',\n}\nconst KEY = {\n 'Africa': 'k_africa',\n}\n`
|
|
const VIA_CALL = `const OF = {\n 'a': 'Africa',\n}\nconst KEY = {\n 'Africa': 'k_africa',\n}\nlabel('Other')\n`
|
|
t.push([JSON.stringify(probe(COVERED, 'label')) === '[]', 'a fully covered table reports nothing'])
|
|
t.push([JSON.stringify(probe(GAP, 'label')) === '["Americas"]', 'a value with no key is reported'])
|
|
// the shape that shipped: the gap was reachable only through a literal argument, never the table
|
|
t.push([JSON.stringify(probe(VIA_CALL, 'label')) === '["Other"]', 'a label passed as a literal argument is reported'])
|
|
// and the blind-gate case: a renamed table must not read as agreement
|
|
t.push([objectBody(`const OTHER = {\n 'a': 'b',\n}`, 'OF') === null, 'a renamed table is detected, not silently skipped'])
|
|
// an INDENTED table inside a function ends at its own brace, not at the function's
|
|
const NESTED = `function f() {\n const OF = {\n 'a': 'Africa',\n };\n const x = { label: 'Not a region' };\n}\n`
|
|
t.push([JSON.stringify([...valuesOf(objectBody(NESTED, 'OF'))]) === '["Africa"]', 'an indented table is read to its own closing brace'])
|
|
// a producer's ternary arms are labels; its startsWith() arguments are not
|
|
const FN = `const regionFor = (id) =>\n OF[id] || (id.startsWith('asia-in-') ? 'India'\n : 'Other');\n`
|
|
t.push([JSON.stringify(producerLabels(FN, 'regionFor')) === '["India","Other"]', 'a producer function yields its returned labels only'])
|
|
t.push([producerLabels(FN, 'renamedFor') === null, 'a renamed producer is detected, not silently skipped'])
|
|
const bad = t.filter(([pass]) => !pass)
|
|
for (const [, msg] of bad) fails.push(`SELF-TEST: ${msg}`)
|
|
}
|
|
|
|
if (fails.length) {
|
|
console.error('LABEL-KEY-COVERAGE: FAIL')
|
|
for (const f of fails) console.error(` ✗ ${f}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`LABEL-KEY-COVERAGE: PASS — ${TABLES.length} lookup table(s): every label the companion table can produce, `
|
|
+ 'including the ones passed as literal arguments, has a key, and every key exists in every locale.')
|