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 = ` +` + 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(` +`, '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 @@ -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)