#!/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 } // ── LITERAL STRINGS PASSED AS PROPS ───────────────────────────────────────────────────────────── // ★ THE THIRD FALSE-NEGATIVE CLASS, and the gate is structurally unable to confirm it (gospel). // poster-wall.vue passes `unit="UPG" item-noun="list"` to , and PosterWall.vue renders // `{{ itemNoun }}` and `{{ unit }}` as visible text. Neither file shows a leak to a template scan: // in the plugin it is an attribute VALUE, in the library it is an interpolation of a VARIABLE. The // English and the render live in different files, so no single-file rule can join them. // Reported as SUSPECT, never counted as a leak, because whether the child renders the prop is not // knowable from here — the child may use it as a key, an id, or an event name. A suspect is a // question for a human, and calling it a defect would make the count untrustworthy in the other // direction. const PROP_HINT = /noun|label|title|unit|text|heading|intro|placeholder|caption/i // Component tags only: PascalCase, or hyphenated custom elements. A plain
takes attributes, // not props, and its visible ones are already covered by VISIBLE_ATTRS. const isComponentTag = (tag) => /^[A-Z]/.test(tag) || /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(tag) function literalProps(node, file, out) { if (node.type !== 1 || !isComponentTag(node.tag || '')) return for (const p of node.props || []) { if (p.type !== 6 || !p.value?.content) continue // static attribute only if (!PROP_HINT.test(p.name)) continue const text = String(p.value.content).trim() if (!text.length || !isProse(text) || allowedBy(text)) continue out.push({ file, kind: `suspect-prop:${p.name}`, text: `<${node.tag} ${p.name}="${text}">`, suspect: true }) } } // ── 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. // ★ ONLY A STATIC dir="ltr" MARKS AN ISLAND, and getting this wrong blinded a whole plugin. // The first version also accepted a BOUND :dir whose expression merely CONTAINED 'ltr' — which // matches `:dir="isRTL ? 'rtl' : 'ltr'"`, the correct locale-aware direction switch that every // RTL-ready component puts on its ROOT. The effect was that such a component's entire template was // treated as deliberate English and every leak in it silenced. regions-dashboard read 0 for exactly // that reason: not clean, BLIND. A dynamic direction switch is the OPPOSITE of "this stays English" // — it is the component promising to follow the reader's locale. const hasLtr = (node) => (node.props || []).some((p) => p.type === 6 && p.name === 'dir' && p.value?.content === 'ltr') function walkTemplate(node, file, out, ltr = false) { if (!node) return // ★ NAME THE ISLAND THAT SUPPRESSED A STRING, not just the fact of suppression. The deliberate // bucket is the one place where over-claiming costs nothing and shows nothing: a 74-line island // hiding 27 strings and a one-line read identically in a flat count. gospel found exactly // that on their own card — an island they believed was a caveat turned out to cover the honesty // prose as well. An island is an object with a size, so the report gives it one. const own = node.type === 1 && hasLtr(node) const inLtr = ltr || own const island = own ? { file, line: node.loc?.start?.line ?? 0, endLine: node.loc?.end?.line ?? 0, tag: node.tag || 'element' } : (ltr && typeof ltr === 'object' ? ltr : null) 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, island }) } if (node.type === 1) literalProps(node, file, out) 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, island }) } // (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; out[i].island = island } } } } // 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; out[i].island = island } } for (const c of node.children || []) walkTemplate(c, file, out, island || 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') // ★ literal props handed to a component that may render them (gospel's poster-wall case) const sus = bad.filter((f) => f.suspect) ok(sus.some((f) => f.kind === 'suspect-prop:unit' && /UPG/.test(f.text)), 'SEES a literal prop whose name suggests it is rendered (unit=)') ok(sus.some((f) => f.kind === 'suspect-prop:item-noun'), 'and one named item-noun') ok(!sus.some((f) => /event-name/.test(f.kind)), 'QUIET on a prop whose name does not suggest rendering (event-name=)') ok(!bad.filter((f) => !f.deliberate && !f.suspect).some((f) => /UPG|item-noun/.test(f.text)), 'a SUSPECT is reported separately and is NOT counted as a leak — the child may never render it') const ltr = bad.filter((f) => f.deliberate && !f.suspect) 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') // ★ a suppressed string must name the island that suppressed it, so an over-broad island is // visible as an object with a size rather than as part of a flat total. ok(ltr.every((f) => f.island && typeof f.island.line === 'number'), 'every suppressed string carries the ISLAND that suppressed it (file, line, tag)') ok(new Set(ltr.map((f) => f.island && f.island.line)).size >= 2, 'and distinct islands are distinguished, not merged into one bucket') // ★ 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') // ★ THE FIXTURE ROOT CARRIES :dir="isRTL ? 'rtl' : 'ltr'" — the correct locale-aware switch every // RTL-ready component puts on its root. An earlier version matched any bound :dir containing the // text 'ltr' and so treated the WHOLE component as deliberate English, silencing every leak in it. // regions-dashboard read 0 for that reason: not clean, blind. Every assertion above now runs inside // such a component, so the blindness cannot come back without the whole self-test failing. ok(bad.filter((f) => !f.deliberate).length >= 4, `a DYNAMIC :dir switch is not an English island — leaks inside it are still counted (got ${bad.filter((f) => !f.deliberate).length})`) 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)' } } // ── CANARY MODE: turn "plugin X = 0" from a claim into a proven 0 ─────────────────────────────── // ★ WHY THIS EXISTS. Both serious bugs in this gate made it QUIETER — the single-word blind spot and // the dynamic-:dir island bug, which silenced a whole plugin's template. A scan that under-reports is // indistinguishable from code that is fine, so a zero is only worth what its detector is worth ON // THAT FILE. Canary mode plants a known leak in each production SFC and requires it to be caught: // a plugin that cannot catch a planted leak cannot be trusted to report zero. // // IN MEMORY, NOT IN A TEMP DIR. scanSfc takes a source string, so nothing is copied to disk — there // is no cleanup to get wrong, nothing to leave behind on a box that has been short of space all day, // and no chance of scanning a stale copy. const CANARY = 'Canary sentinel sentence that must be reported' // Injected as a text node INSIDE the template root, so it inherits whatever the root carries. That // is deliberate: if a plugin's root marks it as a deliberate-English island, the canary is reported // as deliberate rather than as a leak, and the run says so — which is exactly the state that made // regions-dashboard read 0 while being blind. // ★ INSIDE THE ROOT ELEMENT, AND LOCATED BY THE PARSER. Two earlier attempts were wrong for the // same reason in different ways. Injecting after