Religion families follow the reader; the value they filter on does not

REGREL. Two library sites rendered RELIGION_FAMILIES values as English in every
locale: the Religion legend's rows (useLegendData.js) and the search aggregate that
collapses sub-types into their family (useSearch.js). Both now project the LABEL
through religionFamilyLabel(), which resolves through the catalogue and keeps
English as the last resort.

The half worth a gate is the half that did NOT change. A translated value matches
nothing — the moment a localized string lands in a filter, a bus payload or an
element attribute, the link works in English and fails silently in Arabic. So `key`
and `filterKey` stay the canonical family letter, useSearch's key function stays
bare getReligionFamily(), and the check asserts all of that as well as the
projection. RELIGION_FAMILIES is no longer imported in either file: the label was
its only local use, and religionFamilyLabel reads the canonical map internally.

★ A COMMENT IN useSearch.js ARGUED AGAINST THIS FIX, and it was wrong. It read
"label = family name so selection can resolve the family letter back (see
research-map onGeocoderAggregateResult religion branch)" — presenting an English
label as a REQUIREMENT of selection. Measured: onGeocoderAggregateResult does not
exist anywhere in this repo (three comments name it, nothing defines it), and
selection resolves by value — aggregate() emits `key` and `label` as separate
fields, and the consumer reads detail.expectedValue (countries-map.vue:1611).
Retracted in place with the measurement, not deleted, because a reader who has seen
the old claim needs to know it was tested.

check:religion-label-not-value is registered and negative-tested. It executes
religionFamilyLabel's REAL source against the SHIPPED ar/ and en/ religion.json
rather than a stub, because religion.js imports tStatic through an i18n index that
calls import.meta.glob and cannot load in plain Node. Under ar the label is
المسيحية while RELIGION_FAMILIES.C is still "Christianity", and a control asserts
the two really differ — an English-only projection fails that assertion.

One assertion of mine failed on its own retraction first: it required the stale
phrase to be ABSENT, but the corrected comment quotes it in order to record it.
Requiring the absence of a phrase punishes documenting the error, so it now asserts
the retraction is present instead.
This commit is contained in:
Doxa Maps 2026-09-27 01:15:47 +00:00
parent 3c0ddc7e5e
commit 379e475602
4 changed files with 128 additions and 7 deletions

View file

@ -22,7 +22,7 @@ import { ENGAGEMENT_COLORS } from '../../colors/engagement.js'
import { ADOPTION_COLORS } from '../../colors/adoption.js'
import { getLanguageFamilyColor } from '../../colors/language-family.js'
import { getColorStrategy, COLOR_MODES } from '../../colors/_registry.js'
import { PALETTE as RELIGION_COLORS, RELIGION_FAMILIES, getReligionFamily } from '../../colors/religion.js'
import { PALETTE as RELIGION_COLORS, getReligionFamily, religionFamilyLabel } from '../../colors/religion.js'
import { useLanguageFamilyLegendData } from './useLanguageFamilyLegendData.js'
const COLUMNS_SIMPLE = [] // e.g. affinity-blocs — no numeric columns
@ -319,7 +319,11 @@ export function useLegendData(legendTypeRef, opts = {}) {
})
return order.map(fam => ({
key: fam,
label: RELIGION_FAMILIES[fam] || 'Unknown',
// REGREL: the LABEL is localized, the VALUE is not. `key` and `filterKey` below stay the
// canonical family letter, which is what the filter and the map compare on; only this
// display string follows the reader's language. RELIGION_FAMILIES is no longer imported here:
// religionFamilyLabel() reads it internally, so this file's only use of it was this label.
label: religionFamilyLabel(fam) || 'Unknown',
color: RELIGION_COLORS[fam] || '#94a3b8',
count: familyAggregates[fam].count,
population: familyAggregates[fam].population,

View file

@ -32,7 +32,10 @@ import langFamilyByLanguage from '../data/langFamilyByLanguage.json'
// Religion search must group by FAMILY (same 10 families the Religion legend shows),
// not by raw sub-type names — otherwise search surfaces 'Animism'/'Romanism' rows
// the legend can't select. Reuse the legend's exact family logic.
import { RELIGION_FAMILIES, getReligionFamily } from '../colors/religion.js'
// RELIGION_FAMILIES is no longer imported: its only use here was the label that now goes through
// religionFamilyLabel(), which reads the canonical map internally. The KEY still comes from
// getReligionFamily, so nothing that is compared or sent changed.
import { getReligionFamily, religionFamilyLabel } from '../colors/religion.js'
// ── Result category ordering by active-tab context (Round 16) ────────────────
// The search bar is unified — every category is always searched — but the
@ -319,11 +322,19 @@ function buildAggregates(entries) {
// SAME extraction the legend uses (getReligionFamily on the religion code),
// so every search result corresponds to a selectable Religion-legend row.
// The raw sub-type names ('Animism') still MATCH via religionHay, but collapse
// into their family row. label = family name so selection can resolve the
// family letter back (see research-map onGeocoderAggregateResult religion branch).
// into their family row.
// ✗ THIS COMMENT USED TO SAY: "label = family name so selection can resolve the family letter back
// (see research-map onGeocoderAggregateResult religion branch)". It was WRONG, and wrong in the
// direction that forbids the fix below: it presented an English label as a REQUIREMENT of
// selection. Measured 2026-09-27 — onGeocoderAggregateResult does not exist anywhere in this repo
// (three comments name it, nothing defines it), and selection resolves by VALUE: aggregate() emits
// `key` and `label` as SEPARATE fields (:262-263) with key from getReligionFamily, and the consumer
// reads detail.expectedValue, never the label (country-posters/countries-map.vue:1611). So the label
// is display-only and safe to localize. A stale comment that argues against a correct change is
// worse than no comment: the next reader either reverts it or refuses the next one like it.
religions: aggregate(
(e) => getReligionFamily(e.feature.religion || e.feature._raw?.religion?.value || e.feature._raw?.religion || ''),
(e) => RELIGION_FAMILIES[getReligionFamily(e.feature.religion || e.feature._raw?.religion?.value || e.feature._raw?.religion || '')] || 'Unknown'
(e) => religionFamilyLabel(getReligionFamily(e.feature.religion || e.feature._raw?.religion?.value || e.feature._raw?.religion || '')) || 'Unknown'
),
// WAGF / DOXA regions (doxaRegion) — keyed by the region value so a search
// for "Asia" collapses every pin in that region into one row. The click

View file

@ -190,6 +190,7 @@
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
"check:settled": "node internal/settled-tree-gate.mjs",
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
"check:religion-label-not-value": "node plugin-designs/regions-dashboard/scripts/check-religion-label-not-value.mjs",
"check:share-policy": "node internal/check-share-policy.mjs",
"check:shared-reached": "node internal/check-shared-component-reached-bundles.mjs",
"check:shell-scroll": "node internal/verify-shell-scroll.mjs",
@ -226,7 +227,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",

View file

@ -0,0 +1,105 @@
#!/usr/bin/env node
/* check-religion-label-not-value.mjs — `bun run check:religion-label-not-value`. Source-only, headless.
*
* REGREL: two LIBRARY sites showed RELIGION_FAMILIES values as English in every locale. The fix is to
* project the LABEL through religionFamilyLabel() and leave the VALUE canonical English — because a
* TRANSLATED VALUE MATCHES NOTHING. That is the language-link bug class: the moment a localized string
* lands in a filter, a bus payload or an element attribute, the link works in English and silently
* fails in Arabic.
*
* So this gate asserts BOTH halves. Projecting the label is the easy half; the half worth a gate is
* that the value did NOT move with it.
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { codeOnly } from './strip-code.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(HERE, '..', '..', '..')
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
const read = (p) => readFileSync(resolve(ROOT, p), 'utf8')
// ── 1. BEHAVIOUR, against the SHIPPED catalogues ────────────────────────────────────────────────
// religion.js imports tStatic from library/i18n/index.js, which calls import.meta.glob — a Vite
// transform plain Node cannot run. So religionFamilyLabel's REAL SOURCE is extracted and executed with
// tStatic stubbed over the actual ar/ and en/ religion.json, rather than re-typed here: a copy would
// test the copy. Same approach as check-provider-for.mjs and check-optional-t-equivalence.mjs.
const relSrc = read('library/colors/religion.js')
const famBlock = /export const RELIGION_FAMILIES = \{[\s\S]*?\n\}/.exec(relSrc)
const labelFn = /export function religionFamilyLabel\(familyOrName\) \{[\s\S]*?\n\}/.exec(relSrc)
if (!famBlock || !labelFn) {
console.error(' FAIL - could not extract RELIGION_FAMILIES / religionFamilyLabel: NOT MEASURED, not passing')
process.exit(2)
}
const en = JSON.parse(read('library/i18n/locales/en/religion.json'))
const ar = JSON.parse(read('library/i18n/locales/ar/religion.json'))
const messages = { en: { religion: en }, ar: { religion: ar } }
const tStaticFor = (want) => (path) => {
const walk = (loc) => String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), messages[loc])
const v = walk(want)
return typeof v === 'string' ? v : walk('en')
}
const build = (want) => new Function('tStatic',
famBlock[0].replace(/^export /, '') + ';\n' + labelFn[0].replace(/^export /, '')
+ ';\nreturn { RELIGION_FAMILIES, religionFamilyLabel };')(tStaticFor(want))
const AR = build('ar'), EN = build('en')
ok(AR.religionFamilyLabel('C') === ar.family.Christianity,
`under ar the LABEL is Arabic ("${ar.family.Christianity}")`)
ok(AR.RELIGION_FAMILIES.C === 'Christianity',
'while the VALUE stays canonical English ("Christianity") -- a translated value would match nothing')
ok(AR.religionFamilyLabel('C') !== AR.RELIGION_FAMILIES.C,
'CONTROL: label and value really DIFFER under ar, so the projection is doing something')
ok(EN.religionFamilyLabel('C') === 'Christianity',
'under en the label equals the canonical name, so nothing changes for an English reader')
ok(AR.religionFamilyLabel('M') === ar.family.Islam && AR.religionFamilyLabel('H') === ar.family.Hinduism,
'and it holds for more than one family (Islam, Hinduism)')
ok(AR.religionFamilyLabel('') === '', 'an empty family gives an empty label, not the word "undefined"')
ok(AR.religionFamilyLabel('Christianity') === ar.family.Christianity,
'it accepts a NAME as well as a letter, as its signature promises (familyOrName)')
ok(AR.religionFamilyLabel('ZZZ') === 'ZZZ',
'an unknown family falls through to the input rather than blanking the row')
// ── 2. THE HALF WORTH A GATE: the VALUE did not move with the label ─────────────────────────────
const legend = codeOnly(read('library/composables/legends/useLegendData.js'))
ok(/label:\s*religionFamilyLabel\(fam\)/.test(legend), 'useLegendData projects the LABEL through religionFamilyLabel(fam)')
ok(/key:\s*fam\b/.test(legend), 'and `key` is still the canonical family letter')
ok(/filterKey:\s*fam\b/.test(legend), 'and `filterKey` is still the canonical family letter -- this is what the filter compares')
ok(!/key:\s*religionFamilyLabel|filterKey:\s*religionFamilyLabel/.test(legend),
'NEG: neither key nor filterKey is derived from the localized label')
const search = codeOnly(read('library/composables/useSearch.js'))
ok(/religions:\s*aggregate\(/.test(search), 'useSearch still aggregates religions')
ok(/\(e\) => religionFamilyLabel\(getReligionFamily\(/.test(search),
'its LABEL function goes through religionFamilyLabel()')
ok(/\(e\) => getReligionFamily\(/.test(search),
'and its KEY function is still bare getReligionFamily() -- the value is untouched')
ok(/key,\n\s*label:/.test(search) || /\bkey,\s*$/m.test(search),
'aggregate() still emits key and label as SEPARATE fields, so a consumer can choose')
// ── 3. the consumer really does resolve by VALUE, which is why (2) is sufficient ────────────────
const consumer = codeOnly(read('plugin-designs/country-posters/countries-map.vue'))
ok(/kind === 'religion'/.test(consumer) && /expectedValue = detail\.expectedValue/.test(consumer),
'the religion consumer resolves by detail.expectedValue, never by the label (countries-map)')
// ── 4. the stale comment that argued AGAINST this fix is gone ───────────────────────────────────
// ★ THIS ASSERTION FAILED ON MY OWN RETRACTION, which is worth recording. The first version required
// that "onGeocoderAggregateResult religion branch" be ABSENT from the file — but the corrected comment
// QUOTES that phrase in order to record what it used to claim, so the string is still there, inside the
// retraction that fixes it. Requiring the absence of a phrase punishes documenting the error. What I
// actually care about is that the claim is no longer presented as CURRENT, so assert the retraction.
const searchRaw = read('library/composables/useSearch.js')
ok(/USED TO SAY/.test(searchRaw) && /does not exist anywhere in this repo/.test(searchRaw),
'the stale claim is RETRACTED in place, with the measurement that falsified it (the cited handler does not exist)')
ok(!/\/\/ into their family row\. label = family name so selection/.test(searchRaw),
'and it no longer reads as a live requirement -- the original standalone sentence is gone')
// ── 5. NEG CONTROL: the behavioural half must be capable of failing ─────────────────────────────
const brokenLabel = (f) => ({ C: 'Christianity' })[f] || f // an English-only "projection"
ok(brokenLabel('C') !== ar.family.Christianity,
'NEG CONTROL: an English-only projection FAILS the ar assertion, so passing it means something')
console.log(fails ? `\nRELIGION-LABEL: ${fails} FAIL(s)` : '\nRELIGION-LABEL: the label follows the reader, the value stays canonical, and the filter still compares the value')
process.exit(fails ? 1 : 0)