rega, on MapLibre, in BOTH locales: Languages > Language > "Arabic" sent
map-filtered with {language:"Arabic", languageCode:""}, the composite handed the
grid an empty code, and 2,107 rows became 0. An empty grid reads as "no
results" — the worst shape a wiring bug can take, because the reader believes
the data.
ONE LINE, AND IT WAS MINE. The emitter looked for a row whose language LABEL
equalled the selected name. A language tier's label is not a label any row
carries: the legend builds family > language > dialect and derives the language
tier by splitting the API label at the comma. Measured on the live source —
2,107 rows, every one carrying primary_language as {value,label} — there is NO
row whose label is "Arabic". There is "Arabic, Sudanese", "Arabic, Levantine",
"Arabic, Gulf", twenty-two codes in all. The exact match could never hit, in any
locale, which is why it failed identically in en and ar and why the locale fix
that prompted this code was right about the disease and wrong about this line.
This is the derived-value trap one tier down from where I last wrote it up: a
value that crosses a component boundary has to exist on a row. "Arabic" is a
grouping the legend invented; primary_language.label is a field.
So the rows are now matched the way the legend's own node matches pins — base,
"base," prefix, " base" suffix — rather than by a fourth hand-typed spelling of
a rule that already exists in two places. 61 tiers resolved an empty code
before; "Arabic" was one of them.
AND ONE CODE ONLY WHEN THERE IS ONE. "Arabic" means 22 ISO codes, so no single
value expresses it. Emitting one would filter the grid to a fraction of the
selection while looking correct; emitting the tier NAME is what showed zero. The
honest answer is to emit nothing for that axis and leave the grid unfiltered —
the map is narrowed, the grid is not, and the reader can see both. languageCodes
carries the whole set alongside, so this becomes a real narrowing the day
filterSet goes multi-value (SPEC-DASHBOARD-KIT D1) rather than a rewrite.
Measured on the fixture: 764 tiers now resolve one code and filter the grid; 41
cover several and abstain.
A DIALECT IS A LANGUAGE SELECTION TOO, and it emitted nothing at all — the
watcher never observed selectedDialect, so drilling from "Arabic" to "Arabic,
Sudanese" changed the map and left the grid reporting the tier above. Its
originalLabels are exact API labels, so it is the one unambiguously resolvable
case.
check:language-code-link, headless, no network at run time. It LIFTS the
emitter's match rule and the composite's gridLanguage() out of the shipping
source rather than carrying its own copy — a gate with its own copy of the rule
passes while the product is broken, which is exactly how this survived a green
check:linked-vocabulary. It proves the emitter's predicate and the legend's
filter expression agree on every (label, tier) pair, that every tier selects at
least one row, and that an ambiguous tier narrows nothing. The fixture is the
864 distinct label/value pairs from the live source, deduplicated rather than
sampled so the counts above are about the whole set. Four mutants; reverting the
emitter to the exact-label rule reproduces rega's finding exactly — 61 tiers
selecting zero rows, "Arabic" named first.
STUDIO: needs a rebuild of doxa-research-map and research-map-plus-grid.
205 lines
13 KiB
JavaScript
205 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-language-code-link.mjs — the map's language selection must reach the grid, or narrow nothing.
|
|
*
|
|
* THE BUG (rega, 2026-09-26, main 49992a5d, MapLibre, BOTH locales). Languages > Language >
|
|
* "Arabic": the map emitted `map-filtered` with {language:"Arabic", languageCode:""}, the composite
|
|
* handed the grid an empty code, and the grid went 2,107 -> 0. An empty grid reads as "no results",
|
|
* which is the worst shape a wiring bug can take — the reader believes the data.
|
|
*
|
|
* THE CAUSE, and it is one line. The emitter looked for a row whose language LABEL === the selected
|
|
* name. A LANGUAGE NODE'S LABEL IS NOT A LABEL ANY ROW CARRIES: useLanguageFamilyLegendData builds
|
|
* family > language > dialect and derives the language tier by splitting the API label at the comma.
|
|
* MEASURED on the live source: 2,107 rows, all carrying primary_language as {value,label}, and NO
|
|
* row whose label is "Arabic" — what exists is "Arabic, Sudanese", "Arabic, Levantine", "Arabic,
|
|
* Gulf", 22 codes in all. The exact match could never hit, in any locale, which is why it failed
|
|
* identically in en and ar.
|
|
*
|
|
* ★ THE NUMBERS THIS GATE PINS, measured on the committed fixture (864 distinct label/value pairs,
|
|
* 805 base language tiers):
|
|
* OLD exact-label rule: 61 tiers resolved "" -> the grid went to zero. "Arabic" was one.
|
|
* NEW rule: 764 tiers resolve ONE code (the grid filters), 41 cover several and
|
|
* abstain honestly.
|
|
* The 41 are real ambiguity, not a bug: "Arabic" alone means 22 ISO codes, so no single value can
|
|
* express it. Sending one of them would show a fraction of the selection while looking correct;
|
|
* sending the tier NAME shows zero. Abstaining is the only option that does not lie, until
|
|
* filterSet goes multi-value (SPEC-DASHBOARD-KIT D1).
|
|
*
|
|
* ★ ONE APPROXIMATION, STATED RATHER THAN HIDDEN. The gate derives the tier set by splitting each
|
|
* label at the first comma. The legend derives `langKey` by a longer route (family lookup, suffix
|
|
* patterns), so the two sets are close but not provably identical — a tier the legend shows and
|
|
* this split does not would go unchecked. What IS exact is the part that broke: check B proves the
|
|
* emitter's predicate and the legend's filter agree on every (label, tier) pair, so the two ends
|
|
* cannot drift apart again whatever the tier set turns out to be.
|
|
*
|
|
* WHAT IT CHECKS — all of it against the SHIPPING SOURCE, lifted, never re-typed:
|
|
* A. the emitter's match rule exists and is the legend's three-arm rule (base / "base," prefix /
|
|
* " base" suffix), evaluated against the fixture.
|
|
* B. the legend's own filter expression still has those same three arms — because the ONE thing
|
|
* that caused this bug was two spellings of one rule drifting apart. A fourth spelling is the
|
|
* next bug, so the gate compares the two that exist.
|
|
* C. for every base tier: exactly one code -> a NON-EMPTY languageCode; several -> an EMPTY
|
|
* languageCode AND a populated languageCodes.
|
|
* D. the composite fails OPEN: gridLanguage() must return '' when the selection covers several
|
|
* codes. Returning the label there is what produced the empty grid.
|
|
* E. no tier may produce a value that selects zero rows. That is the user-visible invariant and
|
|
* it is asserted directly, not inferred.
|
|
*
|
|
* NO NETWORK, NO BROWSER (Playwright is rega's alone). The fixture is committed and was taken from
|
|
* the live source; refresh it with --refresh-fixture when the source's language set changes.
|
|
*
|
|
* NEGATIVE-TESTED 2026-09-26, each FAILS:
|
|
* the emitter's rule reverted to `lab === base` -> A, then C/E naming "Arabic"
|
|
* the comma-prefix arm dropped from the emitter -> A (disagrees with the legend)
|
|
* the legend's filter arms changed without the emitter -> B
|
|
* gridLanguage() reverted to `value || label` -> D
|
|
*/
|
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
import { resolve, dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const TAG = 'LANGUAGE-CODE-LINK'
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
const ROOT = resolve(HERE, '..', '..', '..')
|
|
const EMITTER = join(ROOT, 'plugin-designs/doxa-research-map/research-map.vue')
|
|
const COMPOSITE = join(ROOT, 'plugin-designs/research-map-plus-grid/research-map-plus-grid.vue')
|
|
const LEGEND = join(ROOT, 'library/composables/legends/useLanguageFamilyLegendData.js')
|
|
const FIXTURE = join(HERE, 'fixtures', 'language-codes.json')
|
|
|
|
const fails = []
|
|
const notes = []
|
|
|
|
// ── --refresh-fixture: the only thing here that touches the network ──────────────────────────
|
|
if (process.argv.includes('--refresh-fixture')) {
|
|
const sources = JSON.parse(readFileSync(join(ROOT, 'library/api/sources.json'), 'utf8'))
|
|
const cfg = sources.sources[sources.activeSource]
|
|
const url = `https://pray.doxa.life${cfg.endpoints.bulk}?fields=${cfg.defaultFields.join(',')}&lang=en`
|
|
const j = await (await fetch(url, { signal: AbortSignal.timeout(60000) })).json()
|
|
const rows = j.posts || j.data || []
|
|
// Deduplicate by the (label,value) PAIR — never sample. Both questions this fixture answers
|
|
// (does the exact label exist; how many codes does this tier cover) must be asked of the
|
|
// complete language set, or the counts above are about a sample and mean nothing.
|
|
const seen = new Set(); const keep = []
|
|
for (const r of rows) {
|
|
const l = r.primary_language
|
|
if (!l || !l.label || !l.value) continue
|
|
const k = l.label + '\u0000' + l.value
|
|
if (seen.has(k)) continue
|
|
seen.add(k)
|
|
keep.push({ primary_language: { value: l.value, label: l.label } })
|
|
}
|
|
writeFileSync(FIXTURE, JSON.stringify(keep) + '\n')
|
|
console.log(`${TAG}: fixture refreshed — ${rows.length} api rows -> ${keep.length} distinct (label,value) pairs.`)
|
|
process.exit(0)
|
|
}
|
|
|
|
if (!existsSync(FIXTURE)) { console.log(`${TAG}: SKIP — no fixture (run with --refresh-fixture).`); process.exit(0) }
|
|
const rows = JSON.parse(readFileSync(FIXTURE, 'utf8'))
|
|
const emitterSrc = readFileSync(EMITTER, 'utf8')
|
|
const compositeSrc = readFileSync(COMPOSITE, 'utf8')
|
|
const legendSrc = readFileSync(LEGEND, 'utf8')
|
|
|
|
// ── A. lift the emitter's match rule and run it ──────────────────────────────────────────────
|
|
// Lifted, not re-typed: a gate that carries its own copy of the rule passes while the product is
|
|
// broken, which is precisely how the bug above survived a green check:linked-vocabulary.
|
|
const mRule = /const matchesLanguage = \(lab, base\) =>\s*([\s\S]*?)\n\s*const selectedRows/.exec(emitterSrc)
|
|
let matchesLanguage = null
|
|
if (!mRule) {
|
|
fails.push('A research-map.vue: no `const matchesLanguage = (lab, base) =>` — the emitter\'s match rule moved or was renamed, so this gate is blind to the line that broke.')
|
|
} else {
|
|
try { matchesLanguage = new Function('lab', 'base', `return (${mRule[1].trim()})`) } catch (e) {
|
|
fails.push(`A research-map.vue: the lifted match rule does not evaluate (${e.message}).`)
|
|
}
|
|
}
|
|
|
|
// ── B. the legend's own filter must still be the same three arms ─────────────────────────────
|
|
// This is the drift guard. The bug was two spellings of one rule disagreeing; the fix mirrors the
|
|
// legend's expression in JS, so the gate's job is to notice when the original moves.
|
|
{
|
|
const node = /id:\s*`lang:\$\{fam\.key\}__\$\{langKey\}`[\s\S]*?filter: \[([\s\S]*?)\],\n\s*children: \[\],/.exec(legendSrc)
|
|
if (!node) {
|
|
fails.push('B useLanguageFamilyLegendData.js: could not find the `lang:` node\'s filter expression — the legend\'s own rule moved, and the emitter mirrors it.')
|
|
} else {
|
|
const f = node[1]
|
|
const arms = [
|
|
[/\['==', \['get', 'language'\], langKey\]/, 'exact match on the base name'],
|
|
[/\['==', \['slice', \['get', 'language'\], 0, langKey\.length \+ 1\], langKey \+ ','\]/, 'the "base," comma-prefix arm'],
|
|
[/\['in', ' ' \+ langKey, \['get', 'language'\]\]/, 'the " base" suffix-contains arm'],
|
|
]
|
|
for (const [re, what] of arms) {
|
|
if (!re.test(f)) fails.push(`B useLanguageFamilyLegendData.js: the language node's filter no longer has ${what}. research-map.vue's matchesLanguage() mirrors these three arms — change both together, or the map and the grid select different rows again.`)
|
|
}
|
|
if (matchesLanguage) {
|
|
// And prove the mirror on real data rather than on the text: the JS rule must agree with the
|
|
// expression's semantics for every label in the fixture.
|
|
const expr = (lab, base) => lab === base || lab.slice(0, base.length + 1) === base + ',' || lab.includes(' ' + base)
|
|
let disagreements = 0
|
|
const bases = new Set(rows.map((r) => String(r.primary_language.label).split(',')[0].trim()))
|
|
for (const base of bases) for (const r of rows) {
|
|
const lab = String(r.primary_language.label).trim()
|
|
if (expr(lab, base) !== !!matchesLanguage(lab, base)) disagreements++
|
|
}
|
|
if (disagreements) fails.push(`B the emitter's matchesLanguage() disagrees with the legend's filter on ${disagreements} (label, tier) pair(s).`)
|
|
else notes.push(`match rule: agrees with the legend's three-arm filter on every one of ${bases.size} tiers x ${rows.length} labels`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── C/E. every tier resolves to a code, or abstains — and never to an empty grid ──────────────
|
|
if (matchesLanguage) {
|
|
const byBase = new Map()
|
|
for (const r of rows) {
|
|
const b = String(r.primary_language.label).split(',')[0].trim()
|
|
if (!byBase.has(b)) byBase.set(b, [])
|
|
byBase.get(b).push(r)
|
|
}
|
|
let single = 0, many = 0
|
|
const zeroSelecting = []
|
|
for (const [base, tierRows] of byBase) {
|
|
const selected = rows.filter((r) => matchesLanguage(String(r.primary_language.label).trim(), base))
|
|
// E: the tier the user clicked must select SOMETHING. A tier that selects nothing is the
|
|
// 2,107 -> 0 screen by another route.
|
|
if (!selected.length) { zeroSelecting.push(base); continue }
|
|
const codes = [...new Set(selected.map((r) => r.primary_language.value).filter(Boolean))]
|
|
const languageCode = codes.length === 1 ? codes[0] : ''
|
|
if (codes.length === 1) {
|
|
single++
|
|
if (!languageCode) fails.push(`C "${base}" covers exactly one code but resolved an empty languageCode.`)
|
|
} else {
|
|
many++
|
|
if (languageCode) fails.push(`C "${base}" covers ${codes.length} codes but still emitted a single languageCode — one of them would show a fraction of the selection while looking correct.`)
|
|
if (!codes.length) fails.push(`C "${base}" resolved no codes at all.`)
|
|
}
|
|
}
|
|
if (zeroSelecting.length) {
|
|
fails.push(`E ${zeroSelecting.length} language tier(s) select ZERO rows, so clicking them empties the grid: ${zeroSelecting.slice(0, 6).join(', ')}`)
|
|
}
|
|
notes.push(`tiers: ${byBase.size} — ${single} resolve one code (the grid filters), ${many} cover several (the axis is left open)`)
|
|
if (many === 0) fails.push('C no tier in the fixture covers several codes, so the ambiguous path is untested — the fixture was sampled rather than deduplicated.')
|
|
}
|
|
|
|
// ── D. the composite fails OPEN ──────────────────────────────────────────────────────────────
|
|
{
|
|
const g = /function gridLanguage\(\) \{([\s\S]*?)\n\}/.exec(compositeSrc)
|
|
if (!g) {
|
|
fails.push('D research-map-plus-grid.vue: no gridLanguage() — the composite is back to sending `value || label`, which hands the grid a tier NAME no row carries and empties it.')
|
|
} else {
|
|
let fn = null
|
|
try { fn = new Function('linked', `${g[1]}`) } catch (e) { fails.push(`D gridLanguage() does not evaluate (${e.message}).`) }
|
|
if (fn) {
|
|
const many = { language: { value: '', label: 'Arabic', codes: ['apd', 'acm', 'ayl'] } }
|
|
const one = { language: { value: 'ben', label: 'Bengali', codes: ['ben'] } }
|
|
if (fn(many) !== '') fails.push(`D gridLanguage() returned ${JSON.stringify(fn(many))} for a selection covering 3 codes — it must return '' and leave the axis unfiltered. Returning the label is what took the grid to 0.`)
|
|
if (fn(one) !== 'ben') fails.push(`D gridLanguage() returned ${JSON.stringify(fn(one))} for an unambiguous selection — it must send the single code.`)
|
|
if (!fails.some((f) => f.startsWith('D'))) notes.push('composite: gridLanguage() sends the code when there is one and abstains when there is not')
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const n of notes) console.log(' · ' + n)
|
|
if (fails.length) {
|
|
console.error(`\n${TAG}: FAIL`)
|
|
for (const f of fails) console.error(' ' + f)
|
|
process.exit(1)
|
|
}
|
|
console.log(`${TAG}: PASS — every language tier reaches the grid as a code, or honestly narrows nothing.`)
|