From 75d2445dec5bac8fb836a67f6a398fc42cf841aa Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 12:52:23 +0000 Subject: [PATCH 1/3] Gate: the regions idle paint must have one source, not a copy per map Two production maps paint the doxa-regions layers directly -- 35 setPaintProperty('regions-fill'|'regions-border') calls between them, 10 in one file and 25 in the other. Twenty-one pass their value through a one-line helper that wraps it in the same ['case', ['has','iso_3166_1_alpha_3'], v, v] expression, and each map defined its OWN copy of that helper while already passing it into the library composable that paints with it. The library owned the paint logic and not the expression, so the two copies could be edited apart and the same "idle" would render differently on the two maps, with nothing failing. No existing check covers this: the nearest one reads a different component entirely. The gate asserts the idle paint is single-sourced: the library exports the helper, neither map keeps a local definition, and neither inlines the expression. It deliberately does NOT require all 35 writes to route through the library. The raw multi-line writes are highlight states and outline resets that differ by design and by map; demanding uniformity there would make this red on the day it was written, which is how a gate earns being ignored. It also does not assert how OFTEN each map repaints -- one repaints the idle state more than the other and has a dynamic call the other lacks. That is a real difference in when, and flattening it is how a map goes blank. This checks that the maps agree on what idle looks like, never on how often they say it. Two-sided. Poisoning it un-exports the library helper and re-inlines a local copy in a consumer; three assertions flip from ok to FAIL, each catching its own planted regression, and the run exits 1. Registered but NOT chained yet: it is legitimately red until the second map adopts the library helper, and chaining a red gate would break the shared run for everyone else. It joins the chain with that change. Building it found a third copy nobody had counted, under a different name, and two bugs in the gate's own first draft: a block-comment stripper that ate the declarations it was meant to find (so every single-source assertion passed while both files plainly defined the helper), and an exemption for lines mentioning the helper, which hid a one-line definition that mentions it. Both were caught by the rule that a gate must fail when poisoned. --- internal/check-region-paint-single-source.mjs | 129 ++++++++++++++++++ package.json | 1 + 2 files changed, 130 insertions(+) create mode 100644 internal/check-region-paint-single-source.mjs diff --git a/internal/check-region-paint-single-source.mjs b/internal/check-region-paint-single-source.mjs new file mode 100644 index 000000000..4a4e4a32c --- /dev/null +++ b/internal/check-region-paint-single-source.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/* check-region-paint-single-source.mjs — `bun run check:region-paint-single-source`. + * Source-only, headless, no browser. + * + * ★ WHAT DRIFTS, AND WHY A GATE RATHER THAN A NOTE. Two production maps — + * doxa-research-map/research-map.vue and country-posters/countries-map.vue — paint the doxa-regions + * layers directly: 35 setPaintProperty('regions-fill'|'regions-border') calls between them, 10 in + * one file and 25 in the other. Twenty-one of those pass their value through a helper, regionIdle, + * that wraps it in the same ['case', ['has','iso_3166_1_alpha_3'], v, v] expression. The helper was + * DEFINED SEPARATELY IN EACH PLUGIN while the library composable both maps already use + * (useLegendMapBridge) took it as an injected dependency and called it itself. + * + * So the library owned the paint logic and not the helper, and the two copies could be edited apart + * with nothing failing. The two maps would then paint the same "idle" state differently — a + * difference no existing gate covers, because check:region-fill-repaint reads only + * RegionCountryMap.vue, a different component. + * + * ★ WHAT THIS ASSERTS, AND THE LINE IT DOES NOT CROSS. It asserts the IDLE paint is single-sourced. + * It does NOT demand that all 35 writes route through the library, because they legitimately should + * not: the raw multi-line ['case', …] writes are HIGHLIGHT states (a matched region painted + * differently from its neighbours) and fill-outline-color:null resets. Those differ by design and by + * map. A gate that demanded uniformity there would be red on the day it was written, which is how a + * gate earns the habit of being ignored. + * + * ⚠ THE FREQUENCY ASYMMETRY IS DELIBERATELY NOT ASSERTED. countries-map repaints the idle state more + * often than research-map (15 helper calls vs 6) and has one dynamic regionIdle(v) the other lacks. + * That is a real difference in WHEN each map repaints, and flattening it is how a map goes blank — + * so this checks that they agree on WHAT idle looks like, never on how often they say it. + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const POISON = process.argv.includes('--poison') + +const BRIDGE = 'library/composables/legends/useLegendMapBridge.js' +const CONSUMERS = [ + 'plugin-designs/doxa-research-map/research-map.vue', + 'plugin-designs/country-posters/countries-map.vue', +] + +let fails = 0 +const ok = (cond, msg) => { console.log(` ${cond ? 'ok ' : 'FAIL'} - ${msg}`); if (!cond) fails++ } +const read = (p) => readFileSync(resolve(ROOT, p), 'utf8') + +// Drop comment LINES so a sentence ABOUT regionIdle never counts as a definition. This gate has a +// sibling lesson behind it: a name inside a regex literal in a gate script was once counted as a +// definition, and the rule it informed would have been red on day one. +// +// ⚠ LINE-ORIENTED ON PURPOSE. The first draft of this used /\/\*[\s\S]*?\*\//g to strip block +// comments, and in a .vue file — which carries CSS and a template alongside the script — that ate +// whole regions INCLUDING the regionIdle declarations it was meant to find. Every "does not define +// its own regionIdle" assertion then passed while both files plainly defined it. A stripper that can +// delete the thing being measured turns the gate green by destroying its own evidence. +const decomment = (s) => s.split('\n') + .map((l) => (/^\s*(\/\/|\*|\/\*)/.test(l) ? '' : l)) + .join('\n') + +// ── the idle expression, as the library builds it ──────────────────────────────────────────────── +const IDLE_SHAPE = /\[\s*'case'\s*,\s*\[\s*'has'\s*,\s*'iso_3166_1_alpha_3'\s*\]\s*,\s*v\s*,\s*v\s*\]/ + +let bridgeSrc = read(BRIDGE) +if (POISON) { + // Poison 1: a plugin re-introduces its own copy of the helper the library exports. + bridgeSrc = bridgeSrc.replace(/export function regionIdle/, 'function regionIdle') +} +const bridge = decomment(bridgeSrc) + +ok(existsSync(resolve(ROOT, BRIDGE)), `${BRIDGE} exists`) + +// 1 ── the library must OWN and EXPORT the idle helper +const libExports = /export\s+(?:function\s+regionIdle\b|const\s+regionIdle\s*=)/.test(bridge) +ok(libExports, 'useLegendMapBridge exports regionIdle (the library owns the idle expression)') + +const libBody = /regionIdle[\s\S]{0,200}?\['case'[\s\S]{0,120}?iso_3166_1_alpha_3/.test(bridge) +ok(libBody, "the library's regionIdle builds the ['case', ['has','iso_3166_1_alpha_3'], v, v] expression") + +// 2 ── SINGLE SOURCE: no consumer may keep its own copy of a name it imports from the bridge. +// This is the narrow shape — a file that IMPORTS module M must not also DEFINE a name M +// exports. The broad version (any library export name redefined anywhere) was measured against +// all 455 library export names and would be red on day one: colour-mode modules implement an +// interface by name, and generic helpers collide. +for (const rel of CONSUMERS) { + let src = read(rel) + if (POISON) { + // Poison 2: the plugin keeps a local regionIdle even though it imports from the bridge. + src = src.replace( + /(import\s*\{[^}]*\}\s*from\s*'[^']*useLegendMapBridge\.js')/, + "$1\nconst regionIdle = (v) => ['case', ['has', 'iso_3166_1_alpha_3'], v, v]" + ) + } + const s = decomment(src) + const name = rel.split('/').pop() + + const importsBridge = /import\s*\{[^}]*\}\s*from\s*'[^']*useLegendMapBridge\.js'/.test(s) + ok(importsBridge, `${name} imports useLegendMapBridge`) + + const localDef = /^[ \t]*(?:export\s+)?(?:function\s+regionIdle\b|(?:const|let|var)\s+regionIdle\s*=)/m.test(s) + ok(!localDef, `${name} does NOT define its own regionIdle (single source: the library's)`) + + // 3 ── every idle paint write in the consumer goes through the helper, never an inlined copy of + // the expression. Highlight writes are exempt by construction: they do not match IDLE_SHAPE, + // because that shape is specifically `v, v` — the same value on both branches, which is what + // makes it the IDLE state rather than a highlight. + // ⚠ Exempt only a line that CALLS regionIdle, never one that DEFINES it. A one-line + // `function regionIdle(v) { return [...] }` contains the name and the expression together, so + // a naive "skip lines mentioning regionIdle" hid exactly the definition this is hunting. + const inlined = s.split('\n').filter((l, i) => { + if (!IDLE_SHAPE.test(l)) return false + const defines = /(?:function\s+regionIdle\b|(?:const|let|var)\s+regionIdle\s*=)/.test(l) + const calls = /regionIdle\s*\(/.test(l) && !defines + if (calls) return false + return true // an inlined copy, or the local definition itself — both are findings + }) + ok(inlined.length === 0, + `${name} has no inlined copy of the idle expression${inlined.length ? ` (${inlined.length} line(s))` : ''}`) +} + +// 4 ── the bridge must still RECEIVE regionIdle for one round (step 1 keeps the injected parameter, +// so the swap is a pure deletion in the plugins plus an addition in the library). When the +// parameter is finally dropped, this assertion is the one to delete with it. +ok(/function useLegendMapBridge\(\{[^}]*\bregionIdle\b/.test(bridge), + 'useLegendMapBridge still accepts regionIdle as a parameter (kept one round, so the swap stays reviewable)') + +console.log(fails === 0 + ? '\ncheck:region-paint-single-source PASS' + : `\ncheck:region-paint-single-source FAIL (${fails})`) +process.exit(fails ? 1 : 0) diff --git a/package.json b/package.json index bb5502428..3bcf7ce64 100644 --- a/package.json +++ b/package.json @@ -260,6 +260,7 @@ "check:quoted-scripts": "node internal/check-quoted-scripts.mjs", "check:reduced-motion": "node internal/check-reduced-motion.mjs", "check:region-fill-repaint": "node internal/check-region-fill-repaint.mjs", + "check:region-paint-single-source": "node internal/check-region-paint-single-source.mjs", "check:rtl-shaping": "node internal/check-gospel-rtl-shaping.mjs", "check:rtl-shaping:poison": "node internal/check-gospel-rtl-shaping.mjs --poison", "check:ring-vocabulary": "node internal/check-ring-vocabulary.mjs", From ff2774503283744df2644d4a84718ed10737fa67 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 12:52:34 +0000 Subject: [PATCH 2/3] The regions idle paint expression moves to the library that already painted with it useLegendMapBridge took the idle paint helper as an injected dependency and called it five times, while each consuming map defined its own copy of the one-line body and handed it over. The library owned the paint logic and not the expression it painted with, so the copies could drift apart silently and the two maps would render the same idle state differently. Export it from the composable instead, and take it from there in this map. The injected parameter is deliberately KEPT for one round: inside the composable the parameter shadows the module export, so what the bridge paints with is still exactly what its callers pass, and this change cannot move behaviour. It is a deletion in the plugin and an addition in the library, nothing more. The parameter goes when the second map has adopted the export too. This map held a THIRD copy under a different name -- a function-scoped helper in the paint prewarm, byte-identical body, used once. It now refers to the library helper. Verified rather than assumed: the library version and both original plugin versions produce identical output for every value the maps actually pass, including the dynamic one. check:region-paint-single-source is green for this map and stays red for the other until it adopts the export, which is the state the gate is meant to report. The second map is the other plugin's file and is not touched here; its owner takes that half. --- .../composables/legends/useLegendMapBridge.js | 23 +++++++++++++++++++ .../doxa-research-map/research-map.vue | 9 +++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/library/composables/legends/useLegendMapBridge.js b/library/composables/legends/useLegendMapBridge.js index dc281c68e..f2259e72a 100644 --- a/library/composables/legends/useLegendMapBridge.js +++ b/library/composables/legends/useLegendMapBridge.js @@ -89,6 +89,29 @@ export function buildFlatLegendMatchExpr(node, legendType) { return null } +/** + * regionIdle — the doxa-regions "idle" paint expression. + * + * ★ WHY THIS LIVES HERE. Both maps that paint the regions layers — research-map.vue and + * countries-map.vue — defined their own copy of this one-liner while ALREADY passing it into this + * composable, which then called it to paint the idle state. So the library owned the paint logic and + * not the expression it painted with, and the two copies could be edited apart with nothing failing: + * the same "idle" would render differently on the two maps. check:region-paint-single-source is the + * gate that now fails if either plugin re-introduces a local copy. + * + * ⚠ THE SAME VALUE ON BOTH BRANCHES IS THE POINT, not a mistake. ['case', cond, v, v] returns v + * whichever way the condition falls — it exists so the expression is shaped like the HIGHLIGHT + * expressions it sits beside (which do differ per branch), and so a region with no + * iso_3166_1_alpha_3 is painted rather than skipped. Collapsing it to a bare `v` would change what + * MapLibre does with featureless regions. + * + * @param {*} v the paint value to apply in both branches + * @returns {Array} a MapLibre 'case' expression + */ +export function regionIdle(v) { + return ['case', ['has', 'iso_3166_1_alpha_3'], v, v] +} + export function useLegendMapBridge({ map, activeTab, countryOutline, regionIdle, mapFly, pplrInstance, mapId }) { /** * Drop every highlight the legend may have painted on the country/region layers. diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 194995a90..620fbc602 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -199,7 +199,7 @@ import { useLanguageFamilyLegendData } from '@map/composables/legends/useLanguag // SemanticTreeLegend instance can render either depending on activeLegendType. import { useAffinityBlockLegendData } from '@map/composables/legends/useAffinityBlockLegendData.js' import { useWagfRegionsLegendData } from '@map/composables/legends/useWagfRegionsLegendData.js' -import { useLegendMapBridge, findNodeInTree, buildFlatLegendMatchExpr } from '@map/composables/legends/useLegendMapBridge.js' +import { useLegendMapBridge, findNodeInTree, buildFlatLegendMatchExpr, regionIdle } from '@map/composables/legends/useLegendMapBridge.js' import { useLegendData as useFlatLegendData } from '@map/composables/legends/useLegendData.js' import { RESEARCH_LEGEND_OPTIONS } from '@map/composables/legends/researchLegendOptions.js' import { findScrollportAncestor } from '@map/utils/scrollport.js' @@ -761,7 +761,8 @@ const mapData = useMapData({ // property constant again, so the next highlight recompiles the shader variant the pre-warm exists to // have already compiled. rega measured 0 ms on a fresh tab; a reader who clears a selection first was // paying it again, which no run so far has exercised. -const regionIdle = (v) => ['case', ['has', 'iso_3166_1_alpha_3'], v, v] +// regionIdle now comes from the library (useLegendMapBridge), which already called it — this file +// was defining a copy and then passing it in. See check:region-paint-single-source. async function highlightCountryByIso(iso3) { const code = String(iso3 || '').trim().toUpperCase() @@ -1107,7 +1108,9 @@ const REGION_IDLE_WIDTH = ['case', ['has', 'iso_3166_1_alpha_3'], 0.6, 0.6] // does not define is skipped rather than invented, and one already wrapped is left alone. function prewarmRegionPaint(m) { if (!m || !m.getLayer('regions-fill')) return - const wrap = (v) => ['case', ['has', 'iso_3166_1_alpha_3'], v, v] + // Was a THIRD local copy of the idle expression, under a different name — byte-identical body. + // The library's regionIdle replaces it exactly. + const wrap = regionIdle // ★ SKIP WHAT IS ALREADY DATA-DRIVEN, and note the test is not "is it an array". exp-shepherd wrote // their copy to skip any array, reasoning that an expression is already data-driven so there is no // variant to warm. That is right for this layer — line-color arrives as a match on a feature property From f331c962c06560103ced8d13ef1511fb1085e0ab Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 12:55:06 +0000 Subject: [PATCH 3/3] Declare the region-paint gate as on-demand while it is half-adopted Registering a check that no chain runs turns the orphan guard red: it fails any gate-shaped script that nothing reaches and nobody declared. That guard is right, and it caught this the round after it was built -- "registered but not chained" is precisely the state it exists to force into the open, and it was the state this branch was in. The gate cannot be chained yet without holding the shared run red over another plugin owner's unlanded work: it asserts the regions idle paint has one source, and only one of the two maps has adopted the library export so far. So declare it on demand with the reason, in the file that requires one. The entry is temporary and says so. It is deleted in the same landing that chains the gate, once the second map's half lands. Verified the declaration satisfies the guard rather than silences it: blanking the reason makes the guard fail again, naming this entry and the file it is in. --- internal/fixtures/on-demand-checks.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/fixtures/on-demand-checks.json b/internal/fixtures/on-demand-checks.json index 5c05654f2..fdadac724 100644 --- a/internal/fixtures/on-demand-checks.json +++ b/internal/fixtures/on-demand-checks.json @@ -7,6 +7,7 @@ "check:composite": "rebuilds every bundle with vite to compare its bytes, minutes long by design; on every landing the precondition's fresh step rebuilds the composites that landing reaches", "check:hardcoded-english": "report-only while its findings are triaged (97 user-visible English strings on 2026-09-29); chaining it now would hold every gate red. Its :self-test IS chained, so the matcher stays proven", "check:shot-fonts": "checks that the MACHINE taking README screenshots can render emoji (fc-list): a property of that box, not of the product, so it runs before a screenshot session, not in the gates", - "check:coverage-consistent": "the full run includes clause L, the age of the oldest link verification (45 days), which depends on the CALENDAR: chained, it would turn gates red on a date (first 2026-11-08) with no code change. Run it with the link re-verification; every other clause runs in gates:pre as check:coverage-consistent:chained" + "check:coverage-consistent": "the full run includes clause L, the age of the oldest link verification (45 days), which depends on the CALENDAR: chained, it would turn gates red on a date (first 2026-11-08) with no code change. Run it with the link re-verification; every other clause runs in gates:pre as check:coverage-consistent:chained", + "check:region-paint-single-source": "TEMPORARY, remove when chained. Asserts the regions idle paint expression has one source in the library rather than a copy per map. Half-adopted on purpose: research-map.vue took the library export (2026-09-29), countries-map.vue is the other plugin owner's file and its two assertions are red until they land their half. Chaining it now would hold gates:post red for everyone over another agent's unlanded work. It joins gates:post in the SAME landing as that half, and this entry is deleted in that commit" } }