From ac2556cc8aed7831d283a64b54ee5fab3b87f789 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 11:58:26 +0000 Subject: [PATCH] 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. --- internal/check-hardcoded-english.mjs | 119 ++++++++++++++++++++++----- 1 file changed, 99 insertions(+), 20 deletions(-) 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) => /^?$/.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 = ` ` const PLANTED_GOOD = `