diff --git a/internal/check-hardcoded-english.mjs b/internal/check-hardcoded-english.mjs index 6ba093f61..b5026eef3 100644 --- a/internal/check-hardcoded-english.mjs +++ b/internal/check-hardcoded-english.mjs @@ -256,6 +256,28 @@ function fallbackFullyResolved(key, root = '.') { return true } +// ★ THE KEY OF A LOOKUP TABLE IS AN IDENTIFIER, NOT A LABEL. gospel's resources-list has +// const RES_KEY = { 'Bible video': 'gospel.res.bibleVideo', ... } +// title: RES_KEY[r.title] ? tr(RES_KEY[r.title]) : r.title +// so the English on the LEFT is matched against a value that arrives in the DATA, and the string a +// reader sees comes from the key on the right. Translating the left side would make every lookup +// miss and fall through to the raw data value — the count would drop while the display broke, which +// is the worst shape a gate finding can have. +// +// Earned, not assumed, exactly as the fallback exemption is: the property's VALUE must be an i18n +// key that really resolves. A table pointing at a key nobody wrote is a real defect — the lookup +// succeeds and hands t() a key the catalogue has never heard of — so that still counts. +function isResolvedLookupKey(node, parent, grand) { + // the literal must BE the property's key, not its value + if (!parent || parent.type !== 'ObjectProperty' || parent.key !== node) return false + if (!grand || grand.type !== 'ObjectExpression') return false + const v = parent.value + if (v?.type !== 'StringLiteral') return false + // a dotted key shape, and it has to resolve — a bare category like 'scripture' is not a key + if (!/^[a-z][\w-]*(\.[\w-]+)+$/i.test(v.value)) return false + return fallbackFullyResolved(v.value) +} + // The `T('a.key', 'English')` / `L('key', 'English')` / `tStatic('a.key') || 'English'` shapes, seen // from the English literal looking outward. `names` is the set of translator identifiers already // resolved for this file, so an alias is recognised without being named here. @@ -455,6 +477,10 @@ function scanScript(code, file, out, lineBase = 0) { const full = (prefixes.get(callee) || '') + fbKey if (fallbackFullyResolved(full)) return } + // An English key in a table whose value is a real i18n key: an identifier the data is matched + // against, never a string the reader sees. + // stack's last entry IS the parent, so the enclosing object is one further back. + if (isResolvedLookupKey(node, parent, stack[stack.length - 2])) return if (isStr) { const text = node.value.trim() @@ -790,6 +816,23 @@ const c = cfg.z || 'A plain config fallback with no translator at all must count 'QUIET on config || t(key) || English when the key resolves') ok(ch.some((f) => /no resolving key/.test(f.text)) && ch.some((f) => /no translator at all/.test(f.text)), 'COUNTS the same chain when the key is missing, and when there is no translator in it') + // ★ LOOKUP-TABLE KEYS. gospel's RES_KEY matches a value that arrives in the DATA and returns the + // i18n key to render. Wrapping the left side in t() would make every lookup miss and fall through + // to the raw data value: the count drops while the display breaks. Exempt only when the target + // really resolves — a table pointing at a key nobody wrote is a genuine defect. + const LK = ` +` + const lk = scanSfc(LK, 'lookup.vue') + ok(!lk.some((f) => /JESUS Film|Bible video/.test(f.text)), + 'QUIET on a lookup-table key whose mapped i18n key resolves in every locale') + ok(lk.some((f) => f.text === 'Gospel audio'), + 'COUNTS a lookup-table key whose mapped i18n key does not exist') + ok(lk.some((f) => f.text === 'Bible Stories'), + "and COUNTS one whose value is a category, not a key — the gate can only vouch for what it can resolve") // the catalogue-side rule, tested directly: the two facts that make a fallback safe ok(fallbackFullyResolved('dashboard.map_loading'), 'a key present and really translated in all 11 locales counts as resolved')