Hardcoded-English gate: three agents' triage of its first run

gospel, upg100 and exp-shepherd triaged 300-odd hits between them, and most of
what they found was the gate being wrong rather than the code.

False positives removed, each from a real hit: class and id suffixes built by
interpolation, relative URLs, custom-element tag names, and empty matches.
Developer strings are now exempt by CALL SITE rather than by content, because
the site is the fact and the wording is not. A split template literal is one
finding reported as its whole string with its holes marked, instead of one hit
per fragment -- that alone was turning single defects into three.

Text inside a dir="ltr" island is DELIBERATE English, the marker these plugins
already use for content ruled to stay English. It is listed but not counted, so
nobody is asked to undo a decision.

The header gains a second blind spot, found the same day by exp-shepherd: a
guarded fallback is judged by its shape, not by whether the lookup it guards
works. A key read with the wrong prefix misses every time, making the English
arm the normal path in every locale while this stays quiet. I had triaged
exactly that line as "not a leak" because the key existed -- existing and being
found are different things.

Self-test is twenty-three assertions: every leak shape seen, every correct form
silent, both blind spots pinned.
This commit is contained in:
Doxa Maps 2026-09-28 11:58:26 +00:00
parent 198fd0f090
commit ac2556cc8a

View file

@ -20,6 +20,15 @@
* to fall back when a key is absent; the English is reachable only when no translation exists. A
* literal inside a conditional whose test calls te() is therefore allowed (sr's pattern).
*
* ⚠ SECOND BLIND SPOT, AND IT BIT ON THE FIRST DAY. A guarded fallback is judged by its SHAPE, not
* by whether the lookup it guards actually works. `msg !== 'messages.engineMissing' ? msg : 'English'`
* is treated as a fallback — but if the key is looked up with the wrong prefix, the lookup ALWAYS
* misses and the English arm becomes the NORMAL path, in every locale, while this gate stays quiet.
* That is exactly what exp-shepherd found in cities-internet after I triaged the same line as "not a
* leak". A hardcoded-English scan cannot see a misconfigured lookup; check:i18n-keys-exist and a
* locale render are what catch that. Treat a guarded fallback as "not a leak HERE", never as "this
* string is not reaching users".
*
* ⚠ KNOWN BLIND SPOT, STATED SO NOBODY READS A GREEN RUN AS MORE THAN IT IS: THIS CANNOT SEE
* SINGLE-WORD STRINGS. A bare 'Other' used as a fallback label is user-visible English and this gate
* will not report it — regions found eight of them by grep, not with this tool. Single words carry
@ -50,8 +59,14 @@ const ALLOW = [
test: (s) => /<\/?[a-zA-Z][^>]*>?/.test(s)
|| /\b(class|href|src|alt|loading|width|height|style|id|target|rel)\s*=/.test(s)
|| /^["'>\s]*$/.test(s) || /^<\/|^["']?\s*>/.test(s) },
{ why: 'URLs and url fragments',
test: (s) => /^(https?:|\/\/|\/|\?|&|#)/.test(s) },
{ why: 'URLs and url fragments, absolute OR relative (gospel: a relative URL was reported as prose)',
test: (s) => /^(https?:|\/\/|\/|\?|&|#)/.test(s)
|| /^\.{0,2}\/[^\s]*$/.test(s)
|| (/^[^\s]+\.(json|js|css|svg|png|jpe?g|webp|geojson|csv|html?)$/i.test(s)) },
{ why: 'CSS class/id SUFFIXES built by interpolation (gospel: `${x}-fill`, `${x}-line` read as prose)',
test: (s) => /^[-_][a-z0-9][a-z0-9-]*$/.test(s) },
{ why: 'custom-element tag names: lowercase and hyphenated, a tag is not a sentence',
test: (s) => /^<?\/?[a-z][a-z0-9]*(-[a-z0-9]+)+>?$/.test(s) },
{ why: 'BCP-47 locale extensions and language tags (e.g. the latin-digits override)',
test: (s) => /^-?[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(s) || /-u-[a-z]{2}-/.test(s) },
{ why: 'developer error text: a bracketed module prefix or an HTTP status, for the console',
@ -76,6 +91,14 @@ function localizedNames(code) {
for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(t|tf)\b\s*(?![(.])/g)) names.add(m[1])
return names
}
// A literal that is an argument to one of these never reaches a reader, whatever it says.
function isDeveloperSite(parent) {
if (!parent) return false
if (parent.type === 'CallExpression' && parent.callee?.object?.name === 'console') return true
if (parent.type === 'NewExpression' && /^[A-Z]\w*Error$|^Error$/.test(parent.callee?.name || '')) return true
if (parent.type === 'CallExpression' && /^[A-Z]\w*Error$|^Error$/.test(parent.callee?.name || '')) return true
return false
}
const calleeName = (node) => node?.callee?.type === 'Identifier' ? node.callee.name
: node?.callee?.type === 'MemberExpression' ? (node.callee.property?.name || '') : ''
@ -127,18 +150,34 @@ function scanScript(code, file, out) {
const isTpl = node.type === 'TemplateLiteral'
if (!isStr && !isTpl) return
if (parent && parent.type === 'CallExpression' && names.has(calleeName(parent))) return // t('…')
if (parent && parent.type === 'CallExpression' && parent.callee?.object?.name === 'console') return
// ★ 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.
if (isDeveloperSite(parent)) return
if (parent && /Import|Export/.test(parent.type)) return
if (parent && parent.type === 'TaggedTemplateExpression') return
if (guardedFallback(stack, names)) return
const pieces = isStr ? [node.value] : node.quasis.map((q) => String(q.value.cooked ?? q.value.raw ?? ''))
for (const raw of pieces) {
const text = raw.trim()
if (!isProse(text)) continue
if (isStr && !/\s/.test(text)) continue // ⚠ the single-word blind spot, documented above
if (allowedBy(text)) continue
out.push({ file, kind: isStr ? 'script-string' : 'template-literal', text })
if (isStr) {
const text = node.value.trim()
if (!text.length || !isProse(text)) return // gospel: empty matches were being reported
if (!/\s/.test(text)) return // ⚠ the single-word blind spot, documented above
if (allowedBy(text)) return
out.push({ file, kind: 'script-string', text })
return
}
// ★ ONE TEMPLATE LITERAL IS ONE FINDING (upg100). Reporting each quasi separately turned a single
// string into 'polys ·', 'zones ·', 'px;' — three hits for one defect, which inflates every count
// and makes a triage list read as worse than the code is. Each quasi is still JUDGED on its own,
// so an allow-listed fragment cannot drag a whole literal in; only the REPORT is joined.
const quasis = node.quasis.map((q) => String(q.value.cooked ?? q.value.raw ?? ''))
const leaks = quasis.some((raw) => {
const text = raw.trim()
return text.length && isProse(text) && !allowedBy(text)
})
if (!leaks) return
const whole = quasis.map((q) => q.trim()).filter(Boolean).join(' {…} ').trim()
out.push({ file, kind: 'template-literal', text: whole })
})
}
@ -155,16 +194,26 @@ function scanBoundExpr(expr, file, attrName, out) {
for (const f of found) out.push({ file, kind: `bind:${attrName}`, text: f.text })
}
function walkTemplate(node, file, out) {
// ★ dir="ltr" MARKS TEXT THAT IS MEANT TO STAY ENGLISH. gospel's plugins use it as the island
// marker for content the Driver ruled stays English (a BLUF, a source citation), so flagging it as a
// leak would ask someone to "fix" a deliberate decision. Such findings are marked deliberate, listed
// SEPARATELY, and excluded from the count a gate would fail on — visible, but not a defect.
// Inherited down the subtree: a dir="ltr" wrapper covers everything inside it.
const hasLtr = (node) => (node.props || []).some((p) =>
(p.type === 6 && p.name === 'dir' && p.value?.content === 'ltr')
|| (p.type === 7 && p.name === 'bind' && p.arg?.content === 'dir' && /['"]ltr['"]/.test(p.exp?.content || '')))
function walkTemplate(node, file, out, ltr = false) {
if (!node) return
const inLtr = ltr || (node.type === 1 && hasLtr(node))
if (node.type === 2) {
const raw = String(node.content || '').trim()
if (isProse(raw) && !allowedBy(raw)) out.push({ file, kind: 'template-text', text: raw })
if (raw.length && isProse(raw) && !allowedBy(raw)) out.push({ file, kind: 'template-text', text: raw, deliberate: inLtr })
}
if (node.type === 1) for (const p of node.props || []) {
if (p.type === 6 && VISIBLE_ATTRS.has(p.name)) {
const v = p.value && p.value.content
if (v && isProse(v) && !allowedBy(v)) out.push({ file, kind: `attr:${p.name}`, text: v })
if (v && isProse(v) && !allowedBy(v)) out.push({ file, kind: `attr:${p.name}`, text: v, deliberate: inLtr })
}
// (interpolations are handled below, on the node itself)
// type 7 = DIRECTIVE. v-bind carries the attribute name in `arg`.
@ -174,16 +223,22 @@ function walkTemplate(node, file, out) {
// better than allow-listing their contents: `background-image: url(` has no semicolon and so
// slipped the CSS shape test, and widening that test to catch it would start swallowing real
// sentences that happen to contain a colon.
if (attrName !== 'style' && attrName !== 'class') scanBoundExpr(p.exp.content, file, attrName, out)
if (attrName !== 'style' && attrName !== 'class') {
const before = out.length
scanBoundExpr(p.exp.content, file, attrName, out)
for (let i = before; i < out.length; i++) out[i].deliberate = inLtr
}
}
}
// type 5 = INTERPOLATION. `{{ cond ? 'Yes' : 'No' }}` is the same defect as a bound attribute —
// a literal the reader sees, assembled in an expression the template walker would otherwise skip.
// `{{ 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)
for (let i = before; i < out.length; i++) out[i].deliberate = inLtr
}
for (const c of node.children || []) walkTemplate(c, file, out)
for (const c of node.children || []) walkTemplate(c, file, out, inLtr)
}
export function scanSfc(src, file) {
@ -223,12 +278,15 @@ const PLANTED_BAD = `<template>
<th :title="\`Sort this table by \${col}\`">h</th>
<button :title="'Reset the view'">r</button>
<span>{{ ok ? 'Everything loaded' : 'Nothing loaded' }}</span>
<p dir="ltr">Counted BY LANGUAGE, so groups collapse.</p>
<div dir="ltr"><em>Source: eBible, checked 2026-09</em></div>
</div>
</template>
<script setup>
import { a } from './a.js'
import { b } from '../b/c.js'
const msg = 'No results were found'
const split = \`\${a} polys · \${b} zones · \`
const ctx = \`\${n} countries\`
</script>`
const PLANTED_GOOD = `<template>
@ -252,6 +310,8 @@ const err = \`HTTP \${res.status}\`
const lone = 'Other'
const proxyMiss = t.search || 'Search names, country, religion…'
const keyMiss = msg !== 'messages.engineMissing' ? msg : 'The map engine did not load.'
const devThrow = () => { throw new Error('table not found for this request') }
const devWarn = () => console.warn('boundaries failed to load for this map')
</script>`
function selfTest() {
@ -273,12 +333,21 @@ function selfTest() {
ok(bad.some((f) => f.kind === 'bind:title' && /Sort this table by/.test(f.text)), 'SEES a TEMPLATE LITERAL inside a bound attribute (sr: every scanner missed this)')
ok(bad.some((f) => f.kind === 'bind:title' && f.text === 'Reset the view'), "SEES a quoted '…' literal inside a bound attribute (both quote styles)")
ok(bad.some((f) => f.kind === 'bind:interpolation' && /Everything loaded|Nothing loaded/.test(f.text)), 'SEES a literal inside an INTERPOLATION expression')
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')
const ltr = bad.filter((f) => f.deliberate)
ok(ltr.some((f) => /Counted BY LANGUAGE/.test(f.text)), 'marks text inside dir="ltr" as DELIBERATE, not a leak')
ok(ltr.some((f) => /Source: eBible/.test(f.text)), 'and INHERITS the marker down the subtree')
ok(bad.filter((f) => !f.deliberate).some((f) => f.text === 'Prayer Coverage'), 'while a leak OUTSIDE the island is still counted')
ok(!good.some((f) => f.text === 'All Regions'), 'QUIET on a call through an ALIAS of t()')
ok(!good.some((f) => f.text === 'Regions List'), 'QUIET on a te()-guarded fallback')
ok(!good.some((f) => String(f.kind).startsWith('bind:')), 'QUIET on a bound attribute that IS a translator call')
ok(!good.some((f) => /Search names/.test(f.text)), 'QUIET on a proxy-t member miss (t.search || …) — a fallback, not a leak')
ok(!good.some((f) => f.kind === 'bind:interpolation'), 'QUIET on interpolations that are translator calls or bare values')
ok(!good.some((f) => /map engine did not load/.test(f.text)), 'QUIET on the "did t() return the key?" miss idiom (msg !== \'a.dotted.key\')')
ok(!good.some((f) => /table not found/.test(f.text)), 'QUIET on a throw new Error(…) argument — exempt by CALL SITE')
ok(!good.some((f) => /boundaries failed/.test(f.text)), 'QUIET on a console.warn(…) argument')
ok(!good.some((f) => /rd-card|example\.test|nu-latn|HTTP/.test(f.text)), 'QUIET on css, urls, locale extensions and developer error text')
ok(good.length === 0, `QUIET overall on a correct file (got ${good.length}${good.length ? ': ' + JSON.stringify(good[0]) : ''})`)
// The blind spot is PINNED, not merely mentioned: if single-word detection is ever added, this
@ -310,10 +379,20 @@ try {
for (const d of dirs) for (const f of sfcsIn(d)) { files++; findings.push(...scanSfc(readFileSync(f, 'utf8'), f)) }
} catch (e) { console.error('HARDCODED-ENGLISH: parse error —', e.message); process.exit(2) }
const deliberate = findings.filter((f) => f.deliberate)
const leaks = findings.filter((f) => !f.deliberate)
console.log(`\n ${dirs.length} production plugin(s), ${files} SFC(s) scanned`)
for (const f of findings) console.log(` ${f.kind.padEnd(16)} ${f.file.replace(/^.*plugin-designs\//, '')} ${JSON.stringify(f.text).slice(0, 90)}`)
console.log(findings.length
? `\nHARDCODED-ENGLISH: ${findings.length} user-visible English string(s) not going through t()`
: '\nHARDCODED-ENGLISH: PASS — no user-visible hardcoded English in any production plugin')
process.exit(findings.length ? 1 : 0)
for (const f of leaks) console.log(` ${f.kind.padEnd(16)} ${f.file.replace(/^.*plugin-designs\//, '')} ${JSON.stringify(f.text).slice(0, 90)}`)
if (deliberate.length) {
// Listed so nobody wonders where they went, but NOT counted: dir="ltr" is the plugin's own marker
// for content the Driver ruled stays English. Asking someone to "fix" these would be asking them
// to undo a decision.
console.log(`\n ${deliberate.length} inside dir="ltr" islands — DELIBERATE English, not counted:`)
for (const f of deliberate.slice(0, 8)) console.log(` · ${f.file.replace(/^.*plugin-designs\//, '')} ${JSON.stringify(f.text).slice(0, 80)}`)
if (deliberate.length > 8) console.log(` · …and ${deliberate.length - 8} more`)
}
console.log(leaks.length
? `\nHARDCODED-ENGLISH: ${leaks.length} user-visible English string(s) not going through t()`
: '\nHARDCODED-ENGLISH: PASS — no user-visible hardcoded English in any production plugin')
process.exit(leaks.length ? 1 : 0)
}