poster-wall translates its section headings by looking the key up by the English: one table maps a zone id to a heading, a second maps that heading to a catalogue key, and the helper returns the raw English when the second table has no entry. Two headings reachable from four zone ids had no key, so they printed in English in all eleven locales. upg100 found and fixed that one; this is so the next table cannot repeat it. The hardcoded-English scan cannot see this class. There the English is a lookup index rather than a rendered label, and the key is computed, so a scan that reads keys written literally at the call site has nothing to check. Resolving arbitrary key expressions is a static-analysis problem; this does the decidable part — two object literals in one file where the values of the first are the keys of the second, and coverage is a set difference. The table list is declared, not discovered, because a heuristic hunting for related-looking objects would be guessing about code it has never read. Two things the check has to get right and could quietly get wrong. It compares the VALUES of the producing table, not its keys — the ids are never translated, and reading them finds nothing while looking like a check. And a label passed straight to the helper as a literal counts as produced: three of the nine headings never appear in the table at all, and "Other" is exactly the catch-all that gets added last and keyed never. A renamed table fails loudly rather than comparing two empty sets and calling it agreement. The self-test covers a covered table, a gap, a gap reachable only through a literal argument, and the renamed case; poisoning either parser or the object matcher trips it. Verified against both real states: development's poster-wall fails naming Americas and Europe, and upg100's fix passes.
145 lines
8.7 KiB
JavaScript
145 lines
8.7 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.
|
|
const TABLES = [
|
|
{
|
|
file: 'plugin-designs/my-upg-100-list/poster-wall.vue',
|
|
produces: 'REGION_OF',
|
|
maps: 'REGION_KEY',
|
|
via: 'regionLabel',
|
|
area: 'poster',
|
|
},
|
|
]
|
|
|
|
/** The body of `const NAME = { ... }`, matched to its first closing brace at column 0. */
|
|
function objectBody(src, name) {
|
|
const m = new RegExp(`const ${name}\\s*=\\s*\\{([\\s\\S]*?)\\n\\}`).exec(src)
|
|
return m ? m[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)
|
|
const produces = objectBody(src, t.produces)
|
|
const maps = objectBody(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]))
|
|
const produced = new Set([...fromTable, ...fromCalls])
|
|
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'])
|
|
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.')
|