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:
parent
198fd0f090
commit
ac2556cc8a
1 changed files with 99 additions and 20 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue