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 = `{{ x }}
+`
+ 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')