558 lines
34 KiB
JavaScript
558 lines
34 KiB
JavaScript
#!/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) => /^<?\/?[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',
|
|
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 = `<template>
|
|
<div>
|
|
<h2>Prayer Coverage</h2>
|
|
<button title="Reset the map">x</button>
|
|
<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>
|
|
<p><bdi dir="ltr">Of {{ tally.langs }} sign languages on this map, covering {{ tally.people }} people,
|
|
{{ tally.both }} have both a film and a text Scripture.</bdi></p>
|
|
</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>
|
|
<div class="rd-card is-open">
|
|
<h2>{{ t('Prayer Coverage') }}</h2>
|
|
<th :title="t('Sort this table')" :class="\`rd-card \${x}\`" :id="\`country-\${code}\`">h</th>
|
|
<span>{{ t('Prayer Coverage') }}</span>
|
|
<span>{{ n }} {{ t('Countries') }}</span>
|
|
</div>
|
|
</template>
|
|
<script setup>
|
|
import { alpha } from './alpha.js'
|
|
import { beta } from '../beta/gamma.js'
|
|
const t2 = t
|
|
const viaAlias = t2('All Regions')
|
|
const guarded = te(key) ? t(key) : 'Regions List'
|
|
const cls = 'rd-card is-open'
|
|
const url = 'https://example.test/a/b'
|
|
const lang = \`\${l}-u-nu-latn\`
|
|
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')
|
|
const host = document.querySelector('gospel-resources-dashboard, gospel-resources-map')
|
|
const one = el.closest('regions-dashboard, doxa-simple-map')
|
|
</script>`
|
|
|
|
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. <bdi dir="ltr"> 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.
|
|
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`)
|
|
}
|
|
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)
|
|
}
|