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')