Catch a key that can never resolve, not just a string never translated
A namespaced translator qualifies every key with its own area, so a key from another area can never resolve and the English fallback becomes the normal path in every locale. The reader sees English exactly as if it had been hardcoded, which is why it belongs here rather than in a separate check. Nothing caught it before. The existing key check reads the key as written, which does exist; it is the key as qualified that misses. exp-shepherd hit it twice in one day, in two different plugins. Detected by shape -- qualified key absent while the unqualified one is present -- so it cannot fire on an ordinary missing key, which is the other check's job. The first version found only one of the two real cases, because the other declares its translator through a wrapper that takes no argument and carries the namespace one indirection away. Wrappers are now resolved from source, and both historical defects are reproduced from the code as it stood before each fix, with the qualified key named in the message. Self-test now makes twenty-eight assertions, including a planted wrapper.
This commit is contained in:
parent
d589edb3a2
commit
41f66f0ce9
1 changed files with 117 additions and 1 deletions
|
|
@ -102,6 +102,102 @@ function isDeveloperSite(parent) {
|
|||
const calleeName = (node) => node?.callee?.type === 'Identifier' ? node.callee.name
|
||||
: node?.callee?.type === 'MemberExpression' ? (node.callee.property?.name || '') : ''
|
||||
|
||||
// ── CROSS-AREA KEYS THROUGH A NAMESPACED TRANSLATOR ─────────────────────────────────────────────
|
||||
// ★ A REAL BUG, FOUND TWICE IN ONE DAY (pplr a56d06c8, cities-internet 7b3fb78d5), that no gate saw.
|
||||
// `const t = useOptionalT('connectivity')` then `t('messages.engineMissing')` — qualify() makes that
|
||||
// `connectivity.messages.engineMissing`, which cannot exist, so the English fallback is the NORMAL
|
||||
// path in every locale. check:i18n-keys-exist passes it because the key AS WRITTEN does exist; the
|
||||
// key as QUALIFIED is what misses. The user-visible outcome is identical to a hardcoded string,
|
||||
// which is why it belongs in this gate: a reader sees English where their language was promised.
|
||||
// Detected by SHAPE — qualified key absent AND unqualified key present — so it cannot fire on an
|
||||
// ordinary missing key, which is check:i18n-keys-exist's job, not this one.
|
||||
const qualifyKey = (ns, key) => (ns && !key.startsWith(`${ns}.`) ? `${ns}.${key}` : key)
|
||||
|
||||
let CATALOGUE = null
|
||||
function catalogue(root = '.') {
|
||||
if (CATALOGUE) return CATALOGUE
|
||||
CATALOGUE = {}
|
||||
const dir = join(root, 'library/i18n/locales/en')
|
||||
if (!existsSync(dir)) return CATALOGUE
|
||||
for (const f of readdirSync(dir)) {
|
||||
if (!f.endsWith('.json')) continue
|
||||
try { CATALOGUE[f.replace(/\.json$/, '')] = JSON.parse(readFileSync(join(dir, f), 'utf8')) } catch { /* skip */ }
|
||||
}
|
||||
return CATALOGUE
|
||||
}
|
||||
// `a.b.c` resolves as area `a` then path b.c inside it — the shape library/i18n/index.js builds.
|
||||
function resolveKey(key, root = '.') {
|
||||
const cat = catalogue(root)
|
||||
const [area, ...rest] = String(key).split('.')
|
||||
let node = cat[area]
|
||||
if (node === undefined) return undefined
|
||||
for (const seg of rest) {
|
||||
if (node === null || typeof node !== 'object') return undefined
|
||||
node = node[seg]
|
||||
if (node === undefined) return undefined
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// ★ WRAPPER FACTORIES CARRY THE NAMESPACE ONE INDIRECTION AWAY, and missing that is why the first
|
||||
// version of this check caught cities-internet but NOT pplr — the bug it was written for. pplr does
|
||||
// `const t = usePplrT()` with NO argument, and usePplrT() is `return useOptionalT('pplr')`. A check
|
||||
// that only reads a literal argument sees no namespace and stays silent on the exact defect twice
|
||||
// over. So wrappers are resolved from source first.
|
||||
let WRAPPERS = null
|
||||
function wrapperNamespaces(root = '.') {
|
||||
if (WRAPPERS) return WRAPPERS
|
||||
WRAPPERS = {}
|
||||
const roots = [join(root, 'library/composables'), join(root, 'plugin-designs')]
|
||||
const visit = (d, depth = 0) => {
|
||||
if (depth > 4 || !existsSync(d)) return
|
||||
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||
const f = join(d, e.name)
|
||||
if (e.isDirectory()) { if (!/node_modules|dist/.test(e.name)) visit(f, depth + 1); continue }
|
||||
if (!e.name.endsWith('.js')) continue
|
||||
let src = ''
|
||||
try { src = readFileSync(f, 'utf8') } catch { continue }
|
||||
for (const m of src.matchAll(/export\s+function\s+(use[A-Za-z]*T)\s*\([^)]*\)\s*\{[^}]*useOptionalT\s*\(\s*['"]([^'"]+)['"]/g)) {
|
||||
WRAPPERS[m[1]] = m[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
roots.forEach((r) => visit(r))
|
||||
return WRAPPERS
|
||||
}
|
||||
|
||||
// `const t = useOptionalT('connectivity')` → { t: 'connectivity' }
|
||||
// `const t = usePplrT()` → { t: 'pplr' }, via the wrapper map above
|
||||
function namespacedTranslators(code, root = '.') {
|
||||
const map = {}
|
||||
for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*use[A-Za-z]*T\s*\(\s*['"]([^'"]+)['"]/g)) map[m[1]] = m[2]
|
||||
const wrappers = wrapperNamespaces(root)
|
||||
for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(use[A-Za-z]*T)\s*\(\s*\)/g)) {
|
||||
if (wrappers[m[2]]) map[m[1]] = wrappers[m[2]]
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function crossAreaKeys(code, file, root = '.') {
|
||||
const out = []
|
||||
const ns = namespacedTranslators(code, root)
|
||||
if (!Object.keys(ns).length) return out
|
||||
let ast
|
||||
try { ast = babel.parse(code, { sourceType: 'module', plugins: ['jsx'], errorRecovery: true }) } catch { return out }
|
||||
walkAst(ast, (node) => {
|
||||
if (node.type !== 'CallExpression') return
|
||||
const name = node.callee?.type === 'Identifier' ? node.callee.name : ''
|
||||
if (!ns[name]) return
|
||||
const arg = node.arguments?.[0]
|
||||
if (arg?.type !== 'StringLiteral' || !arg.value.includes('.')) return
|
||||
const qualified = qualifyKey(ns[name], arg.value)
|
||||
if (resolveKey(qualified, root) !== undefined) return // resolves: fine
|
||||
if (resolveKey(arg.value, root) === undefined) return // plain missing key: keys-exist's job
|
||||
out.push({ file, kind: 'cross-area-key', text: `${name}('${arg.value}') → ${qualified} (never resolves; '${arg.value}' does)` })
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ── the walk ────────────────────────────────────────────────────────────────────────────────────
|
||||
function walkAst(node, visit, parent = null, stack = []) {
|
||||
if (!node || typeof node.type !== 'string') return
|
||||
|
|
@ -247,7 +343,7 @@ export function scanSfc(src, file) {
|
|||
if (errors && errors.length) throw new Error(`${file}: ${errors[0].message}`)
|
||||
if (descriptor.template && descriptor.template.ast) walkTemplate(descriptor.template.ast, file, out)
|
||||
const code = [descriptor.script?.content, descriptor.scriptSetup?.content].filter(Boolean).join('\n')
|
||||
if (code) scanScript(code, file, out)
|
||||
if (code) { scanScript(code, file, out); out.push(...crossAreaKeys(code, file)) }
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -314,6 +410,17 @@ const devThrow = () => { throw new Error('table not found for this request') }
|
|||
const devWarn = () => console.warn('boundaries failed to load for this map')
|
||||
</script>`
|
||||
|
||||
const XAREA_BAD = `const t = useOptionalT('regions')
|
||||
const msg = t('messages.engineMissing')`
|
||||
// The wrapper shape: no literal namespace at the call site, so it is only detectable by resolving
|
||||
// usePplrT() back to useOptionalT('pplr'). This is the shape pplr actually had.
|
||||
const XAREA_WRAPPED = `const t = usePplrT()
|
||||
const msg = t('messages.mapUnavailable')`
|
||||
const XAREA_GOOD = `const t = useOptionalT('messages')
|
||||
const msg = t('engineMissing')
|
||||
const t2 = useOptionalT('regions')
|
||||
const ok2 = t2('Countries')`
|
||||
|
||||
function selfTest() {
|
||||
let fails = 0
|
||||
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
|
||||
|
|
@ -353,6 +460,15 @@ function selfTest() {
|
|||
// The blind spot is PINNED, not merely mentioned: if single-word detection is ever added, this
|
||||
// fails and whoever adds it must update the header rather than leave it claiming a gap that closed.
|
||||
ok(!good.some((f) => f.text === 'Other'), 'DOCUMENTED BLIND SPOT: a single-word leak is NOT reported (see header)')
|
||||
// ★ CROSS-AREA KEYS: a real bug found twice in one day, and the shape both instances had.
|
||||
const xb = crossAreaKeys(XAREA_BAD, 'planted-xarea.vue')
|
||||
const xg = crossAreaKeys(XAREA_GOOD, 'planted-xarea-ok.vue')
|
||||
ok(xb.length === 1 && /never resolves/.test(xb[0].text),
|
||||
'SEES a cross-area key through a namespaced t() (qualified misses, unqualified exists)')
|
||||
ok(xg.length === 0, `QUIET when the key resolves under its own namespace (got ${xg.length})`)
|
||||
const xw = crossAreaKeys(XAREA_WRAPPED, 'planted-xarea-wrapped.vue')
|
||||
ok(xw.length === 1 && /pplr\.messages\.mapUnavailable/.test(xw[0].text),
|
||||
'SEES it through a WRAPPER factory with no literal namespace (usePplrT() → useOptionalT(\'pplr\'))')
|
||||
console.log(fails ? `\nSELF-TEST: ${fails} FAIL(s)` : '\nSELF-TEST: the gate can see planted leaks and stays quiet on correct code')
|
||||
return fails
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue