diff --git a/internal/check-hardcoded-english.mjs b/internal/check-hardcoded-english.mjs
index b79999898..0b7afb612 100644
--- a/internal/check-hardcoded-english.mjs
+++ b/internal/check-hardcoded-english.mjs
@@ -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 = `
h
{{ ok ? 'Everything loaded' : 'Nothing loaded' }}
+
Counted BY LANGUAGE, so groups collapse.
+
Source: eBible, checked 2026-09
`
const PLANTED_GOOD = `
@@ -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')
`
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)
}