#!/usr/bin/env node /** * check-hardcoded-english — user-visible English that never reaches a translator. Headless. * * ONE GATE INSTEAD OF FOUR PRIVATE SCANNERS. sr, upg100, gospel and regions each wrote one of these * on 2026-09-28 and each found real defects; four copies means four sets of blind spots and four * allow-lists drifting apart. This is the merge, and every rule below earns its place from a defect * one of those scanners actually caught. * * ★ PARSED, NEVER REGEXED. A regex for string literals matches from the CLOSING quote of one to the * OPENING quote of the next, so a file with adjacent imports yields phantom "strings" — the first * version of regions' scanner reported 137 of them. Templates go through @vue/compiler-sfc and * scripts through @babel/parser. * * ★ ALIASES ARE DISCOVERED, NOT LISTED. `const t2 = t` defeats a callee-name check, and that cost a * false positive. The alias set is read out of each file (`const X = t`), so a rename cannot make * translated text look hardcoded. * * ★ te()-GUARDED FALLBACKS ARE NOT LEAKS. `te(key) ? t(key) : 'Some English'` is the documented way * 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 * too many non-prose meanings (ids, class fragments, enum values) to flag without drowning the * signal. If that trade is ever revisited, the missing piece is context (what the value is ASSIGNED * to), not a longer word list. * * Exit 0 clean, 1 with findings, 2 on a parse error. `--self-test` runs the planted cases only. */ import { readFileSync, readdirSync, existsSync } from 'node:fs' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { execSync } from 'node:child_process' import { parse } from '@vue/compiler-sfc' import * as babel from '@babel/parser' // ── what counts as user-visible ───────────────────────────────────────────────────────────────── const VISIBLE_ATTRS = new Set(['title', 'placeholder', 'aria-label', 'alt', 'label']) const isProse = (s) => /[A-Za-z]/.test(s) && /[A-Za-z]{2,}/.test(s.trim()) // ── the allow-list: SHAPES first, names only where a shape cannot express it ───────────────────── // Each entry records the class and why it is not a leak. A shape is preferred over a literal so the // list does not need editing every time a new font or status code appears. const ALLOW = [ { why: 'CSS: declarations, selectors, at-rules — read by the DOM, not by a person', test: (s) => /[{}]/.test(s) || /[a-z-]+\s*:\s*[^;]+;/.test(s) || /^\s*@(media|supports|keyframes|font-face)/.test(s) || /^\s*[.#][a-zA-Z]/.test(s) || /^\s*\[[a-z-]+[~|^$*]?=/.test(s) }, { why: 'HTML fragments: these plugins build popup markup in template literals, so a quasi is often ' + 'a tag, or the seam between two attributes ("\" alt=\"" / "\" loading=\"lazy\">")', 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, 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', test: (s) => /^\[[a-z0-9-]+\]/i.test(s) || /^HTTP\b/.test(s) }, { why: 'DOM id / class / data-key fragments: hyphenated or BEM identifiers, alone or as a class LIST', // Every token must carry a separator. That is what tells `rd-card is-open` from `the map`: // prose words do not contain hyphens, and this codebase's classes always do. A class list with // no hyphen anywhere (`card open`) is deliberately NOT allowed — flagging is the safe direction. // A trailing separator is allowed because that is the shape of an id PREFIX built by // interpolation — `id="country-${code}"` leaves the quasi "country-". test: (s) => s.split(/\s+/).every((tok) => /^[a-z][a-z0-9]*(?:[-_]{1,2}[a-z0-9]*)+$/.test(tok)) }, { why: 'font family names: typeface identifiers, never translated', test: (s) => /^(Bebas Neue|Poppins|Oswald|Figtree|Segoe UI|Helvetica Neue|Times New Roman)$/.test(s) }, { why: 'engine/provider tokens and well-known proper nouns that are not prose', test: (s) => /^(mapbox|maplibre|mercator|globe|Mapbox|MapLibre)$/.test(s) }, ] const allowedBy = (s) => ALLOW.find((a) => a.test(s)) // ── localisation calls: base names plus aliases discovered per file ────────────────────────────── 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]) return names } // ★ 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: // the single-tag rule matched neither, because it is a comma-separated LIST. Exempting by CALL SITE // is the right shape (as with developer strings) — the site is the fact, and no content rule can // reliably tell a tag list from a sentence. const DOM_QUERY = new Set([ 'querySelector', 'querySelectorAll', 'closest', 'matches', 'getElementById', 'getElementsByClassName', 'getElementsByTagName', 'getElementsByName', ]) function isDomQuerySite(parent) { return !!parent && parent.type === 'CallExpression' && parent.callee?.type === 'MemberExpression' && DOM_QUERY.has(parent.callee.property?.name || '') } // 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 || '') : '' // ── CROSS-AREA KEYS THROUGH A NAMESPACED TRANSLATOR ───────────────────────────────────────────── // ★ A REAL BUG, FOUND TWICE IN ONE DAY (pplr a56d06c8, cities-internet 7b3fb78d5), that no gate saw. // `const t = useOptionalT('connectivity')` then `t('messages.engineMissing')` — qualify() makes that // `connectivity.messages.engineMissing`, which cannot exist, so the English fallback is the NORMAL // path in every locale. check:i18n-keys-exist passes it because the key AS WRITTEN does exist; the // key as QUALIFIED is what misses. The user-visible outcome is identical to a hardcoded string, // which is why it belongs in this gate: a reader sees English where their language was promised. // Detected by SHAPE — qualified key absent AND unqualified key present — so it cannot fire on an // 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 */ } } return CATALOGUE } // `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) const [area, ...rest] = String(key).split('.') let node = cat[area] if (node === undefined) return undefined for (const seg of rest) { if (node === null || typeof node !== 'object') return undefined node = node[seg] if (node === undefined) return undefined } return node } // ★ 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 // that only reads a literal argument sees no namespace and stays silent on the exact defect twice // over. So wrappers are resolved from source first. let WRAPPERS = null function wrapperNamespaces(root = '.') { if (WRAPPERS) return WRAPPERS WRAPPERS = {} const roots = [join(root, 'library/composables'), join(root, 'plugin-designs')] const visit = (d, depth = 0) => { if (depth > 4 || !existsSync(d)) return for (const e of readdirSync(d, { withFileTypes: true })) { const f = join(d, e.name) if (e.isDirectory()) { if (!/node_modules|dist/.test(e.name)) visit(f, depth + 1); continue } if (!e.name.endsWith('.js')) continue let src = '' try { src = readFileSync(f, 'utf8') } catch { continue } for (const m of src.matchAll(/export\s+function\s+(use[A-Za-z]*T)\s*\([^)]*\)\s*\{[^}]*useOptionalT\s*\(\s*['"]([^'"]+)['"]/g)) { WRAPPERS[m[1]] = m[2] } } } roots.forEach((r) => visit(r)) return WRAPPERS } // `const t = useOptionalT('connectivity')` → { t: 'connectivity' } // `const t = usePplrT()` → { t: 'pplr' }, via the wrapper map above function namespacedTranslators(code, root = '.') { const map = {} for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*use[A-Za-z]*T\s*\(\s*['"]([^'"]+)['"]/g)) map[m[1]] = m[2] const wrappers = wrapperNamespaces(root) for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(use[A-Za-z]*T)\s*\(\s*\)/g)) { if (wrappers[m[2]]) map[m[1]] = wrappers[m[2]] } return map } export function crossAreaKeys(code, file, root = '.') { const out = [] const ns = namespacedTranslators(code, root) if (!Object.keys(ns).length) return out let ast try { ast = babel.parse(code, { sourceType: 'module', plugins: ['jsx'], errorRecovery: true }) } catch { return out } walkAst(ast, (node) => { if (node.type !== 'CallExpression') return const name = node.callee?.type === 'Identifier' ? node.callee.name : '' if (!ns[name]) return const arg = node.arguments?.[0] if (arg?.type !== 'StringLiteral' || !arg.value.includes('.')) return const qualified = qualifyKey(ns[name], arg.value) if (resolveKey(qualified, root) !== undefined) return // resolves: fine if (resolveKey(arg.value, root) === undefined) return // plain missing key: keys-exist's job out.push({ file, kind: 'cross-area-key', text: `${name}('${arg.value}') → ${qualified} (never resolves; '${arg.value}' does)` }) }) return out } // ── the walk ──────────────────────────────────────────────────────────────────────────────────── function walkAst(node, visit, parent = null, stack = []) { if (!node || typeof node.type !== 'string') return visit(node, parent, stack) const next = [...stack, node] for (const k of Object.keys(node)) { if (k === 'loc' || k === 'leadingComments' || k === 'trailingComments') continue const v = node[k] if (Array.isArray(v)) { for (const c of v) if (c && typeof c.type === 'string') walkAst(c, visit, node, next) } else if (v && typeof v.type === 'string') walkAst(v, visit, node, next) } } // A literal is a GUARDED FALLBACK — reachable only when a translation is absent — in two shapes: // · te(key) ? t(key) : 'English' the documented existence check (sr) // · t.search || 'Search…' a PROXY t whose member read misses (people-groups-grid) // Both mean "the catalogue had nothing", so the English is the designed last resort rather than a // string that skipped translation. Ancestors are walked, not just the immediate parent, because the // fallback is often nested (`a ?? (te(k) ? t(k) : 'X')`). function guardedFallback(stack, names) { return stack.some((n) => { if (n.type !== 'ConditionalExpression' && n.type !== 'LogicalExpression') return false let found = false walkAst(n.test || n.left, (x) => { if (x.type === 'CallExpression' && /^te$/.test(calleeName(x))) found = true // a member read on a localisation object or alias: t.search, i18n.t.search if (x.type === 'MemberExpression' && x.object?.type === 'Identifier' && names.has(x.object.name)) found = true // THE "DID t() RETURN THE KEY?" IDIOM. vue-i18n returns the key itself when it is missing, so // `msg !== 'messages.engineMissing' ? msg : 'English'` is a miss check and its English arm is a // fallback. Matched on the KEY SHAPE (dotted, no spaces) rather than on any string comparison, // so an ordinary `x !== 'yes'` is not mistaken for one. (cities-internet, triaged 2026-09-28.) if (x.type === 'BinaryExpression' && /^[!=]==?$/.test(x.operator)) { for (const side of [x.left, x.right]) { if (side?.type === 'StringLiteral' && /^[a-z][\w-]*(\.[\w-]+)+$/.test(side.value)) found = true } } }) return found }) } function scanScript(code, file, out) { const names = localizedNames(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('…') // ★ 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) || isDomQuerySite(parent)) return if (parent && /Import|Export/.test(parent.type)) return if (parent && parent.type === 'TaggedTemplateExpression') return if (guardedFallback(stack, names)) return 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 }) }) } // ★ BOUND ATTRIBUTES HIDE LEAKS FROM A TEMPLATE WALKER. `:title="`Sort by ${col}`"` is a DIRECTIVE // whose value is an EXPRESSION, not a static attribute, so a scan that reads only static attrs sees // 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) { const code = String(expr || '').trim() if (!code) return const found = [] try { scanScript(`(${code})`, file, found) } catch { return } // not parseable as an expression: skip for (const f of found) out.push({ file, kind: `bind:${attrName}`, text: f.text }) } // ★ dir="ltr" MARKS TEXT THAT IS MEANT TO STAY ENGLISH. gospel's plugins use it as the island // marker for content the agent 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 (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, deliberate: inLtr }) } // (interpolations are handled below, on the node itself) // type 7 = DIRECTIVE. v-bind carries the attribute name in `arg`. if (p.type === 7 && p.name === 'bind' && p.exp?.content) { const attrName = p.arg?.content || 'bind' // :style and :class carry CSS and identifiers, never prose. Skipping them by ATTRIBUTE is // 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') { 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, inLtr) } 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 code = [descriptor.script?.content, descriptor.scriptSetup?.content].filter(Boolean).join('\n') if (code) { scanScript(code, file, out); out.push(...crossAreaKeys(code, file)) } return out } // ── production plugins: the scope this gate defends ───────────────────────────────────────────── // Read from the built manifest so the list cannot drift from what actually ships. function productionPluginDirs(root = '.') { const mf = join(root, 'public/dx-maps/manifest.json') if (!existsSync(mf)) return [] const m = JSON.parse(readFileSync(mf, 'utf8')) const names = m.bundles.filter((b) => b.stage === 'production').map((b) => b.bundle) return names.map((n) => join(root, 'plugin-designs', n)).filter((d) => existsSync(d)) } const sfcsIn = (dir) => { const out = [] const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { const p = join(d, e.name) if (e.isDirectory()) { if (!/node_modules|dist|snapshots|fixtures/.test(e.name)) walk(p) } else if (e.name.endsWith('.vue')) out.push(p) } } walk(dir); return out } // ── self-test: the gate must be shown able to SEE, and able to stay quiet ──────────────────────── const PLANTED_BAD = ` ` const PLANTED_GOOD = ` ` const XAREA_BAD = `const t = useOptionalT('regions') const msg = t('messages.engineMissing')` // The wrapper shape: no literal namespace at the call site, so it is only detectable by resolving // usePplrT() back to useOptionalT('pplr'). This is the shape pplr actually had. const XAREA_WRAPPED = `const t = usePplrT() const msg = t('messages.mapUnavailable')` const XAREA_GOOD = `const t = useOptionalT('messages') const msg = t('engineMissing') const t2 = useOptionalT('regions') const ok2 = t2('Countries')` function selfTest() { let fails = 0 const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ } // ★ THE FIXTURES MUST PARSE, ASSERTED FIRST. sr's first planted sample was malformed Vue, so the // scan found nothing in it and LOOKED blind — a broken fixture and a broken scanner produce the // same silence. parse() surfaces template errors that scanSfc would otherwise throw on. for (const [name, src] of [['planted-bad', PLANTED_BAD], ['planted-good', PLANTED_GOOD]]) { const { errors } = parse(src, { filename: `${name}.vue` }) ok(!errors || errors.length === 0, `FIXTURE ${name}.vue is valid Vue${errors && errors.length ? ': ' + errors[0].message : ''}`) } const bad = scanSfc(PLANTED_BAD, 'planted-bad.vue') const good = scanSfc(PLANTED_GOOD, 'planted-good.vue') ok(bad.some((f) => f.kind === 'template-text' && f.text === 'Prayer Coverage'), 'SEES a hardcoded heading in the TEMPLATE') ok(bad.some((f) => f.kind === 'attr:title'), 'SEES a hardcoded title= attribute') ok(bad.some((f) => f.kind === 'script-string' && f.text === 'No results were found'), 'SEES a hardcoded sentence in a SCRIPT BLOCK') ok(bad.some((f) => f.kind === 'template-literal' && f.text === 'countries'), 'SEES English inside a TEMPLATE LITERAL (the class that held a real defect)') 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') // ★ gospel's real case: an island SPLIT BY INTERPOLATIONS. Each run between two {{ }} is its own // text node, so a check that only marked a whole-text island reported the pieces as separate // leaks — and they nearly got translated. is the element they actually use. const splitIsland = bad.filter((f) => /sign languages on this map|both a film/.test(f.text)) ok(splitIsland.length > 0, 'sees the pieces of an interpolation-split island at all') ok(splitIsland.every((f) => f.deliberate), `every piece of an interpolation-split dir="ltr" island is DELIBERATE (got ${splitIsland.filter((f) => !f.deliberate).length} not marked)`) 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) => /gospel-resources-dashboard|regions-dashboard, doxa/.test(f.text)), 'QUIET on a DOM query selector, including a comma-separated list of custom-element tags') 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 // fails and whoever adds it must update the header rather than leave it claiming a gap that closed. ok(!good.some((f) => f.text === 'Other'), 'DOCUMENTED BLIND SPOT: a single-word leak is NOT reported (see header)') // ★ CROSS-AREA KEYS: a real bug found twice in one day, and the shape both instances had. const xb = crossAreaKeys(XAREA_BAD, 'planted-xarea.vue') const xg = crossAreaKeys(XAREA_GOOD, 'planted-xarea-ok.vue') ok(xb.length === 1 && /never resolves/.test(xb[0].text), 'SEES a cross-area key through a namespaced t() (qualified misses, unqualified exists)') ok(xg.length === 0, `QUIET when the key resolves under its own namespace (got ${xg.length})`) const xw = crossAreaKeys(XAREA_WRAPPED, 'planted-xarea-wrapped.vue') ok(xw.length === 1 && /pplr\.messages\.mapUnavailable/.test(xw[0].text), 'SEES it through a WRAPPER factory with no literal namespace (usePplrT() → useOptionalT(\'pplr\'))') console.log(fails ? `\nSELF-TEST: ${fails} FAIL(s)` : '\nSELF-TEST: the gate can see planted leaks and stays quiet on correct code') return fails } // Short HEAD sha plus a dirty marker, or 'unknown' outside a checkout. function treeStamp() { try { const sha = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() const dirty = execSync('git status --porcelain', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() return dirty ? `${sha}+dirty(${dirty.split('\n').length} file(s))` : sha } catch { return 'unknown (not a git checkout)' } } // ── main ──────────────────────────────────────────────────────────────────────────────────────── // RUN ONLY WHEN INVOKED DIRECTLY. Without this guard, `import`ing the module to reuse scanSfc() runs // the whole production scan as a side effect — which is how I first "verified" it against sr's real // example and got a full gate run instead of one file's findings. A gate that cannot be imported // cannot be unit-tested by whoever owns it. const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href if (!isMain) { /* imported: expose scanSfc and do nothing */ } else main() function main() { const selfOnly = process.argv.includes('--self-test') let fails = selfTest() if (fails) { console.log('\nHARDCODED-ENGLISH: self-test failed — findings below would not be trustworthy'); process.exit(1) } if (selfOnly) process.exit(0) const dirs = productionPluginDirs('.') const findings = [] let files = 0 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) // ★ THE RUN NAMES ITS OWN SHA. Three reports today were true of an older artifact than the one in // hand, and each cost someone a wasted investigation — the last one nearly had a working island // check "fixed". gospel's corollary is that the reader cannot check what the writer did not say, so // the tool says it: any number quoted from this output carries the tree it was measured on, and a // dirty tree is marked because then the sha alone does not describe what ran. // // ★ AND IT PROVED ITSELF WITHIN THE HOUR, in the case that justifies it better than the one it was // built for. gospel and I compared counts and disagreed — 185/169/155 against 178/168/147 — and // spent the exchange assuming one set was stale. Neither was: SAME SCRIPT SHA, DIFFERENT SOURCE // TREES, twelve scanned SFCs apart. Both numbers were honest measurements of different bytes. // "7 production plugin(s), 19 SFC(s) scanned" describes the SHAPE of a scan and says nothing about // WHICH BYTES, so two correct runs are indistinguishable in a report. The stamp is therefore not // only for the writer who forgets to name a sha — it is what lets a READER tell two runs apart at // all. When counts disagree, compare the tree line before anything else. console.log(`\n ${dirs.length} production plugin(s), ${files} SFC(s) scanned · tree ${treeStamp()}`) 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 agent 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`) } // ★ THE STAMP GOES ON THE LINE PEOPLE QUOTE, not only on the header. gospel's qualification is that // they investigated our mismatch because the ARITHMETIC WOULD NOT CLOSE — an eight-hit gap was too // large to rationalise — and that a gap of one or two would have been shrugged off as drift. So the // residual failure is a discrepancy SMALL ENOUGH TO EXPLAIN AWAY, and provenance in a header the // quoter skips does nothing for it. The verdict line is the one that gets pasted into a report, so // the tree travels with the number rather than three lines above it. console.log(leaks.length ? `\nHARDCODED-ENGLISH: ${leaks.length} user-visible English string(s) not going through t() [tree ${treeStamp()}]` : `\nHARDCODED-ENGLISH: PASS — no user-visible hardcoded English in any production plugin [tree ${treeStamp()}]`) process.exit(leaks.length ? 1 : 0) }