doxa-maps/plugin-designs/research-map-plus-grid/scripts/check-linked-vocabulary.mjs
Doxa Maps fbc517c94a A browser gate refuses to launch outside the browser slot; check:browser-gates-guarded keeps it that way
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
2026-09-28 08:20:21 +00:00

314 lines
19 KiB
JavaScript

#!/usr/bin/env node
/**
* check-linked-vocabulary.mjs — the two halves of this composite must mean the SAME THING by a
* religion family, and this proves it by measuring, on the built tree, that the link delivers
* exactly what the facet delivers on its own.
*
* WHY THIS GATE EXISTS. Linking a facet across two elements is the one place in this repo where a
* bug is INVISIBLE: both halves keep working, both look filtered, and they are simply showing
* different peoples. Region survived that risk because the two halves share a slug. Religion does
* not:
*
* the map — filters religion through its legend's flat tab, keyed by the FIRST LETTER of the
* religion code ('B'), named by RELIGION_FAMILIES ('Buddhism')
* the grid — filters religion through a DERIVED axis, `__religion_family`, which runs that same
* RELIGION_FAMILIES table forward per row (code 'BLA' -> 'Buddhism')
*
* One shared table in library/colors/religion.js, read in opposite directions, copied by neither.
* That is the mapping under test. A composite that sent the letter instead of the name, or the raw
* code instead of the family, would look exactly as healthy — and show a grid of nothing, or a grid
* of everything, beside a correctly dimmed map.
*
* MEASURED, AND IT CHANGED THE DESIGN: `mapStore.filters.religions`, which the map's filter panel
* writes, is read by NOTHING — `mapLayers.applyFilters()` is called and never implemented. A link
* routed through it would have passed every plausible smoke test and dimmed no pin. That is why
* this gate compares COUNTS reached two different ways rather than asserting that state was set.
*
* HOW. Serves the built tree on a free port, loads the composite's own leaf, and for one religion
* family that the data actually contains:
*
* DIRECT set `religion-family` on the nested grid itself — the element's own documented prop,
* no link involved — and record the total it reports. Must be >0 and < unfiltered.
* LINKED clear, then drive the composite's published wire instead: a `map-filtered` event
* carrying the family NAME, exactly as the map emits it. Record the total again.
* ASSERT linked === direct. The link delivered the facet, not something adjacent to it.
*
* ★ THE MUTANT RUNS FIRST, AND THIS GATE FAILS IF THE MUTANT PASSES. Before the real name is sent,
* the same wire is driven with the legend's LETTER KEY ('B') — the single most likely wrong version
* of this mapping, and the one a refactor would reach for. A grid that narrows to the same total on
* 'B' as on 'Buddhism' is a grid whose linking cannot tell the two apart, so the gate reports
* NO-TEETH and fails. A check that has never been seen to fail is not evidence.
*
* Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from '../../../internal/_tree-name.mjs'
const TAG = 'LINKED-VOCABULARY'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('../../../internal/lib/playwright-guarded.mjs')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'research-map-plus-grid')
const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl)
if (!profile) skip('the manifest declares no research-map-plus-grid leaf to load.')
const freePort = () => new Promise((ok) => {
const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
})
const PORT = await freePort()
const TREE = `http://127.0.0.1:${PORT}/${TREE_NAME}`
const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}`
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
await new Promise((r) => setTimeout(r, 900))
const reachable = await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)
if (!reachable) { cleanup(); skip('could not serve the built tree locally.') }
// The tree we just started is the tree we built — but say so out loud rather than trust the port.
const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
await requireThisTree(TREE, ROOT, TAG)
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
const fails = []
const lines = []
// ★ THE PROVIDER MATRIX. The same assertions, run once per engine, because interoperability that
// holds on one provider and not the other is not interoperability
// (BUILD-INTEROP-CONTRACT-GRID-UNDER-MAP-BOTH-PROVIDERS). It is not a formality: the map half of
// this link resolves a religion family through its own legend, and the legend is drawn by the map
// engine — a MapLibre-only paint or layout mistake drops a whole layer silently, which would show
// up here as a link that delivers nothing while the Mapbox run stays green.
const PROVIDERS = ['mapbox', 'maplibre']
// ★ AND PER LOCALE, because the grid's DATA now follows the URL. The labels this link used to carry
// are fully localized — zero language names shared between en and ar — so a link proven only in
// English proves nothing about the language a reader actually asked for. en is the control; ar is
// the case that would have broken silently.
const LOCALES = (process.env.LINK_LOCALES || 'en,ar').split(',').map((s) => s.trim()).filter(Boolean)
const results = {}
try {
for (const locale of LOCALES) {
for (const engine of PROVIDERS) {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
// ★ LISTEN BEFORE THE PAGE RUNS. The grid reports its total on its FIRST filter pass, which can happen
// before an evaluate() after goto attaches a listener; the gate then read "the grid never reported a
// total" while the grid had reported one (rega, ded4b3c1). Every total is recorded from the start.
await page.addInitScript(() => {
window.__lvTotals = []
document.addEventListener('groups-filtered', (e) => { window.__lvTotals.push(e.detail?.total ?? null) }, true)
})
await page.goto(`${LEAF}?provider=${engine}&lang=${locale}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
// Everything below runs in ONE evaluate: the totals have to be read from the same live page, and
// a `groups-filtered` listener installed from Node would miss events fired between round trips.
const out = await page.evaluate(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const deep = (sel) => {
const seen = new Set()
const walk = (root) => {
if (!root || seen.has(root)) return null
seen.add(root)
const hit = root.querySelector?.(sel)
if (hit) return hit
for (const el of root.querySelectorAll?.('*') || []) {
if (el.shadowRoot) { const r = walk(el.shadowRoot); if (r) return r }
}
return null
}
return walk(document)
}
const host = document.querySelector('research-map-plus-grid')
if (!host) return { skip: 'the composite element never appeared on its own leaf' }
const root = host.shadowRoot || host
let grid = null
for (let i = 0; i < 120 && !grid; i++) { grid = deep('people-groups-grid'); if (!grid) await sleep(500) }
if (!grid) return { skip: 'the nested grid never mounted' }
// The grid reports its total on every filter pass. Listening is how a total is read here: the
// rendered count line is localized and the DOM shape is not this gate's business.
// Totals recorded since the page started (addInitScript above), so an early first pass is not missed.
const seen = window.__lvTotals || (window.__lvTotals = [])
let total = seen.length ? seen[seen.length - 1] : null
document.addEventListener('groups-filtered', (e) => { total = e.detail?.total ?? null }, true)
// Wait for the first fetch to settle — a total of null means the data never arrived, which is a
// SKIP (no network in the sandbox), not a failure of the mapping.
for (let i = 0; i < 120 && total === null; i++) await sleep(500)
if (total === null) return { skip: 'the grid never reported a total (no data reached it)' }
const unfiltered = total
// WHICH FAMILY TO TEST: ask both halves what they can name, and take one they agree on. A
// hard-coded 'Islam' would be a table of exactly the kind this whole design refuses.
const gridVocab = (grid.__vocabulary || window.__peopleGroupsGridVocabulary)?.('__religion_family') || []
// The map loads its own records independently of the grid's fetch, and in this composite it is
// the slower half. Waiting for its vocabulary to be non-empty is waiting for its DATA — asking
// before then reports "the two halves share nothing", which is a lie about the mapping.
let mapVocab = []
for (let i = 0; i < 160; i++) {
mapVocab = window.__researchMapVocabulary?.('religionFamily') || []
if (mapVocab.length) break
await sleep(500)
}
if (!mapVocab.length) return { skip: 'the nested map never reported a religion vocabulary (its data never arrived)' }
const shared = gridVocab.filter((n) => mapVocab.includes(n))
if (!shared.length) {
return { fail: `the two halves share NO religion family name. map=[${mapVocab.join(', ')}] grid=[${gridVocab.join(', ')}]` }
}
const settle = async () => { const before = seen.length; for (let i = 0; i < 40 && seen.length === before; i++) await sleep(250); await sleep(400) }
const clear = async () => {
grid.setAttribute('religion-family', '')
host.dispatchEvent(new CustomEvent('map-filtered', { detail: { region: '', religion: '', language: '' }, bubbles: true, composed: true }))
await settle()
}
const driveLinked = async (value) => {
await clear()
// ★ WAIT OUT THE ECHO WINDOW, OR MEASURE AN ARTIFACT. The composite ignores child reports for
// 1200ms after it pushes (its `pushing` guard, which exists so a push does not read back as a
// user action). A drive fired inside that window is DROPPED, and the grid then reports its
// previous total — which for the mutant looks exactly like "the wrong mapping matched
// nothing". MEASURED: the first green here had the mutant at 2107 (untouched), not 0
// (rejected), and the two are indistinguishable from the outside.
await sleep(1500)
// The composite listens on its own root for the map's published event. Firing it from the
// MAP element is what the map itself does; firing it from the host reaches the same listener.
const map = deep('doxa-research-map') || host
map.dispatchEvent(new CustomEvent('map-filtered', { detail: { region: '', religion: value, language: '' }, bubbles: true, composed: true }))
await settle()
return total
}
// Pick a family whose own filtered total is a real subset — a family matching everything or
// nothing proves nothing about the link.
let family = null, direct = null
for (const name of shared) {
await clear()
grid.setAttribute('religion-family', name)
await settle()
if (total > 0 && total < unfiltered) { family = name; direct = total; break }
}
if (!family) return { skip: 'no religion family narrows this grid to a strict subset' }
const letter = family.charAt(0).toUpperCase()
// ── MUTANT FIRST ──────────────────────────────────────────────────────────────────────────
const mutant = await driveLinked(letter)
// ── THEN THE REAL THING ───────────────────────────────────────────────────────────────────
const linked = await driveLinked(family)
// ★ AND THE LANGUAGE FACET, WHICH IS THE ONE THAT MOVES WITH THE LOCALE.
// MEASURED against the live API: primary_language's LABEL is fully localized (zero names shared
// between en and ar) while its VALUE is the stable ISO 639-3 code (all identical). So the link
// must travel as the CODE. This proves it does, and the poison case proves the code is what is
// doing the work rather than a name that happens to match in the locale under test.
let lang = null
const langVocab = (grid.__vocabulary || window.__peopleGroupsGridVocabulary)?.('primary_language') || []
// Find a language whose own facet narrows the grid to a strict subset, and learn its CODE from
// the grid's rows rather than from a table.
for (const name of langVocab.slice(0, 40)) {
await clear()
await sleep(1500)
grid.setAttribute('primary-language', name)
await settle()
if (total > 0 && total < unfiltered) { lang = { name, direct: total }; break }
}
if (lang) {
// The map reports both forms on its own wire; drive that wire and read what the grid does.
await clear(); await sleep(1500)
const map = deep('doxa-research-map') || host
map.dispatchEvent(new CustomEvent('map-filtered', {
detail: { region: '', religion: '', language: lang.name, languageCode: '' },
bubbles: true, composed: true,
}))
await settle()
lang.byName = total
// POISON: a code that cannot exist. If the grid still narrows the same way, the wire is not
// carrying what this gate thinks it is.
await clear(); await sleep(1500)
map.dispatchEvent(new CustomEvent('map-filtered', {
detail: { region: '', religion: '', language: 'zz-not-a-language', languageCode: 'zzz' },
bubbles: true, composed: true,
}))
await settle()
lang.poison = total
}
return { unfiltered, family, letter, direct, linked, mutant, mapVocab, gridVocab: gridVocab.slice(0, 12), lang, langVocabCount: langVocab.length }
})
await page.close()
if (out.skip) { await browser.close(); cleanup(); skip(`${out.skip} (under ${locale}/${engine})`) }
results[`${locale}/${engine}`] = out
if (out.fail) { fails.push(`[${locale}/${engine}] ${out.fail}`); continue }
lines.push(` ── ${locale} / ${engine}`)
lines.push(` vocabulary — the map names ${out.mapVocab.length} religion families, the grid names ${out.gridVocab.length}+; '${out.family}' is shared`)
lines.push(` direct — religion-family="${out.family}" set on the grid itself: ${out.direct} of ${out.unfiltered}`)
lines.push(` linked — the same family over the composite's wire: ${out.linked}`)
lines.push(` mutant — the legend KEY '${out.letter}' over the same wire: ${out.mutant}`)
if (out.mutant === out.direct) {
fails.push(`[${engine}] NO TEETH — sending the legend key '${out.letter}' narrowed the grid to the same ${out.direct} rows as the family name '${out.family}'. This gate cannot tell a right mapping from a wrong one, so its pass would mean nothing.`)
}
if (out.linked !== out.direct) {
fails.push(`[${engine}] the link does not deliver the facet — '${out.family}' set on the grid gives ${out.direct} rows, the same family over the composite's wire gives ${out.linked}.`)
}
if (out.lang) {
lines.push(` language — '${out.lang.name}' direct ${out.lang.direct}, over the wire ${out.lang.byName}, poison ${out.lang.poison} (of ${out.langVocabCount} names)`)
if (out.lang.byName !== out.lang.direct) {
fails.push(`[${engine}] the LANGUAGE link does not deliver the facet — '${out.lang.name}' set on the grid gives ${out.lang.direct} rows, the same language over the composite's wire gives ${out.lang.byName}.`)
}
if (out.lang.poison === out.lang.direct) {
fails.push(`[${engine}] NO TEETH on language — a language that does not exist narrowed the grid to the same ${out.lang.direct} rows. The wire is not carrying what this gate thinks it is.`)
}
} else {
lines.push(` language — SKIPPED (no single language narrows this grid to a strict subset)`)
}
}
}
// ★ THE TWO ENGINES MUST AGREE, WITHIN EACH LOCALE. Each run above only proves the link works in
// one build; identical COUNTS is what proves the contract is the same product on both. Comparing
// ACROSS locales would compare two different datasets, since the labels themselves change — so the
// comparison is per locale, and a per-engine pass with different numbers still fails.
for (const locale of LOCALES) {
const [a, b] = PROVIDERS.map((e) => `${locale}/${e}`)
if (results[a] && results[b] && !results[a].fail && !results[b].fail) {
if (results[a].unfiltered !== results[b].unfiltered) {
fails.push(`the two engines start from different data — ${a} lists ${results[a].unfiltered} groups, ${b} lists ${results[b].unfiltered}.`)
}
if (results[a].family === results[b].family && results[a].direct !== results[b].direct) {
fails.push(`'${results[a].family}' narrows to ${results[a].direct} rows under ${a} and ${results[b].direct} under ${b} — the same choice gives different peoples on the two engines.`)
}
if (results[a].linked !== results[b].linked) {
fails.push(`the link delivers ${results[a].linked} rows under ${a} and ${results[b].linked} under ${b} — interoperability that holds on one provider and not the other is not interoperability.`)
}
}
}
} catch (err) {
fails.push(`the measurement itself failed: ${err && err.message}`)
} finally {
await browser.close().catch(() => {})
cleanup()
}
for (const l of lines) console.log(l)
if (fails.length) {
console.log(`\n${TAG}: FAIL`)
for (const f of fails) console.log(` ✗ ${f}`)
console.log('\n The religion link is one shared table (library/colors/religion.js) read in two')
console.log(' directions: the map reverses family NAME -> legend letter, the grid runs code -> family')
console.log(' NAME per row. Both halves must speak the NAME on the wire.')
process.exit(1)
}
console.log(`${TAG}: PASS — on BOTH providers the composite's religion link delivers exactly the facet the grid delivers on its own, a wrong mapping does not, and the two engines agree to the row.`)