doxa-maps/internal/check-composite-registration.mjs
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

238 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* check-composite-registration.mjs — a composite that bakes a leaf's .vue must register everything
* that leaf's own entry file registers.
*
* THE DEFECT THIS CATCHES. In the cap-5 shell the Region donut, the country bars and the Place tree
* rendered ENTIRELY GREY, while the identical build was correct standalone.
*
* The mechanism, which matters more than the symptom: a cap-4 composite imports the sibling
* component directly —
* plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue:18
* import ResearchDashboard from '../research-dashboard/research-dashboard.vue'
* — which takes the COMPONENT and skips `research-dashboard/index.js`, the file that actually
* assembles the bundle (Pinia, i18n, colour strategies, customElements.define). The composite's own
* index.js therefore RE-DECLARES all of that by hand: a manual copy of another file that nothing
* keeps in sync. That is precisely how the strategies leg was lost — the file already documented
* this bypass FOR i18n and fixed only that one leg.
*
* ★ WHY IT IS SILENT, and why a gate is the right answer. `getColorStrategy()` falls back to
* language-family for an unregistered mode rather than throwing; research-dashboard/index.js says so
* in its own comment: "the failure is silent … grey swatches, whereas a named import fails loudly at
* build time." So a missing registration produces a PLAUSIBLE WRONG PICTURE — charts that look like
* a data problem rather than a wiring problem. It was found by READING AN IMAGE; every number passed.
*
* WHAT IT ASSERTS. For each bundle whose root .vue imports a sibling bundle's .vue, every strategy
* key registered in the SIBLING's index.js must also be registered in the COMPOSITE's index.js.
*
* A composite that registers nothing while its sibling registers nothing is fine, and so is a
* composite whose sibling has no index.js. The rule is about DRIFT between two lists that are meant
* to agree, not about any absolute requirement to register.
*
* ★ A FALSE POSITIVE THIS DELIBERATELY AVOIDS: big-picture also bakes a sibling .vue
* (people-groups-grid) and registers ZERO strategies — and that is CORRECT, because neither file
* references colorFor / getColorStrategy / mapbox. A first pass that flagged "bakes a sibling AND
* registers nothing" called it a second instance of the bug; measuring actual usage showed it is
* not one. Hence the comparison is sibling-list vs composite-list, never "must be non-empty".
*
* Parses both files as TEXT. Importing them would evaluate `import.meta.glob` (Vite-only) and throw
* under plain node — see documentation/reference.md. Text parsing keeps this hermetic: no build, no
* browser, no network.
*
* NEGATIVE-TESTED: deleting a key from the composite's registerStrategies fails by name.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DESIGNS = join(ROOT, 'plugin-designs')
// The keys inside `registerStrategies({ a: x, b: y })`. Comments are stripped first: these files
// discuss the registration at length, and a naive scan matches the prose describing the fix.
// ★ BRACE-MATCH, do not regex the argument. The real call is
// registerStrategies({
// './affinity-block.js': { default: affinityBlock },
// './doxa-region.js': { default: doxaRegion },
// })
// — QUOTED keys with NESTED objects. A `\{([\s\S]*?)\}` stopped at the first inner `}` and a
// `[A-Za-z_$][\w$]*\s*:` key pattern never matched a quoted path, so the first version of this gate
// extracted NOTHING and cheerfully reported "no composite bakes a sibling that registers
// strategies" — while its own negative test also passed, because deleting a key from a set that was
// always empty changes nothing. A gate that cannot fail, caught only because the PASS text made a
// claim ("no pairs exist") that I knew to be false.
function strategyKeys(file) {
if (!existsSync(file)) return null
// ★ DO NOT PRE-STRIP COMMENTS HERE. A `/\/\*[\s\S]*?\*\//g` pass deleted 2,953 of 4,102
// characters of research-dashboard/index.js — 72% of the file, INCLUDING the call this function
// exists to find — because the file's prose contains sequences the non-greedy block pattern spans
// across. The gate then reported "no composite bakes a sibling that registers strategies", which
// is a confident claim about the whole repo derived from having parsed nothing.
//
// It is not needed anyway: we locate the CALL (`registerStrategies(`) and then brace-match its
// argument, so a mention inside a comment only matters if it is written as an invocation. The
// import line is excluded by requiring the `(`.
//
// ★ THE PROCESS LESSON, recorded because it cost four rounds: every earlier fix was verified
// against dashboard-plus-grid/index.js, which survives stripping, and never against
// research-dashboard/index.js, which does not. Both files are needed for a single comparison, and
// testing the half that already worked proved nothing each time. When a gate compares A to B,
// assert on A AND B before believing any fix.
const src = readFileSync(file, 'utf8')
// ★ FIND THE CALL, NOT THE IMPORT. The first textual occurrence of "registerStrategies" is the
// import line — `import { registerStrategies } from '@map/colors/_registry.js'` — whose own brace
// is the next `{`, so an indexOf-then-next-brace walk parses the IMPORT and returns its single
// identifier. research-dashboard/index.js came back empty for exactly this reason while
// dashboard-plus-grid/index.js parsed correctly, purely because its two lines sit differently.
// Same code, two files, opposite answers — the tell that the match was positional luck.
// Require a `(` after the name so only an invocation matches.
const call = src.match(/registerStrategies\s*\(/)
if (!call) return new Set()
const at = call.index
const open = src.indexOf('{', at)
if (open < 0) return new Set()
let depth = 0
let end = -1
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++
else if (src[i] === '}') { depth--; if (!depth) { end = i; break } }
}
if (end < 0) return new Set()
const body = src.slice(open + 1, end)
// Only TOP-LEVEL keys: a nested `{ default: x }` must not contribute "default" as a strategy.
const keys = new Set()
let d = 0
for (let i = 0; i < body.length; i++) {
if (body[i] === '{') d++
else if (body[i] === '}') d--
else if (d === 0) {
const rest = body.slice(i)
const m = rest.match(/^(['"])([^'"]+)\1\s*:|^([A-Za-z_$][\w$]*)\s*:/)
if (m) { keys.add(m[2] || m[3]); i += m[0].length - 1 }
}
}
return keys
}
const bundles = readdirSync(DESIGNS, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
.map((e) => e.name)
/**
* ★ THE SIDE EFFECTS A BAKED COMPOSITE MUST REPEAT, beyond the strategy keys.
*
* An entry file does five things. FOUR OF THEM FAIL SILENTLY if a composite forgets them, which is
* why counting on "it renders" is no evidence the entry's work was done:
*
* createPinia() silent — state simply is not shared
* createAppI18n() silent — an empty SyntaxError, and the subtree vanishes
* registerStrategies() silent — getColorStrategy() FALLS BACK instead of throwing, so the
* charts paint grey and read as a data problem
* installRtlTextPlugin() silent — Arabic/Hebrew/Persian labels render REVERSED. Measured: the
* composite shipped mapbox-gl-rtl-text 0 times while both leaf
* bundles shipped it once. Arab World (99 rows) and
* Persian-Median (49) are in scope, so that is a WRONG NAME.
* provide('profileModules') LOUD — an explicit throw, and the ONLY one safe to omit, because a
* baked composite does not use ProfileLoader.vue at all.
*
* So `provide` is deliberately NOT in this list: requiring it would fail a composite that is
* correct. The rule is "repeat what silently disappears", not "repeat everything".
*
* Matched as CALLS (name followed by `(`) so an import line does not count as a use — the same trap
* that made the strategy parser read the import instead of the invocation.
*/
const SILENT_SIDE_EFFECTS = ['createPinia', 'createAppI18n', 'installRtlTextPlugin']
function calledEffects(file) {
if (!existsSync(file)) return null
const src = readFileSync(file, 'utf8')
const out = new Set()
for (const name of SILENT_SIDE_EFFECTS) {
// Strip the import line for THIS name, then look for a real call.
const withoutImport = src.replace(new RegExp(`^import[^\\n]*\\b${name}\\b[^\\n]*$`, 'gm'), '')
if (new RegExp(`\\b${name}\\s*\\(`).test(withoutImport)) out.add(name)
}
return out
}
const errs = []
let comparisons = 0
for (const b of bundles) {
const dir = join(DESIGNS, b)
const vues = readdirSync(dir).filter((f) => f.endsWith('.vue'))
if (!vues.length) continue
// Which sibling bundles does this one bake directly?
const siblings = new Set()
for (const v of vues) {
const src = readFileSync(join(dir, v), 'utf8')
for (const m of src.matchAll(/from\s+'\.\.\/([\w-]+)\/[^']*\.vue'/g)) siblings.add(m[1])
}
if (!siblings.size) continue
const mine = strategyKeys(join(dir, 'index.js'))
if (mine === null) continue // no entry file of its own; nothing to compare
const myEffects = calledEffects(join(dir, 'index.js')) || new Set()
for (const s of siblings) {
const theirs = strategyKeys(join(DESIGNS, s, 'index.js'))
const theirEffects = calledEffects(join(DESIGNS, s, 'index.js')) || new Set()
if (theirs === null) continue // sibling has no entry file; nothing to inherit
// 1 — strategy keys
if (theirs.size) {
comparisons++
const missing = [...theirs].filter((k) => !mine.has(k))
if (missing.length) {
errs.push(
`${b} bakes ${s}/${s}.vue but does not register: ${missing.join(', ')}\n` +
` ${s}/index.js registers [${[...theirs].join(', ')}]\n` +
` ${b}/index.js registers [${[...mine].join(', ')} ]`
)
}
}
// 2 — the silent side effects. Same rule, wider surface: whatever the LEAF entry calls and
// whose absence would not announce itself, the composite must call too.
if (theirEffects.size) {
comparisons++
const missingFx = [...theirEffects].filter((k) => !myEffects.has(k))
if (missingFx.length) {
errs.push(
`${b} bakes ${s}/${s}.vue but never calls: ${missingFx.map((f) => f + '()').join(', ')}\n` +
` ${s}/index.js calls [${[...theirEffects].join(', ')}]\n` +
` ${b}/index.js calls [${[...myEffects].join(', ')} ]\n` +
' Each of these fails SILENTLY when missing — no error, no warning, just a wrong\n' +
' picture. installRtlTextPlugin was absent exactly this way and reversed every\n' +
' Arabic, Hebrew and Persian label in the cap-5 shell.'
)
}
}
}
}
if (errs.length) {
console.error(`COMPOSITE-REG: FAIL — ${errs.length} composite(s) drifted from the leaf they bake.\n`)
errs.forEach((e) => console.error(' ' + e + '\n'))
console.error(' Importing a sibling .vue takes the COMPONENT and skips that bundle\'s index.js,')
console.error(' where the strategies are registered. The composite re-declares them by hand, so')
console.error(' the two lists drift and nothing says so — getColorStrategy() falls back to')
console.error(' language-family instead of throwing, which paints GREY CHARTS that read as a data')
console.error(' problem rather than a wiring one.')
console.error('\n The durable fix is not another hand-copy: export the registration from the leaf')
console.error(' entry as one named function and have both entry files call it.')
process.exit(1)
}
if (!comparisons) {
console.log('COMPOSITE-REG: PASS — no composite bakes a sibling that registers strategies.')
console.log(' Nothing to compare today. This is a real pass, not a skip: the walk ran over every')
console.log(' bundle and found no leaf/composite pair where drift is possible.')
} else {
console.log(`COMPOSITE-REG: PASS — ${comparisons} leaf/composite pair(s); every strategy the leaf`)
console.log(' registers is also registered by the composite that bakes it.')
console.log(' A composite registering nothing while its leaf registers nothing is fine — the rule')
console.log(' is about DRIFT between two lists meant to agree, not about registering per se.')
}