From 396c8f2ee7d7e8ca7a6fd15c13f40bfd20bdbefd Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 04:14:13 +0000 Subject: [PATCH] Stop counting a translator fallback whose key really resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T('dashboard.map_loading', 'Research map loading…') is the idiom these components use: the English is what shows if the catalogue ever loses the key, not what the reader gets. Forty-four findings were code of exactly that shape, and triaging them meant reading catalogue entries that were already complete in all eleven locales. Two things were wrong. Wrapper aliases were not recognised as translators at all — only `const X = t` was, never `const T = (key, en) => ...` — so every fallback in those components read as a leak. And the rule that exempted translator arguments exempted all of them, including the fallback, which left the gate structurally unable to see a typo'd key: the English then renders forever while the call site still looks correct. So the key argument stays exempt and the fallback argument has to earn it, by resolving to a real value in every shipped locale. A missing key still counts. An identical rendering counts only when the English has three or more translatable words: measured against this catalogue, the identical entries are cognates (Normal in de, Auto in fr), proper nouns, and format-only strings like "{label} · {count} · {pct}%", and failing on those would report correct translations as leaks. Bound expressions are parsed on their own, so they could not see the translator declared in the script block; the declarations are carried in as stubs. The stubs deliberately contain no || — written the natural way they match the te()-guard shape and exempt every fallback in the file, resolved or not. Self-test covers both sides: a resolved fallback is quiet in template and script, an unresolved one counts in both, and the catalogue rule is asserted directly. 249 -> 205 across the fleet, all 44 in this owner's plugins; detection elsewhere is unchanged. --- internal/check-hardcoded-english.mjs | 206 ++++++++++++++++++++++++--- 1 file changed, 186 insertions(+), 20 deletions(-) diff --git a/internal/check-hardcoded-english.mjs b/internal/check-hardcoded-english.mjs index 77ac51ccb..162f285fa 100644 --- a/internal/check-hardcoded-english.mjs +++ b/internal/check-hardcoded-english.mjs @@ -104,8 +104,24 @@ const allowedBy = (s) => ALLOW.find((a) => a.test(s)) function localizedNames(code) { const names = new Set(['t', 'tf', '$t']) for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(t|tf)\b\s*(?![(.])/g)) names.add(m[1]) + // WRAPPERS ARE TRANSLATORS TOO. `const T = (key, en) => tStatic(key) || en` is the shape most of + // these components use, and matching only `const X = t` missed every one of them — the wrapper's + // own English argument then read as a leak. Recognised by the BODY calling a known translator. + for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*=>\s*([^\n]*)/g)) { + if (/\b(?:t|tf|\$t|tStatic)\s*\(/.test(m[2])) names.add(m[1]) + } return names } +// Some wrappers bake in an AREA: `const L = (key, en) => tStatic('dashboard.' + key) || en` is +// called as L('align_dashboard') but the catalogue holds dashboard.align_dashboard. Without the +// prefix the key looks unresolvable and a correct fallback keeps counting. +function keyPrefixes(code) { + const out = new Map() + for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*=>\s*[^\n]*?\b(?:t|tf|\$t|tStatic)\s*\(\s*['"]([\w.\-]+\.)['"]\s*\+/g)) { + out.set(m[1], m[2]) + } + return out +} // ★ A DOM QUERY ARGUMENT IS A SELECTOR, NOT PROSE — and a selector made of custom-element tags // reads as English only because the tag names are English words. gospel found // `querySelector("gospel-resources-dashboard, gospel-resources-map")` reported as user-visible text: @@ -144,21 +160,36 @@ const calleeName = (node) => node?.callee?.type === 'Identifier' ? node.callee.n // 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 */ } +const CATALOGUES = new Map() +function catalogueFor(locale, root = '.') { + const hit = CATALOGUES.get(locale) + if (hit) return hit + const cat = {} + const dir = join(root, `library/i18n/locales/${locale}`) + if (existsSync(dir)) { + for (const f of readdirSync(dir)) { + if (!f.endsWith('.json')) continue + try { cat[f.replace(/\.json$/, '')] = JSON.parse(readFileSync(join(dir, f), 'utf8')) } catch { /* skip */ } + } } - return CATALOGUE + CATALOGUES.set(locale, cat) + return cat +} +function catalogue(root = '.') { return catalogueFor('en', root) } +// Every locale that ships, read from the tree rather than hardcoded, so adding a locale tightens +// this check automatically instead of silently leaving the new one unguarded. +let LOCALES = null +function locales(root = '.') { + if (LOCALES) return LOCALES + const dir = join(root, 'library/i18n/locales') + LOCALES = existsSync(dir) + ? readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort() + : [] + return LOCALES } // `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) +function resolveKey(key, root = '.', locale = 'en') { + const cat = catalogueFor(locale, root) const [area, ...rest] = String(key).split('.') let node = cat[area] if (node === undefined) return undefined @@ -170,6 +201,68 @@ function resolveKey(key, root = '.') { return node } +// ★ A FALLBACK BEHIND A KEY THAT REALLY RESOLVES IS NOT A LEAK. `T('dashboard.map_loading', 'Research +// map loading…')` is the idiom this codebase uses everywhere: the second argument is what shows if the +// catalogue ever loses the key, so the English is a SAFETY NET, not the string the reader gets. Counting +// it made ~19% of the remaining findings correct code, and sent owners off to write translations that +// already existed (sr, 2026-09-29). +// +// The exemption is earned, never assumed, because an unresolved fallback is a REAL leak wearing the +// same shape — a typo'd key renders English forever and looks perfectly correct at the call site. So +// the key must resolve to a non-empty string in EVERY shipped locale, and in no non-English locale may +// it merely echo the English: both of those mean a reader somewhere still sees English, which is the +// defect this gate exists to find. Anything less than fully translated keeps counting. +// Words a translator would actually have to render: placeholders, punctuation and bare numbers are +// not translatable, so "{label} · {count} · {pct}%" counts as zero and never reads as untranslated. +function translatableWords(s) { + return String(s) + .replace(/\{[^}]*\}/g, ' ') // {label} interpolation holes + .split(/\s+/) + .filter((w) => /[A-Za-z]{2,}/.test(w)) + .length +} + +function fallbackFullyResolved(key, root = '.') { + const locs = locales(root) + if (!locs.length) return false // no catalogue to vouch for it: cannot exempt + const en = resolveKey(key, root, 'en') + if (typeof en !== 'string' || !en.trim()) return false + for (const loc of locs) { + const v = resolveKey(key, root, loc) + if (typeof v !== 'string' || !v.trim()) return false // missing in one locale = still a leak + // ★ IDENTICAL-TO-EN IS ONLY EVIDENCE WHEN THERE IS SOMETHING TO TRANSLATE. Measured across this + // catalogue, the identical entries are overwhelmingly CORRECT: cognates a locale really does + // share ("Normal" in de, "Auto" in fr, "Total" in es), proper nouns, and format-only strings like + // "{label} · {count} · {pct}%" that contain no translatable words at all. Failing on every echo + // would call all of those leaks. A multi-word English SENTENCE reproduced verbatim is the case + // that actually means untranslated, so that is the one that keeps counting. + if (loc !== 'en' && v === en && translatableWords(en) >= 3) return false + } + return true +} + +// 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. +function resolvedFallbackOf(node, parent, stack, names, root = '.') { + // form 1: the literal is the SECOND argument of a translator call + if (parent && parent.type === 'CallExpression' && names.has(calleeName(parent)) && + parent.arguments.length >= 2 && parent.arguments[1] === node) { + const k = parent.arguments[0] + if (k?.type === 'StringLiteral') return k.value + } + // form 2: the literal is the RIGHT side of `t(…) || 'English'` or `t(…) ?? 'English'` + const lg = stack[stack.length - 1] + if (lg && lg.type === 'LogicalExpression' && (lg.operator === '||' || lg.operator === '??') && lg.right === node) { + let call = lg.left + if (call?.type === 'CallExpression' && names.has(calleeName(call))) { + const k = call.arguments[0] + if (k?.type === 'StringLiteral') return k.value + } + } + return null +} + // ★ 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 @@ -312,12 +405,21 @@ function lineOf(node, lineBase) { // internal numbering describes the fragment and not the file). function scanScript(code, file, out, lineBase = 0) { const names = localizedNames(code) + const prefixes = keyPrefixes(code) const ast = babel.parse(code, { sourceType: 'module', plugins: ['jsx'], errorRecovery: true }) walkAst(ast, (node, parent, stack) => { const isStr = node.type === 'StringLiteral' const isTpl = node.type === 'TemplateLiteral' if (!isStr && !isTpl) return - if (parent && parent.type === 'CallExpression' && names.has(calleeName(parent))) return // t('…') + // ★ THE KEY ARGUMENT IS EXEMPT; THE FALLBACK ARGUMENT MUST EARN IT. `t('a.key')` is never a leak, + // but `T('a.key', 'English')` is only correct if that key resolves — a typo'd key renders the + // English forever while looking right at the call site. Exempting every argument of a translator + // call, as this rule used to, made the gate structurally unable to see that defect. The fallback + // argument therefore falls through to the resolution test below. + if (parent && parent.type === 'CallExpression' && names.has(calleeName(parent))) { + const isFallbackArg = parent.arguments.length >= 2 && parent.arguments[1] === node + if (!isFallbackArg) return // t('…') + } // ★ DEVELOPER STRINGS ARE EXEMPT BY CALL SITE, NOT BY CONTENT (upg100). `throw new Error('…')` // and console.* never reach a reader, and judging them by their text means inventing a rule for // every sentence a developer might write. The SITE is the fact; the wording is not. @@ -325,6 +427,14 @@ function scanScript(code, file, out, lineBase = 0) { if (parent && /Import|Export/.test(parent.type)) return if (parent && parent.type === 'TaggedTemplateExpression') return if (guardedFallback(stack, names)) return + // A fallback whose key really resolves everywhere is correct code. An unresolved one is not, + // and keeps counting — so the key is looked up, not merely observed to be present. + const fbKey = resolvedFallbackOf(node, parent, stack, names) + if (fbKey !== null) { + const callee = parent?.type === 'CallExpression' ? calleeName(parent) : calleeName(stack[stack.length - 1]?.left) + const full = (prefixes.get(callee) || '') + fbKey + if (fallbackFullyResolved(full)) return + } if (isStr) { const text = node.value.trim() @@ -354,11 +464,35 @@ function scanScript(code, file, out, lineBase = 0) { // nothing — a blind spot every one of the four private scanners shared (sr, 2026-09-28; real case // DashboardDataTable.vue:884). The expression is parsed with the same rules as a script block, so a // literal inside t()/tf(), or behind a te() guard, still passes. -function scanBoundExpr(expr, file, attrName, out, line = 0) { +// Just the translator DECLARATION lines of a script block — never the whole block, whose own strings +// would then be re-reported once per bound expression in the file. Each is rewritten to a bare stub: +// only the NAME and any baked-in key prefix matter to the fallback test, and a stub carries no English +// of its own to be counted. +function translatorDecls(code) { + const out = [] + for (const name of localizedNames(code)) { + if (name === 't' || name === 'tf' || name === '$t') continue + const pfx = keyPrefixes(code).get(name) + // NB the stub must contain no `||`/`??` and no te() guard of its own: guardedFallback keys on + // exactly those shapes, so a stub written the natural way exempts every fallback in the file + // whether its key resolves or not — the over-exemption this whole check exists to avoid. + out.push(pfx ? `const ${name} = (k, en) => t('${pfx}' + k)` : `const ${name} = t`) + } + return out.join('\n') +} + +// ★ A BOUND EXPRESSION IS PARSED ALONE, SO IT CANNOT SEE ITS OWN TRANSLATOR. `{{ T('a.key', 'English') }}` +// is scanned as the fragment `(T('a.key', 'English'))`, where `T` is an unknown identifier — the +// component's `const T = (key, en) => …` lives in the script block, which is not in the fragment. The +// translator declarations are therefore carried in and prepended, so the template half of a file judges +// a fallback by exactly the rules the script half does. Without this the exemption fired in ` + const fb = scanSfc(FB, 'fallbacks.vue') + ok(!fb.some((f) => /Research map loading/.test(f.text)), + 'QUIET on a t(key, English) fallback whose key resolves in every locale') + ok(fb.filter((f) => /only thing a reader sees/.test(f.text)).length === 2, + `COUNTS a fallback whose key does not resolve — in template AND script (got ${fb.filter((f) => /only thing a reader sees/.test(f.text)).length}/2)`) + ok(!fb.some((f) => f.text === 'dashboard.map_loading'), + 'and the KEY argument itself is never a finding') + // 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') + ok(!fallbackFullyResolved('dashboard.no_such_key_at_all'), + 'a key absent from the catalogue does NOT count as resolved') + ok(translatableWords('{label} · {count} · {pct}%') === 0, + 'a format-only string has no translatable words, so an identical rendering is not "untranslated"') + ok(translatableWords('This fallback is the only thing a reader sees') >= 3, + 'an English sentence does have translatable words') const splits = bad.filter((f) => /polys|zones/.test(f.text)) ok(splits.length === 1, `a split template literal is ONE finding, not one per fragment (got ${splits.length})`) ok(splits.length === 1 && /polys.*\{…\}.*zones/.test(splits[0].text), 'and it is reported as the WHOLE string with its holes marked')