diff --git a/internal/check-hardcoded-english.mjs b/internal/check-hardcoded-english.mjs index 0b7afb612..4e358cbb8 100644 --- a/internal/check-hardcoded-english.mjs +++ b/internal/check-hardcoded-english.mjs @@ -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') ` +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 }