Teach the gate the two classes gospel classified by hand
gospel found 12 real defects — including a search box's aria-label, which is
screen-reader text — inside 88 findings they had already described as mostly
deliberate. None were visible from the count. They classified every remaining
finding by hand and named the two shapes the gate miscounts.
A string compared with === against table 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 renders through a key.
Translating it makes the comparison miss and the feature stop, while the
count falls and it looks like progress — the same inversion as a lookup-table
key, which is why gospel classed them together.
A separator followed by a brand is not a sentence. `{{ t('…') }} · GRN`
splits into a keyed half and the text node "· GRN", and only the second
reaches this scan.
The names are LISTED, not patterned. "Punctuation then a capitalised word"
would swallow "· Not found in any pool", a real caveat, and that is the class
gospel's 12 defects were hiding in. A name earns its place by being a
publisher or standard that is the same word in every language.
Each quiet case is paired in the self-test with the nearest thing that must
stay loud: a caveat after the same separator, prose that merely begins with a
brand, a real English sentence used as a table key, and an .includes()
argument. Poisoning the name list into a punctuation rule fails.
Poisoning the operator check did NOT fail, which is why there is now a
concatenation case: `greeting + 'Welcome to the prayer map'` is a
BinaryExpression too, so accepting any operator would silence a sentence
assembled for the reader. Only an equality test means "compared against
data".
Also the last finding in this owner's plugins. All seven main tabs resolve
through tabs.<id> in all eleven locales, so their English labels could never
render — a second copy of each name that could only drift. The sub-tab tables
lost theirs the same way.
Fleet 152 -> 143. This owner's plugins: 1 -> 0.
This commit is contained in:
parent
168b1f1bd9
commit
592880ac75
2 changed files with 78 additions and 7 deletions
|
|
@ -97,6 +97,21 @@ const ALLOW = [
|
||||||
test: (s) => /^(Bebas Neue|Poppins|Oswald|Figtree|Segoe UI|Helvetica Neue|Times New Roman)$/.test(s) },
|
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',
|
{ why: 'engine/provider tokens and well-known proper nouns that are not prose',
|
||||||
test: (s) => /^(mapbox|maplibre|mercator|globe|Mapbox|MapLibre)$/.test(s) },
|
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 '
|
{ 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 '
|
+ '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 '
|
+ 'another language matches nothing, so motion safety turns off for exactly the readers who '
|
||||||
|
|
@ -267,6 +282,19 @@ function fallbackFullyResolved(key, root = '.') {
|
||||||
// Earned, not assumed, exactly as the fallback exemption is: the property's VALUE must be an i18n
|
// 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
|
// 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.
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
function isResolvedLookupKey(node, parent, grand) {
|
function isResolvedLookupKey(node, parent, grand) {
|
||||||
// the literal must BE the property's key, not its value
|
// the literal must BE the property's key, not its value
|
||||||
if (!parent || parent.type !== 'ObjectProperty' || parent.key !== node) return false
|
if (!parent || parent.type !== 'ObjectProperty' || parent.key !== node) return false
|
||||||
|
|
@ -481,6 +509,8 @@ function scanScript(code, file, out, lineBase = 0) {
|
||||||
// against, never a string the reader sees.
|
// against, never a string the reader sees.
|
||||||
// stack's last entry IS the parent, so the enclosing object is one further back.
|
// stack's last entry IS the parent, so the enclosing object is one further back.
|
||||||
if (isResolvedLookupKey(node, parent, stack[stack.length - 2])) return
|
if (isResolvedLookupKey(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) {
|
if (isStr) {
|
||||||
const text = node.value.trim()
|
const text = node.value.trim()
|
||||||
|
|
@ -833,6 +863,44 @@ const KIND = { 'Bible Stories': 'scripture' }
|
||||||
'COUNTS a lookup-table key whose mapped i18n key does not exist')
|
'COUNTS a lookup-table key whose mapped i18n key does not exist')
|
||||||
ok(lk.some((f) => f.text === 'Bible Stories'),
|
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")
|
"and COUNTS one whose value is a category, not a key — the gate can only vouch for what it can resolve")
|
||||||
|
// ★ 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 = `<template>
|
||||||
|
<p>{{ t('gospel.res.gospelAudio') }} · GRN</p>
|
||||||
|
<p>{{ t('gospel.res.x') }} · Not found in any pool</p>
|
||||||
|
<p>GRN · and the rest of this sentence is prose</p>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
const a = scope === 'Full Bible'
|
||||||
|
const LOOKUP = { 'Bible video': 'gospel.res.bibleVideo' }
|
||||||
|
const REAL = { 'A whole English sentence used as a key': 'not.a.real.key.anywhere' }
|
||||||
|
const c = label.includes('Some readable prose here')
|
||||||
|
</script>`
|
||||||
|
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 = `<template><p>{{ x }}</p></template>
|
||||||
|
<script setup>
|
||||||
|
const a = greeting + 'Welcome to the prayer map'
|
||||||
|
</script>`
|
||||||
|
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"')
|
||||||
// the catalogue-side rule, tested directly: the two facts that make a fallback safe
|
// the catalogue-side rule, tested directly: the two facts that make a fallback safe
|
||||||
ok(fallbackFullyResolved('dashboard.map_loading'),
|
ok(fallbackFullyResolved('dashboard.map_loading'),
|
||||||
'a key present and really translated in all 11 locales counts as resolved')
|
'a key present and really translated in all 11 locales counts as resolved')
|
||||||
|
|
|
||||||
|
|
@ -585,14 +585,17 @@ const FEATURES = {
|
||||||
// (qa: 2026-05-04 user feedback — Adoption swapped before Engagement so
|
// (qa: 2026-05-04 user feedback — Adoption swapped before Engagement so
|
||||||
// the order reads as the user-flow ladder Prayer → Adopt → Engage rather
|
// the order reads as the user-flow ladder Prayer → Adopt → Engage rather
|
||||||
// than the previous alphabetical Engagement-before-Adoption.)
|
// than the previous alphabetical Engagement-before-Adoption.)
|
||||||
|
// No `label:` here: tabLabel() reads tabs.<i18nKey|id> behind a te() guard, and all seven keys
|
||||||
|
// resolve in all eleven locales, so an English label could never render — it was a second copy of
|
||||||
|
// each name that could only drift from the catalogue. The sub-tab tables lost theirs the same way.
|
||||||
const ALL_TABS = [
|
const ALL_TABS = [
|
||||||
{ id: 'prayer', label: 'Prayer', colorStrategy: 'prayerProgress', legend: 'prayer', popup: 'pray', feature: null },
|
{ id: 'prayer', colorStrategy: 'prayerProgress', legend: 'prayer', popup: 'pray', feature: null },
|
||||||
{ id: 'adoption', label: 'Adoption', colorStrategy: 'adoption', legend: 'adoption', popup: 'adopt', feature: 'adoption' },
|
{ id: 'adoption', colorStrategy: 'adoption', legend: 'adoption', popup: 'adopt', feature: 'adoption' },
|
||||||
{ id: 'engagement', label: 'Engagement', colorStrategy: 'engagement', legend: 'engagement', popup: 'pray', feature: null, sepAfter: true },
|
{ id: 'engagement', colorStrategy: 'engagement', legend: 'engagement', popup: 'pray', feature: null, sepAfter: true },
|
||||||
{ id: 'affinity-blocks', label: 'People Groups (Affinity)', colorStrategy: 'affinityBlock', legend: 'affinity-block', popup: 'pray', feature: null },
|
{ id: 'affinity-blocks', colorStrategy: 'affinityBlock', legend: 'affinity-block', popup: 'pray', feature: null },
|
||||||
{ id: 'doxa-regions', label: 'Regions', colorStrategy: 'doxaRegion', legend: 'doxa-regions', popup: 'pray', feature: 'doxaRegions', i18nKey: 'regions' },
|
{ id: 'doxa-regions', colorStrategy: 'doxaRegion', legend: 'doxa-regions', popup: 'pray', feature: 'doxaRegions', i18nKey: 'regions' },
|
||||||
{ id: 'language-families', label: 'Languages', colorStrategy: 'languageFamily', legend: 'language-family', popup: 'pray', feature: null },
|
{ id: 'language-families', colorStrategy: 'languageFamily', legend: 'language-family', popup: 'pray', feature: null },
|
||||||
{ id: 'religion', label: 'Religions', colorStrategy: 'religion', legend: 'religion', popup: 'pray', feature: null }
|
{ id: 'religion', colorStrategy: 'religion', legend: 'religion', popup: 'pray', feature: null }
|
||||||
]
|
]
|
||||||
const TABS = ALL_TABS.filter(t => !t.feature || FEATURES[t.feature])
|
const TABS = ALL_TABS.filter(t => !t.feature || FEATURES[t.feature])
|
||||||
const tabs = computed(() => TABS)
|
const tabs = computed(() => TABS)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue