From 75d2445dec5bac8fb836a67f6a398fc42cf841aa Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 12:52:23 +0000 Subject: [PATCH] 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",