238 lines
13 KiB
JavaScript
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.')
|
|
}
|