Check a composite registers everything the leaf it bakes registers
Some checks are pending
build-gate / build (push) Waiting to run

In the cap-5 shell the Region donut, country bars and Place tree rendered
entirely grey while the identical build was correct standalone. A cap-4
composite imports the sibling component directly
(dashboard-plus-grid.vue:18), 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
re-declares all of it by hand, a manual copy nothing keeps in sync, which is
how the strategies leg was lost. That file already documented this bypass for
i18n and fixed only that one leg.

It is silent by construction: getColorStrategy() falls back to language-family
for an unregistered mode instead of throwing, so the result is a plausible
wrong picture -- grey charts that read as a data problem rather than a wiring
one. Found by reading an image; every number passed.

The gate compares the leaf's registerStrategies keys against the composite's.
A composite registering nothing while its leaf registers nothing is fine: the
rule is about drift between two lists meant to agree. big-picture also bakes a
sibling and registers zero strategies, and that is correct -- neither it nor
people-groups-grid references colorFor, getColorStrategy or mapbox. A first
pass called it a second instance; measuring usage showed it is not one.

Four rounds to get working, each a real bug found by testing the wrong half:

- pre-stripping comments deleted 2,953 of 4,102 characters of
  research-dashboard/index.js, including the call being searched for, so the
  gate claimed "no composite bakes a sibling that registers strategies" --
  a confident claim about the repo derived from parsing nothing. Not stripping
  is also correct: the call is located by requiring "(", so a mention in prose
  cannot match.
- indexOf('registerStrategies') found the IMPORT line first and brace-matched
  its own braces.
- a non-greedy brace regex stopped at the first inner "}" and the key pattern
  missed quoted path keys.

Every fix was verified against dashboard-plus-grid, which survives stripping,
and never against research-dashboard, which does not. A comparison needs both
halves asserted before any fix is believed.

Negative-tested: dropping doxa-region from the composite fails by name and
prints both lists.
This commit is contained in:
doxa-maps-88655c70 2026-09-22 18:55:01 +00:00
parent 2f5cf655f4
commit ca39d27088
3 changed files with 181 additions and 3 deletions

View file

@ -25,7 +25,7 @@ The scripts these commands are grounded in (from this branch's `package.json`):
// claiming to be ground truth, and this one had silently stopped being it.
"dev": "bun x vite",
"build": "bash internal/build-lock.sh",
"build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:kpi-unit && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
"build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
"preview": "bun x vite preview",
"serve": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public",
"cap:sync": "./node_modules/.bin/cap sync android",

View file

@ -0,0 +1,177 @@
#!/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)
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
for (const s of siblings) {
const theirs = strategyKeys(join(DESIGNS, s, 'index.js'))
if (theirs === null || !theirs.size) continue // sibling registers nothing -> nothing to inherit
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(', ')} ]`
)
}
}
}
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.')
}

View file

@ -48,7 +48,7 @@
"check:dxlife": "node internal/check-dxlife-untouched.mjs",
"check:facet-cascade": "node internal/check-facet-cascade.mjs # a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
"build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:kpi-unit && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
"build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery",
"check:template-literal": "node internal/template-literal-check.mjs",
"verify:map-paint": "node internal/verify-map-paint.mjs",
"check:sri": "node internal/sri-consistency-check.mjs",
@ -99,7 +99,8 @@
"check:no-orphan-gates": "node internal/check-no-orphan-gates.mjs",
"check:one-palette": "node internal/check-one-palette.mjs",
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
"check:synced-css": "node internal/check-synced-css-copies.mjs"
"check:synced-css": "node internal/check-synced-css-copies.mjs",
"check:composite-reg": "node internal/check-composite-registration.mjs"
},
"devDependencies": {
"@capacitor/android": "^7.6.9",