#!/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 a later review found in cities-internet after the same line had been triaged 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' import { productionForGating, stageReason } from './lib/effective-stage.mjs' // ── 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) // dir and lang join the list (SR-DIRLANG): `'" dir="'` is the seam between two // attributes in a print page assembled by concatenation, and every RTL-aware page writes both. // They are the same class as class= and href= — attribute NAMES, not text a reader sees. || /\b(class|href|src|alt|loading|width|height|style|id|target|rel|dir|lang)\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: 'CSS FUNCTION FRAGMENTS left by splitting a template literal. A quasi is judged alone, so ' + '`url(${base}/x.svg)` is tested as "url(" and "/x.svg)", and `repeat(${n}, minmax(0, 1fr))` ' + 'as "repeat(" and ", minmax(0, 1fr))". The WHOLE string is obviously CSS and the existing CSS ' + 'shape matches it, but no fragment carries a brace or a colon-value pair, so each one fell ' + 'through and was reported as English. Measured 2026-09-29: this was 3 of my 94 findings.', test: (s) => /^[a-z-]+\($/.test(s) || /^[,)\s]*[a-z-]+\([^)]*\)[,)\s]*$/.test(s) || /^[,)\s]+$/.test(s) }, // ★ DECLARATION SEAMS, the other half of the same split (I18N-EXP-PLAN). Splitting // `width:${w}px;height:${h}px` leaves the quasi "px;height:" — a seam BETWEEN two declarations, // carrying the tail of one value and the head of the next property. It has a colon but no value // and no brace, so every CSS shape above misses it. exp measured these as false leaks in // upg-neighbor and prayer-walk-planner once the gate runs over experimental bundles in report mode. // // ⚠ THE `;` IS WHAT KEEPS PROSE OUT, and it is load-bearing: "12 people:" and "Access in their own // language:" have the colon but no semicolon-separated declaration before it. Verified on the case // that worries me most — "Pools checked; nothing found:" HAS a semicolon and still stays loud, // because the segments either side are not `property: value` pairs. { why: 'CSS DECLARATION SEAMS left by splitting a template literal: "px;height:", "; background:"', test: (s) => /^[a-z%]*\s*;\s*(?:[a-z-]+\s*:\s*[^;]*;\s*)*[a-z-]+\s*:\s*$/.test(s) }, { why: 'ID PREFIXES ending in a COLON, built by interpolation: `fam:${x}`, `dial:${x}`, and ' + 'localStorage keys like `pgg-cards-per-row:${profile}`. The hyphen rule below covers a ' + 'trailing separator but not a colon, so these read as prose. A prefix is not a sentence: ' + 'it is one lowercase token, no spaces, ending at the separator.', test: (s) => /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*:$/.test(s) }, { 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) }, // ★ A SEPARATOR AND A BRAND IS NOT A SENTENCE. `{{ t('gospel.res.gospelAudio') }} · GRN` splits // into a keyed half and the text node "· GRN", and only the second reaches this scan. gospel // counted 33 findings of that shape (their HARDCODED-ENGLISH-CLASSES, class 1). // // ⚠ THE NAMES ARE LISTED, NOT PATTERNED. "punctuation then a capitalised word" would swallow // "· Not found in any pool" — a real caveat, and exactly the class gospel found 12 live defects // hiding in. A name earns its place here by being a publisher or standard that is the same word // in every language; anything else stays counted. The separator must be leading: "GRN · and the // rest of the sentence" is prose that happens to start with a brand. { why: 'a leading separator followed only by never-translate publisher/standard names', test: (s) => { const NAMES = /^(eBible\.org|PNG\.Bible|Faith Comes By Hearing|JESUS Film|Arclight|Glottolog|Open Bible Stories|Door43|GRN|Zume|Cru|GodTools|Scripture Earth|Deaf Bible Society|DOOR International|knowgod\.com|ISO 639-3|Joshua Project|OpenStreetMap|geoBoundaries)$/ const m = /^[\s·•|,\-–—]+(.+?)[\s·•|,\-–—]*$/.exec(s) return !!m && NAMES.test(m[1].trim()) } }, { why: 'CSS MEDIA QUERIES passed to matchMedia, and bare CSS keyword VALUES. Translating a query ' + 'silently breaks the feature it guards — "(prefers-reduced-motion: reduce)" rendered in ' + 'another language matches nothing, so motion safety turns off for exactly the readers who ' + 'asked for it. A query is parenthesised feature:value; a value is CSS keywords only.', test: (s) => /^\(\s*(?:prefers-|min-|max-|orientation|hover|pointer|display-mode|forced-colors)[a-z-]*\s*:/.test(s) || /^(?:top|bottom|left|right|center|start|end)(?:\s+(?:top|bottom|left|right|center|start|end))?$/.test(s) }, { why: 'LICENCE ATTRIBUTION for map tiles. The credit a data licence requires is the one string ' + 'that must stay exactly as the licensor wrote it, in every language. These render only when ' + 'the live sources cannot be read, which is the case a licence never allows to come out empty.', test: (s) => /^©/.test(s) && /(OpenStreetMap|OpenFreeMap|OpenMapTiles|Mapbox|Maxar|Esri)/.test(s) }, { why: 'the PRODUCT NAME: a proper noun, never translated, used as the last-resort poster title', test: (s) => /^Doxa Maps?$/.test(s) }, ] const allowedBy = (s) => ALLOW.find((a) => a.test(s)) // ── localisation calls: base names plus aliases discovered per file ────────────────────────────── function localizedNames(code) { // tStatic is the library's own translator for code outside a component's setup (imported from // @map/i18n), and leaving it out meant `tStatic('grid.search') || 'English'` — correct, guarded, // fully translated code — read as a leak wherever it was used directly rather than behind a wrapper. // keyedError(key, 'English', params) (library/i18n/keyedError.js) is a translator call too: its English is // the FALLBACK of a catalogue key that errorText() shows in the reader's language, so it is judged like // T(key, 'English') — quiet when the key resolves in every locale, a finding when it does not. As a plain // call it would have counted every keyed error as a leak, and exempting it like new Error() would have // hidden a typo'd key. const names = new Set(['t', 'tf', '$t', 'tStatic', 'keyedError']) for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(t|tf)\b\s*(?![(.])/g)) names.add(m[1]) // WRAPPERS ARE TRANSLATORS TOO. `const T = (key, en) => tStatic(key) || en` is the shape most of // these components use, and matching only `const X = t` missed every one of them — the wrapper's // own English argument then read as a leak. Recognised by the BODY calling a known translator. for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*=>\s*([^\n]*)/g)) { if (/\b(?:t|tf|\$t|tStatic)\s*\(/.test(m[2])) names.add(m[1]) } return names } // Some wrappers bake in an AREA: `const L = (key, en) => tStatic('dashboard.' + key) || en` is // called as L('align_dashboard') but the catalogue holds dashboard.align_dashboard. Without the // prefix the key looks unresolvable and a correct fallback keeps counting. function keyPrefixes(code) { const out = new Map() for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*=>\s*[^\n]*?\b(?:t|tf|\$t|tStatic)\s*\(\s*['"]([\w.\-]+\.)['"]\s*\+/g)) { out.set(m[1], m[2]) } return out } // ★ 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. // ★ CONCATENATION HIDES A DEVELOPER SITE FROM ITS OWN GUARD. `throw new Error(\`head\` + rest)` // puts a BinaryExpression between the literal and the NewExpression, so the parent check below sees // the `+` and not the `new Error`. Measured on legend-tree.js:392, a thrown message reported as a // leak. It is NOT a .js-path bug — a .vue file concatenating inside a throw hits it identically; the // .js scan just surfaced a shape that is rarer in templates. // // Only `+` chains are walked: a string built with + is still the same argument to the same call, // whereas any other expression type between the two is a different construct and should be judged // on its own site. function developerSiteAncestor(parent, stack) { if (isDeveloperSite(parent)) return true if (!parent || parent.type !== 'BinaryExpression' || parent.operator !== '+') return false for (let i = stack.length - 1; i >= 0; i--) { const n = stack[i] if (n.type === 'BinaryExpression' && n.operator === '+') continue return isDeveloperSite(n) } return false } 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) const CATALOGUES = new Map() function catalogueFor(locale, root = '.') { const hit = CATALOGUES.get(locale) if (hit) return hit const cat = {} const dir = join(root, `library/i18n/locales/${locale}`) if (existsSync(dir)) { for (const f of readdirSync(dir)) { if (!f.endsWith('.json')) continue try { cat[f.replace(/\.json$/, '')] = JSON.parse(readFileSync(join(dir, f), 'utf8')) } catch { /* skip */ } } } CATALOGUES.set(locale, cat) return cat } function catalogue(root = '.') { return catalogueFor('en', root) } // Every locale that ships, read from the tree rather than hardcoded, so adding a locale tightens // this check automatically instead of silently leaving the new one unguarded. let LOCALES = null function locales(root = '.') { if (LOCALES) return LOCALES const dir = join(root, 'library/i18n/locales') LOCALES = existsSync(dir) ? readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort() : [] return LOCALES } // `a.b.c` resolves as area `a` then path b.c inside it — the shape library/i18n/index.js builds. function resolveKey(key, root = '.', locale = 'en') { const cat = catalogueFor(locale, 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 } // ★ A FALLBACK BEHIND A KEY THAT REALLY RESOLVES IS NOT A LEAK. `T('dashboard.map_loading', 'Research // map loading…')` is the idiom this codebase uses everywhere: the second argument is what shows if the // catalogue ever loses the key, so the English is a SAFETY NET, not the string the reader gets. Counting // it made ~19% of the remaining findings correct code, and sent owners off to write translations that // already existed (sr, 2026-09-29). // // The exemption is earned, never assumed, because an unresolved fallback is a REAL leak wearing the // same shape — a typo'd key renders English forever and looks perfectly correct at the call site. So // the key must resolve to a non-empty string in EVERY shipped locale, and in no non-English locale may // it merely echo the English: both of those mean a reader somewhere still sees English, which is the // defect this gate exists to find. Anything less than fully translated keeps counting. // Words a translator would actually have to render: placeholders, punctuation and bare numbers are // not translatable, so "{label} · {count} · {pct}%" counts as zero and never reads as untranslated. function translatableWords(s) { return String(s) .replace(/\{[^}]*\}/g, ' ') // {label} interpolation holes .split(/\s+/) .filter((w) => /[A-Za-z]{2,}/.test(w)) .length } function fallbackFullyResolved(key, root = '.') { const locs = locales(root) if (!locs.length) return false // no catalogue to vouch for it: cannot exempt const en = resolveKey(key, root, 'en') if (typeof en !== 'string' || !en.trim()) return false for (const loc of locs) { const v = resolveKey(key, root, loc) if (typeof v !== 'string' || !v.trim()) return false // missing in one locale = still a leak // ★ IDENTICAL-TO-EN IS ONLY EVIDENCE WHEN THERE IS SOMETHING TO TRANSLATE. Measured across this // catalogue, the identical entries are overwhelmingly CORRECT: cognates a locale really does // share ("Normal" in de, "Auto" in fr, "Total" in es), proper nouns, and format-only strings like // "{label} · {count} · {pct}%" that contain no translatable words at all. Failing on every echo // would call all of those leaks. A multi-word English SENTENCE reproduced verbatim is the case // that actually means untranslated, so that is the one that keeps counting. if (loc !== 'en' && v === en && translatableWords(en) >= 3) return false } return true } // ★ THE KEY OF A LOOKUP TABLE IS AN IDENTIFIER, NOT A LABEL. gospel's resources-list has // const RES_KEY = { 'Bible video': 'gospel.res.bibleVideo', ... } // title: RES_KEY[r.title] ? tr(RES_KEY[r.title]) : r.title // so the English on the LEFT is matched against a value that arrives in the DATA, and the string a // reader sees comes from the key on the right. Translating the left side would make every lookup // miss and fall through to the raw data value — the count would drop while the display broke, which // is the worst shape a gate finding can have. // // Earned, not assumed, exactly as the fallback exemption is: the property's VALUE must be an i18n // key that really resolves. A table pointing at a key nobody wrote is a real defect — the lookup // succeeds and hands t() a key the catalogue has never heard of — so that still counts. // ★ A STRING COMPARED AGAINST DATA IS A VALUE, NOT A LABEL. `scope === 'Full Bible'` tests a cell // of the shipped resource table; the reader never sees that literal, and the row it selects is // rendered through a key. Translating it makes the comparison miss and the feature silently stop — // the same inversion as a lookup-table key, which is why gospel classed them together (their // HARDCODED-ENGLISH-CLASSES, class 4). Only === and !== count: a loose == against a string is rare // enough here that leaving it reported costs nothing, and `.includes('…')` is NOT this shape — it // can be a substring test on user-visible prose. function isDataComparison(node, parent) { if (!parent || parent.type !== 'BinaryExpression') return false if (parent.operator !== '===' && parent.operator !== '!==') return false return parent.left === node || parent.right === node } // ★ A PURE DATA MAP'S KEYS ARE JOIN VALUES, NOT LABELS. territory-counts.js is // export const ADM1_COUNTS = { 'Andhra Pradesh': 81, 'Assam': 15, … } // and those keys are matched against `props.shapeName` off the map data. Translating one breaks the // join silently — the count goes missing and nothing errors. Same inversion as a lookup table whose // values are i18n keys, which isResolvedLookupKey already covers; this is the case where the values // are NUMBERS, so there is no key to resolve and that rule cannot vouch for it. // // ⚠ JUDGED ON THE WHOLE OBJECT, NOT ON ONE PROPERTY. A single numeric value proves nothing — a map // of sentences with one count in it would exempt the sentences. Every value must be a non-key // literal, and there must be enough of them that the shape is a table rather than a couple of // options. Below that floor the object is judged property by property, as before. // // ⚠ AND THE KEYS THEMSELVES MUST LOOK LIKE IDENTIFIERS, NOT PROSE. "Andhra Pradesh" is a name; "A // resource not found means NOT FOUND in the pools checked" is a sentence that happens to be used as // a key, and that is a real leak — gospel found twelve defects hiding in exactly that kind of // already-classified bucket. Anything with sentence punctuation stays loud whatever its value is. // ★ REGION IDENTITIES, EXEMPT ONLY WHILE THEY ARE TRANSLATABLE (R26). // legend-tree's REGION_OF maps a zone slug to a region NAME, and that name is an identity: node ids // derive from it, and every surface that shows one translates it at render through REGION_KEY — // upg-100-list.vue:440 `REGION_KEY[english] ? t(\`poster.${…}\`) : english`, poster-wall.vue:129 the // same. So the English never reaches a reader. // // ⚠ THE EXEMPTION IS CONDITIONAL, AND THAT IS THE WHOLE POINT. It holds only for a name that IS a // key of REGION_KEY. A new region added to REGION_OF without a REGION_KEY entry falls through that // `|| english` and renders English in all eleven locales — which is precisely the defect upg100 // already shipped once with Americas and Europe. So the rule flags it, and the gate keeps working as // the table grows instead of blessing the file. // // Deliberately NOT "object values" and NOT "legend-tree.js": either would exempt the next table // nobody has checked. The membership test is what makes this safe to have at all. function regionKeyNames(code) { // ⚠ NO NEWLINE REQUIREMENT. The first version anchored on `\n}` and so never matched a REGION_KEY // written on ONE LINE — regionKeyNames returned null, the rule exited early, and BOTH two-sided // cases passed without the exemption ever running. Found by poisoning the membership test and // seeing nothing fail. const m = /\bREGION_KEY\s*=\s*\{([\s\S]*?)\}/.exec(code) if (!m) return null return new Set([...m[1].matchAll(/'([^']+)'\s*:/g)].map((x) => x[1])) } function isTranslatedRegionIdentity(text, code) { const names = regionKeyNames(code) if (!names || !names.has(text)) return false // it must actually be used as a region identity in this file, not merely share a spelling return /const REGION_OF\s*=/.test(code) || /regionFor\s*=/.test(code) } const DATA_MAP_MIN_ENTRIES = 6 function isPureDataMapKey(node, parent, grand) { if (!parent || parent.type !== 'ObjectProperty' || parent.key !== node) return false if (!grand || grand.type !== 'ObjectExpression') return false const props = grand.properties || [] if (props.length < DATA_MAP_MIN_ENTRIES) return false // every value a plain non-key literal: a number, a boolean, or a short bare token const allData = props.every((p) => { if (p.type !== 'ObjectProperty') return false const v = p.value if (v?.type === 'NumericLiteral' || v?.type === 'BooleanLiteral' || v?.type === 'NullLiteral') return true if (v?.type === 'UnaryExpression' && v.argument?.type === 'NumericLiteral') return true // -1 // a short lowercase token ('scripture', 'film') is an identifier, not a sentence if (v?.type === 'StringLiteral') return /^[a-z][\w-]{0,24}$/.test(v.value) return false }) if (!allData) return false // the KEY must read as a name or an identifier, never as a sentence const key = node.value if (/[.!?;:]|\s—\s|\bthe\b.*\bthe\b/i.test(key)) return false // Eight, not six: "Dādra and Nagar Haveli and Damān and Diu" is one real Indian union territory // and the longest key in a 46-entry join table. A place name is still not a sentence — the // punctuation test above is what separates them, not the length. return key.split(/\s+/).length <= 8 } function isResolvedLookupKey(node, parent, grand) { // the literal must BE the property's key, not its value if (!parent || parent.type !== 'ObjectProperty' || parent.key !== node) return false if (!grand || grand.type !== 'ObjectExpression') return false const v = parent.value if (v?.type !== 'StringLiteral') return false // a dotted key shape, and it has to resolve — a bare category like 'scripture' is not a key // A dotted key resolves directly. A BARE key is a SUFFIX the caller prefixes — legend-tree's // REGION_KEY maps 'Middle East' -> 'region_middle_east' and the call site reads // t(`poster.${REGION_KEY[english]}`). Try the bare value against every area rather than // rejecting it, so a suffix table is exempt on the same terms: the key it names must resolve. if (/^[a-z][\w-]*(\.[\w-]+)+$/i.test(v.value)) return fallbackFullyResolved(v.value) if (/^[a-z][\w]*(_[\w]+)*$/i.test(v.value)) { for (const area of Object.keys(catalogue())) { if (fallbackFullyResolved(`${area}.${v.value}`)) return true } } return false } // The `T('a.key', 'English')` / `L('key', 'English')` / `tStatic('a.key') || 'English'` shapes, seen // from the English literal looking outward. `names` is the set of translator identifiers already // resolved for this file, so an alias is recognised without being named here. function resolvedFallbackOf(node, parent, stack, names, root = '.') { // form 1: the literal is the SECOND argument of a translator call if (parent && parent.type === 'CallExpression' && names.has(calleeName(parent)) && parent.arguments.length >= 2 && parent.arguments[1] === node) { const k = parent.arguments[0] if (k?.type === 'StringLiteral') return k.value } // form 2: the literal is the RIGHT side of `t(…) || 'English'` or `t(…) ?? 'English'` const lg = stack[stack.length - 1] if (lg && lg.type === 'LogicalExpression' && (lg.operator === '||' || lg.operator === '??') && lg.right === node) { // `||` is left-associative, so `config || t('key') || 'English'` parses with the whole // `config || t('key')` on the left. The translator is the LAST alternative before the English, // which is the rightmost call down that spine — a real shape here: an instance may override the // wording, otherwise the catalogue, and the English only if neither exists. let call = lg.left while (call?.type === 'LogicalExpression' && (call.operator === '||' || call.operator === '??')) call = call.right if (call?.type === 'CallExpression' && names.has(calleeName(call))) { const k = call.arguments[0] if (k?.type === 'StringLiteral') return k.value } } return null } // ★ 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, line: p.loc?.start?.line ?? node.loc?.start?.line ?? 0 }) } } // ── 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 }) } // A node's own line, shifted into file coordinates. Babel counts from 1, so a block that itself // starts at line N puts its first line at N, not N+1 — hence the -1. Missing loc (errorRecovery can // produce it) degrades to the block start rather than to a confident 0. function lineOf(node, lineBase) { const l = node?.loc?.start?.line return typeof l === 'number' ? l + lineBase - (lineBase ? 1 : 0) : (lineBase || 0) } // ★ A FINDING WITHOUT A LINE COSTS THE READER THE SEARCH. Reporting only file + text means every // owner re-derives the site by grepping the string, and the FIRST textual match is very often a // comment that discusses the string rather than the code that renders it — sr triaged two findings // to the wrong site that way before adding this. `lineBase` maps a parsed block's own coordinates // back onto the file: 0 for a whole script block that starts at line 1, the block's start line // otherwise, and the attribute's line for a bound expression (which is re-parsed standalone, so its // internal numbering describes the fragment and not the file). function scanScript(code, file, out, lineBase = 0) { const names = localizedNames(code) const prefixes = keyPrefixes(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 // ★ THE KEY ARGUMENT IS EXEMPT; THE FALLBACK ARGUMENT MUST EARN IT. `t('a.key')` is never a leak, // but `T('a.key', 'English')` is only correct if that key resolves — a typo'd key renders the // English forever while looking right at the call site. Exempting every argument of a translator // call, as this rule used to, made the gate structurally unable to see that defect. The fallback // argument therefore falls through to the resolution test below. if (parent && parent.type === 'CallExpression' && names.has(calleeName(parent))) { const isFallbackArg = parent.arguments.length >= 2 && parent.arguments[1] === node if (!isFallbackArg) 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 (developerSiteAncestor(parent, stack) || isDomQuerySite(parent)) return if (parent && /Import|Export/.test(parent.type)) return if (parent && parent.type === 'TaggedTemplateExpression') return if (guardedFallback(stack, names)) return // A fallback whose key really resolves everywhere is correct code. An unresolved one is not, // and keeps counting — so the key is looked up, not merely observed to be present. const fbKey = resolvedFallbackOf(node, parent, stack, names) if (fbKey !== null) { const callee = parent?.type === 'CallExpression' ? calleeName(parent) : calleeName(stack[stack.length - 1]?.left) const full = (prefixes.get(callee) || '') + fbKey if (fallbackFullyResolved(full)) return } // An English key in a table whose value is a real i18n key: an identifier the data is matched // against, never a string the reader sees. // stack's last entry IS the parent, so the enclosing object is one further back. if (isResolvedLookupKey(node, parent, stack[stack.length - 2])) return // A pure data map's keys are join values — see isPureDataMapKey. if (isPureDataMapKey(node, parent, stack[stack.length - 2])) return // A literal tested against data with === is a value the reader never sees. if (isDataComparison(node, parent)) 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 // A region identity translated at render — exempt only while REGION_KEY names it. if (isTranslatedRegionIdentity(text, code)) return out.push({ file, kind: 'script-string', text, line: lineOf(node, lineBase) }) 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, line: lineOf(node, lineBase) }) }) } // ★ 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. // Just the translator DECLARATION lines of a script block — never the whole block, whose own strings // would then be re-reported once per bound expression in the file. Each is rewritten to a bare stub: // only the NAME and any baked-in key prefix matter to the fallback test, and a stub carries no English // of its own to be counted. function translatorDecls(code) { const out = [] for (const name of localizedNames(code)) { if (name === 't' || name === 'tf' || name === '$t') continue const pfx = keyPrefixes(code).get(name) // NB the stub must contain no `||`/`??` and no te() guard of its own: guardedFallback keys on // exactly those shapes, so a stub written the natural way exempts every fallback in the file // whether its key resolves or not — the over-exemption this whole check exists to avoid. out.push(pfx ? `const ${name} = (k, en) => t('${pfx}' + k)` : `const ${name} = t`) } return out.join('\n') } // ★ A BOUND EXPRESSION IS PARSED ALONE, SO IT CANNOT SEE ITS OWN TRANSLATOR. `{{ T('a.key', 'English') }}` // is scanned as the fragment `(T('a.key', 'English'))`, where `T` is an unknown identifier — the // component's `const T = (key, en) => …` lives in the script block, which is not in the fragment. The // translator declarations are therefore carried in and prepended, so the template half of a file judges // a fallback by exactly the rules the script half does. Without this the exemption fired in ` 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')` // ★ EFFECTIVE STAGE: a production leaf pulls experimental bundles into the production bar. const STAGE_FIXTURE = { bundles: [ { bundle: 'leaf-prod', stage: 'production', composes: ['mid-exp', 'pair/with-profile'] }, { bundle: 'mid-exp', stage: 'experimental', composes: ['deep-exp'] }, { bundle: 'deep-exp', stage: 'experimental' }, { bundle: 'pair', stage: 'experimental' }, { bundle: 'lonely-exp', stage: 'experimental' }, // ★ CYCLES ARE REAL IN THIS MANIFEST, not hypothetical: six edges are self-composition via the // bundle/profile form (country-posters, my-upg-100-list, regions-dashboard all compose // themselves), and upg100 measured that a naive recursive walk hangs on it TODAY. The header // claimed termination in prose and never tested it, which is the same gap as an untested // fallback — so a self-cycle and a mutual cycle are both in the fixture. If the closure ever // regresses to recursion this test does not fail, it HANGS, which is its own loud signal. { bundle: 'selfie', stage: 'production', composes: ['selfie/one', 'selfie/two', 'ring-a'] }, { bundle: 'ring-a', stage: 'experimental', composes: ['ring-b'] }, { bundle: 'ring-b', stage: 'experimental', composes: ['ring-a'] }, ], } 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') // ★ EVERY FINDING CARRIES A LINE, AND THE LINE MUST HOLD THE STRING. Asserting only that a line // is PRESENT would pass on a confidently wrong number, which is the failure this exists to stop: // sr sent itself to two comment lines that merely discussed a string while the rendering code sat // elsewhere. So the fixture's own source is the oracle — read the reported line back and require // the text (or, for a split literal, its first fragment) to be on it. An off-by-one from a block // offset, or a fragment's local numbering leaking out of a bound expression, fails here. const badLines = PLANTED_BAD.split('\n') ok(bad.every((f) => typeof f.line === 'number' && f.line > 0), 'every finding carries a LINE') const misplaced = bad.filter((f) => { const src = badLines[f.line - 1] || '' // A suspect's text is SYNTHESISED (``) and so is not on any line verbatim; // its prop VALUE is the part the source actually holds. const m = /^<\S+ \S+="(.*)">$/.exec(String(f.text)) const needle = (m ? m[1] : String(f.text).split(' {…} ')[0]).trim() return needle && !src.includes(needle) }) ok(misplaced.length === 0, `every reported line actually CONTAINS its string (${misplaced.length} misplaced${misplaced.length ? ': ' + misplaced.map((f) => `${f.kind}@${f.line} ${JSON.stringify(f.text.slice(0, 28))}`).join('; ') : ''})`) // ★ THE FALLBACK EXEMPTION, BOTH SIDES. A resolved key makes its English a safety net; an // unresolved one makes it the string the reader actually gets. Asserting only the quiet side // would pass on a gate that exempts every fallback, which is precisely the bug this replaced: // the old blanket "any argument of a translator call is exempt" rule could not see a typo'd key. const FB = ` ` const fb = scanSfc(FB, 'fallbacks.vue') ok(!fb.some((f) => /Research map loading/.test(f.text)), 'QUIET on a t(key, English) fallback whose key resolves in every locale') ok(fb.filter((f) => /only thing a reader sees/.test(f.text)).length === 2, `COUNTS a fallback whose key does not resolve — in template AND script (got ${fb.filter((f) => /only thing a reader sees/.test(f.text)).length}/2)`) ok(!fb.some((f) => f.text === 'dashboard.map_loading'), 'and the KEY argument itself is never a finding') // tStatic is used directly, not only behind a wrapper, and the same two-sided rule has to hold there. const TS = ` ` const ts = scanSfc(TS, 'tstatic.vue') ok(!ts.some((f) => /Research map loading/.test(f.text)), 'QUIET on a tStatic(key) || English fallback whose key resolves') ok(ts.some((f) => /must still count/.test(f.text)), 'COUNTS a tStatic fallback whose key does not resolve') // `config || t('key') || 'English'` — an instance may override the wording, else the catalogue, // else the English. The translator is not the immediate left operand, so the spine is walked. const CH = ` ` const ch = scanSfc(CH, 'chain.vue') ok(!ch.some((f) => /Research map loading/.test(f.text)), 'QUIET on config || t(key) || English when the key resolves') ok(ch.some((f) => /no resolving key/.test(f.text)) && ch.some((f) => /no translator at all/.test(f.text)), 'COUNTS the same chain when the key is missing, and when there is no translator in it') // ★ LOOKUP-TABLE KEYS. gospel's RES_KEY matches a value that arrives in the DATA and returns the // i18n key to render. Wrapping the left side in t() would make every lookup miss and fall through // to the raw data value: the count drops while the display breaks. Exempt only when the target // really resolves — a table pointing at a key nobody wrote is a genuine defect. const LK = ` ` const lk = scanSfc(LK, 'lookup.vue') ok(!lk.some((f) => /JESUS Film|Bible video/.test(f.text)), 'QUIET on a lookup-table key whose mapped i18n key resolves in every locale') ok(lk.some((f) => f.text === 'Gospel audio'), 'COUNTS a lookup-table key whose mapped i18n key does not exist') ok(lk.some((f) => f.text === 'Bible Stories'), "and COUNTS one whose value is a category, not a key — the gate can only vouch for what it can resolve") // ★ PURE DATA MAPS. Keys joined against map data (territory-counts.js) are join VALUES; a // sentence used as a key, or rendered as a value, is still a leak. const DM = ` ` const dm = scanSfc(DM, 'datamap.vue').map((f) => f.text) ok(!dm.includes('Andhra Pradesh') && !dm.includes('Kerala'), 'QUIET on the keys of a pure data map whose values are all numbers') ok(dm.some((t) => /A resource not found means/.test(t)), 'COUNTS a real SENTENCE used as a data-map key — a name is not a sentence') ok(dm.some((t) => /A sentence the reader actually sees/.test(t)), 'COUNTS a sentence used as a data-map VALUE — only the keys are join values') // ⚠ THE allData CHECK IS WHAT STOPS A LABEL MAP BEING READ AS A DATA MAP. Keys that look like // names, values that are prose a reader sees: the keys must STILL count, because this is a // label table, not a join table. Without this case the allData guard can be removed and every // test still passes — measured by poisoning it. // ★ REGION IDENTITIES (R26): exempt ONLY while REGION_KEY names them. A region added // without a key falls through `|| english` and renders English in eleven locales — the defect // upg100 already shipped once with Americas and Europe. The conditional IS the rule. { const KEYED = "const REGION_KEY={'Africa':'region_africa','Middle East':'region_middle_east','India':'region_india','Other':'region_other','China + Mongolia':'region_china_mongolia','Central Asia':'region_central_asia'}" const mk = (of) => `\n` ok(!scanSfc(mk("{ 'west-africa': 'Africa', 'middle-east-core': 'Middle East' }"), 'rg.vue').some((f) => f.text === 'Africa'), 'QUIET on a region identity that REGION_KEY names') ok(scanSfc(mk("{ 'west-africa': 'Africa', 'new-place': 'Antarctica Region' }"), 'rb.vue').some((f) => f.text === 'Antarctica Region'), 'COUNTS a region with NO REGION_KEY entry — it would render English through the fallback') } ok(scanSfc(` `, 'labels.vue').map((f) => f.text).includes('Andhra Pradesh'), 'COUNTS a data-map key when the VALUES are prose — that is a label table, not a join table') ok(scanSfc(` `, 'few.vue').map((f) => f.text).includes('Tamil Nadu'), 'COUNTS the same key in a THREE-entry object — two options are not a table') // ★ THE TWO CLASSES gospel ASKED FOR, both sides each. Their 12 real defects were hiding inside 88 // findings they had already called "mostly deliberate", so the danger in teaching the gate a class // is that it silences the next 12 too. Each quiet case is paired with the nearest thing that must // stay loud: a caveat after the same separator, prose that merely begins with a brand, and a real // English sentence used as a table key. const CLS = ` ` const cls = scanSfc(CLS, 'classes.vue') const txt = cls.map((f) => f.text) ok(!txt.includes('· GRN'), 'QUIET on a separator followed only by a never-translate name') ok(!txt.some((t) => /Full Bible/.test(t)), 'QUIET on a string compared with === against table data') ok(txt.includes('· Not found in any pool'), 'COUNTS a real caveat after the same separator — the name list is a list, not a punctuation rule') ok(txt.includes('GRN · and the rest of this sentence is prose'), 'COUNTS prose that merely BEGINS with a brand — the separator has to be leading') ok(txt.includes('A whole English sentence used as a key'), 'COUNTS a real sentence used as a table key when its value resolves to nothing') ok(txt.includes('Some readable prose here'), 'COUNTS an .includes() argument — a substring test can be against prose a reader sees') // ⚠ CONCATENATION IS A BinaryExpression TOO. Accepting any operator instead of just ===/!== would // silence `greeting + 'Welcome to the prayer map'` — a sentence assembled for the reader, which is // the opposite of a value tested against data. Found by poisoning the operator check and seeing // nothing fail; this is the case that makes it fail. const CAT = ` ` ok(scanSfc(CAT, 'concat.vue').some((f) => f.text === 'Welcome to the prayer map'), 'COUNTS a sentence built by CONCATENATION — only an equality test means "compared against data"') // ★ CSS DECLARATION SEAMS (I18N-EXP-PLAN), tested on the SHAPE rather than through a fixture. // A seam like "px;height:" only exists as a separate string if a quasi is judged alone, and this // gate deliberately judges a split template literal as ONE string — so a .vue fixture can never // produce one, and an assertion written that way passes whether or not the rule exists. I wrote // that version first and it stayed green with the rule disabled, which is the whole failure mode // this file exists to refuse. The rule is real for any caller that does judge quasis separately; // asserting it at the shape is the only way to prove it can fail. ok(!!allowedBy('px;height:') && !!allowedBy('; background:'), 'a CSS declaration seam is allowed') ok(!allowedBy('12 people:'), 'prose ending in a colon is NOT — the seam shape needs a semicolon-separated declaration') ok(!allowedBy('Pools checked; nothing found:'), 'prose with a semicolon AND a colon is NOT — the segments must be property:value pairs') // ★ HTML ATTRIBUTE SEAM (SR-DIRLANG). `''` // leaves the quasi `" dir="`, an attribute seam rather than a declaration seam. dir and lang were // the only standard attributes missing from the HTML shape. Asserted on the shape for the same // reason as the CSS seam: the gate judges a split literal as ONE string, so no fixture reaches it. ok(!!allowedBy('" dir="') && !!allowedBy('" lang="'), 'an HTML attribute seam on dir/lang is allowed') ok(!allowedBy('Direction: left to right'), 'prose ABOUT direction is NOT — the shape needs an attribute name followed by =') // ★ A DEVELOPER SITE REACHED THROUGH CONCATENATION. `throw new Error(\`head\` + rest)` puts a // BinaryExpression between the literal and the NewExpression, so the parent-only check missed it // and reported a thrown message as a leak (legend-tree.js:392). Not a .js-path bug — a .vue file // concatenating inside a throw hits it identically. const DEV = ` ` const dev = scanSfc(DEV, 'dev.vue').map((f) => f.text) ok(!dev.some((t) => /leaf nodes violate/.test(t)), 'QUIET on a thrown developer message built by CONCATENATION') ok(dev.some((t) => /A sentence a reader actually sees/.test(t)), 'COUNTS a concatenated sentence that is NOT at a developer site') // the catalogue-side rule, tested directly: the two facts that make a fallback safe ok(fallbackFullyResolved('dashboard.map_loading'), 'a key present and really translated in all 11 locales counts as resolved') ok(!fallbackFullyResolved('dashboard.no_such_key_at_all'), 'a key absent from the catalogue does NOT count as resolved') ok(translatableWords('{label} · {count} · {pct}%') === 0, 'a format-only string has no translatable words, so an identical rendering is not "untranslated"') ok(translatableWords('This fallback is the only thing a reader sees') >= 3, 'an English sentence does have translatable words') 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) => /Loading the map for you/.test(f.text)), 'QUIET on keyedError(key, English) whose key resolves in every locale') ok(bad.some((f) => /this sentence has no catalogue key/.test(f.text)), 'SEES keyedError(key, English) whose key does NOT resolve — its English is what the reader gets') 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\'))') const eff = productionForGating(STAGE_FIXTURE) ok(eff.includes('mid-exp'), 'an experimental bundle COMPOSED by a production leaf is production-for-gating') ok(eff.includes('deep-exp'), 'and TRANSITIVELY — composition nests, so one pass is not enough') ok(eff.includes('pair'), "a composes entry of the form 'bundle/profile' resolves to its BUNDLE") ok(!eff.includes('lonely-exp'), 'CONTROL: an experimental bundle nobody composes stays out of scope') ok(stageReason(STAGE_FIXTURE, 'mid-exp').startsWith('composed by'), 'and the report can say WHY it is in scope') ok(eff.includes('selfie'), 'a SELF-COMPOSING bundle resolves rather than looping (6 such edges exist in the real manifest)') ok(eff.includes('ring-a') && eff.includes('ring-b'), 'and a MUTUAL cycle terminates, marking both members') 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