Stop counting a translator fallback whose key really resolves

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.
This commit is contained in:
Doxa Maps 2026-09-29 04:14:13 +00:00
parent c7677edd57
commit 396c8f2ee7

View file

@ -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 <script>
// and not in <template>, which is the worse of the two possible inconsistencies: it reads as though the
// gate has an opinion about WHERE a correct idiom is allowed.
function scanBoundExpr(expr, file, attrName, out, line = 0, decls = '') {
const code = String(expr || '').trim()
if (!code) return
const found = []
try { scanScript(`(${code})`, file, found) } catch { return } // not parseable as an expression: skip
try { scanScript(`${decls}\n;(${code})`, file, found) } catch { return } // not parseable as an expression: skip
// The fragment was parsed alone, so `f.line` numbers the fragment; the ATTRIBUTE's line is the
// fact the reader needs. A multi-line bound expression therefore reports its opening line.
for (const f of found) out.push({ file, kind: `bind:${attrName}`, text: f.text, line })
@ -379,7 +513,7 @@ function scanBoundExpr(expr, file, attrName, out, line = 0) {
const hasLtr = (node) => (node.props || []).some((p) =>
p.type === 6 && p.name === 'dir' && p.value?.content === 'ltr')
function walkTemplate(node, file, out, ltr = false) {
function walkTemplate(node, file, out, ltr = false, decls = '') {
if (!node) return
// ★ NAME THE ISLAND THAT SUPPRESSED A STRING, not just the fact of suppression. The deliberate
// bucket is the one place where over-claiming costs nothing and shows nothing: a 74-line island
@ -411,7 +545,7 @@ function walkTemplate(node, file, out, ltr = false) {
// sentences that happen to contain a colon.
if (attrName !== 'style' && attrName !== 'class') {
const before = out.length
scanBoundExpr(p.exp.content, file, attrName, out, p.loc?.start?.line ?? node.loc?.start?.line ?? 0)
scanBoundExpr(p.exp.content, file, attrName, out, p.loc?.start?.line ?? node.loc?.start?.line ?? 0, decls)
for (let i = before; i < out.length; i++) { out[i].deliberate = !!inLtr; out[i].island = island }
}
}
@ -421,17 +555,18 @@ function walkTemplate(node, file, out, ltr = false) {
// `{{ t('Foo') }}` stays quiet because the expression is parsed with the same translator rules.
if (node.type === 5 && node.content?.content) {
const before = out.length
scanBoundExpr(node.content.content, file, 'interpolation', out, node.loc?.start?.line ?? 0)
scanBoundExpr(node.content.content, file, 'interpolation', out, node.loc?.start?.line ?? 0, decls)
for (let i = before; i < out.length; i++) { out[i].deliberate = !!inLtr; out[i].island = island }
}
for (const c of node.children || []) walkTemplate(c, file, out, island || inLtr)
for (const c of node.children || []) walkTemplate(c, file, out, island || inLtr, decls)
}
export function scanSfc(src, file) {
const out = []
const { descriptor, errors } = parse(src, { filename: 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 scriptSrc = [descriptor.script?.content, descriptor.scriptSetup?.content].filter(Boolean).join('\n')
if (descriptor.template && descriptor.template.ast) walkTemplate(descriptor.template.ast, file, out, false, translatorDecls(scriptSrc))
// ★ SCANNED SEPARATELY, NOT JOINED. Concatenating <script> and <script setup> made every line
// number in the second block wrong by the length of the first, which is worse than no line at
// all — a confident wrong site sends the reader to unrelated code. Each block carries its own
@ -587,6 +722,37 @@ function selfTest() {
})
ok(misplaced.length === 0,
`every reported line actually CONTAINS its string (${misplaced.length} misplaced${misplaced.length ? ': ' + misplaced.map((f) => `${f.kind}@${f.line} ${JSON.stringify(f.text.slice(0, 28))}`).join('; ') : ''})`)
// ★ THE FALLBACK EXEMPTION, BOTH SIDES. A resolved key makes its English a safety net; an
// unresolved one makes it the string the reader actually gets. Asserting only the quiet side
// would pass on a gate that exempts every fallback, which is precisely the bug this replaced:
// the old blanket "any argument of a translator call is exempt" rule could not see a typo'd key.
const FB = `<template>
<p>{{ W('dashboard.map_loading', 'Research map loading…') }}</p>
<p :title="W('dashboard.map_loading', 'Research map loading…')">x</p>
<p>{{ W('dashboard.no_such_key_at_all', 'This fallback is the only thing a reader sees') }}</p>
</template>
<script setup>
const W = (key, en) => tStatic(key) || en
const a = W('dashboard.map_loading', 'Research map loading…')
const b = W('dashboard.no_such_key_at_all', 'This fallback is the only thing a reader sees')
const c = t('dashboard.map_loading')
</script>`
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')