diff --git a/internal/check-hardcoded-english.mjs b/internal/check-hardcoded-english.mjs
index 35b5f5528..daf887e6e 100644
--- a/internal/check-hardcoded-english.mjs
+++ b/internal/check-hardcoded-english.mjs
@@ -335,6 +335,48 @@ function isDataComparison(node, parent) {
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.
+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
@@ -342,8 +384,17 @@ function isResolvedLookupKey(node, parent, grand) {
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
- if (!/^[a-z][\w-]*(\.[\w-]+)+$/i.test(v.value)) return false
- return fallbackFullyResolved(v.value)
+ // 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
@@ -549,6 +600,8 @@ function scanScript(code, file, out, lineBase = 0) {
// 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
@@ -716,7 +769,7 @@ const sfcsIn = (dir) => {
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)
+ else if (e.name.endsWith('.vue') || e.name.endsWith('.js')) out.push(p)
} }
walk(dir); return out
}
@@ -905,6 +958,35 @@ const KIND = { 'Bible Stories': 'scripture' }
'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 = `{{ x }}
+`
+ 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.
+ ok(scanSfc(`{{ x }}
+`, '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(`{{ x }}
+`, '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
@@ -1170,7 +1252,12 @@ 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)) }
+ for (const d of dirs) for (const f of sfcsIn(d)) {
+ files++
+ const src = readFileSync(f, 'utf8')
+ if (f.endsWith('.js')) { const out = []; scanScript(src, f, out); findings.push(...out) }
+ else findings.push(...scanSfc(src, f))
+ }
} catch (e) { console.error('HARDCODED-ENGLISH: parse error —', e.message); process.exit(2) }
const deliberate = findings.filter((f) => f.deliberate && !f.suspect)