231 lines
15 KiB
JavaScript
231 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-composite-interop.mjs — the links between a composite's children must exist on BOTH ends.
|
|
*
|
|
* WHY THIS EXISTS. A capability-4 composite is only a composite because its children talk: the grid
|
|
* narrows the map, "Show on map" moves the map, a region click retitles the dashboard. Every one of
|
|
* those is a NAME (an event, an attribute, a config key) written in two files that nothing ties
|
|
* together. Rename it on one side and the build stays green, both children still render, and the
|
|
* link is silently gone. The interop matrix (2026-09-26) also found the other silent way a link dies:
|
|
* research-map.vue found its host with getCurrentInstance() INSIDE a watcher callback, where Vue
|
|
* returns null, so `map-filtered` was never dispatched, and the only "proof" had fired a synthetic
|
|
* event at the composite. Neither shape needs a browser to see.
|
|
*
|
|
* WHAT IT CHECKS, headless:
|
|
* A. SOURCE: each link's send pattern is in the sender file and its listen pattern is in the
|
|
* receiver file (a rename on either side fails, naming both files).
|
|
* B. BUILT: each link's literal is in every bundle that ships an end of it (what the reader
|
|
* actually gets; a stale bundle fails here even when the source is right).
|
|
* C. COVERAGE: every composite (*.meta.json type nested-elements-mfe) has links in LINKS or an
|
|
* entry in NO_LINKS with its reason. A new composite with neither fails as "unmapped".
|
|
* D. LATE getCurrentInstance(): in plugin-designs/ and library/, a getCurrentInstance() call
|
|
* inside a plain function (not setup, not a lifecycle hook, not a use* composable body) is a
|
|
* host lookup that returns null whenever that function runs from a watcher or an event. Capture
|
|
* the instance in setup instead. Sites already routed to an owner are listed in LATE_KNOWN and
|
|
* printed, not failed; delete the entry in the owner's fix commit so the gate enforces it again.
|
|
*
|
|
* NOT A SUBSTITUTE FOR A BROWSER. Both ends existing is necessary, not sufficient: UPG-100's
|
|
* select-zone listener exists and still loses the first click, because the lazy Map pane attaches it
|
|
* too late (rega, 2026-09-26). The runtime half is check:gallery-embed's `linked` phase.
|
|
*
|
|
* NEGATIVE-TESTED (2026-09-26, main 521f8b18, PASS with 17 links and the 3 LATE_KNOWN sites), each FAILS:
|
|
* a receiver renamed in source ('map-filtered' -> 'map-filter' in research-map-plus-grid.vue);
|
|
* a built bundle out of step ("country-selected" renamed in regions-dashboard.js only);
|
|
* stack-demo's NO_LINKS entry removed (unmapped composite);
|
|
* a new getCurrentInstance() in a one-line function, a one-line arrow and a multi-line function;
|
|
* the emitFilterState site fixed with its LATE_KNOWN entry left behind (stale entry).
|
|
*/
|
|
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
import { resolve, dirname, join, relative } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { TREE_NAME } from './_tree-name.mjs'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const PD = 'plugin-designs'
|
|
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
// Common send/listen shapes. `n` is the event name.
|
|
const EMIT = (n) => new RegExp(`emit\\(\\s*['"]${esc(n)}['"]`)
|
|
const VON = (n) => new RegExp(`@${esc(n)}=`)
|
|
const DOM_SEND = (n) => new RegExp(`new CustomEvent\\(\\s*['"]${esc(n)}['"]|['"]${esc(n)}['"][\\s\\S]{0,400}dispatchEvent|dispatch\\(\\s*['"]${esc(n)}['"]`)
|
|
const DOM_LISTEN = (n) => new RegExp(`addEventListener\\??\\.?\\(\\s*['"]${esc(n)}['"]`)
|
|
const LIT = (s) => new RegExp(esc(s))
|
|
const camelOn = (n) => 'on' + n.replace(/(^|-)([a-z])/g, (_, __, c) => c.toUpperCase())
|
|
|
|
// ── the links (PRODUCTION-MATRIX.md "Interop matrix") ─────────────────────────────────────────
|
|
// send/recv: [file, RegExp]. built: [bundle, literal] — every bundle that ships an end of the link.
|
|
const LINKS = [
|
|
// big-picture: children compiled in (Vue emits + props)
|
|
{ composite: 'big-picture', id: 'header facets -> grid (research-filter)',
|
|
send: [`${PD}/big-picture/src/components/BpResearchHeader.vue`, EMIT('research-filter')],
|
|
recv: [`${PD}/big-picture/big-picture.vue`, VON('research-filter')],
|
|
built: [['big-picture', '"research-filter"'], ['big-picture', camelOn('research-filter')]] },
|
|
{ composite: 'big-picture', id: 'grid total -> header count (groups-filtered)',
|
|
send: [`${PD}/people-groups-grid/people-groups-grid.vue`, EMIT('groups-filtered')],
|
|
recv: [`${PD}/big-picture/big-picture.vue`, VON('groups-filtered')],
|
|
built: [['big-picture', '"groups-filtered"'], ['big-picture', camelOn('groups-filtered')]] },
|
|
// dashboard-plus-grid: children compiled in
|
|
{ composite: 'dashboard-plus-grid', id: 'dashboard facets -> grid (dashboard-filter)',
|
|
send: [`${PD}/research-dashboard/research-dashboard.vue`, EMIT('dashboard-filter')],
|
|
recv: [`${PD}/dashboard-plus-grid/dashboard-plus-grid.vue`, VON('dashboard-filter')],
|
|
built: [['dashboard-plus-grid', '"dashboard-filter"'], ['dashboard-plus-grid', camelOn('dashboard-filter')]] },
|
|
// research-map-plus-grid: children composed by src (DOM events + attributes)
|
|
{ composite: 'research-map-plus-grid', id: 'grid -> composite (groups-filtered)',
|
|
send: [`${PD}/people-groups-grid/people-groups-grid.vue`, DOM_SEND('groups-filtered')],
|
|
recv: [`${PD}/research-map-plus-grid/research-map-plus-grid.vue`, DOM_LISTEN('groups-filtered')],
|
|
built: [['people-groups-grid', '"groups-filtered"'], ['research-map-plus-grid', '"groups-filtered"']] },
|
|
{ composite: 'research-map-plus-grid', id: 'map -> composite (map-filtered)',
|
|
send: [`${PD}/doxa-research-map/research-map.vue`, DOM_SEND('map-filtered')],
|
|
recv: [`${PD}/research-map-plus-grid/research-map-plus-grid.vue`, DOM_LISTEN('map-filtered')],
|
|
built: [['doxa-research-map', '"map-filtered"'], ['research-map-plus-grid', '"map-filtered"']] },
|
|
{ composite: 'research-map-plus-grid', id: 'composite -> map (externalFilters)',
|
|
send: [`${PD}/research-map-plus-grid/research-map-plus-grid.vue`, LIT('externalFilters')],
|
|
recv: [`${PD}/doxa-research-map/research-map.vue`, LIT('externalFilters')],
|
|
built: [['research-map-plus-grid', 'externalFilters'], ['doxa-research-map', 'externalFilters']] },
|
|
{ composite: 'research-map-plus-grid', id: 'composite -> map (fitSlugs)',
|
|
send: [`${PD}/research-map-plus-grid/research-map-plus-grid.vue`, LIT('fitSlugs')],
|
|
recv: [`${PD}/doxa-research-map/research-map.vue`, /'fitSlugs' in raw/],
|
|
built: [['research-map-plus-grid', 'fitSlugs'], ['doxa-research-map', 'fitSlugs']] },
|
|
...[['wagf-region', 'wagfRegion'], ['religion-family', 'religionFamily'], ['primary-language', 'primaryLanguage']].map(([attr, prop]) => ({
|
|
composite: 'research-map-plus-grid', id: `composite -> grid (attribute ${attr})`,
|
|
send: [`${PD}/research-map-plus-grid/research-map-plus-grid.vue`, new RegExp(`setAttribute\\(\\s*'${esc(attr)}'`)],
|
|
recv: [`${PD}/people-groups-grid/people-groups-grid.vue`, new RegExp(`^\\s*${prop}\\s*:\\s*\\{\\s*type`, 'm')],
|
|
built: [['research-map-plus-grid', `"${attr}"`], ['people-groups-grid', prop]],
|
|
})),
|
|
// country-posters: tabs (window event scoped by instanceId)
|
|
{ composite: 'country-posters', id: 'wall "Show on map" -> composite',
|
|
send: [`${PD}/country-posters/country-poster-wall.vue`, /event-name="doxa-country-posters:show-on-map"/],
|
|
recv: [`${PD}/country-posters/country-posters-mfe.vue`, /['"]doxa-country-posters:show-on-map['"]/],
|
|
built: [['country-posters', 'doxa-country-posters:show-on-map']] },
|
|
// my-upg-100-list: tabs
|
|
{ composite: 'my-upg-100-list', id: 'wall "Show on map" -> composite',
|
|
send: [`${PD}/my-upg-100-list/poster-wall.vue`, /event-name="doxa-upg-100-list:show-on-map"/],
|
|
recv: [`${PD}/my-upg-100-list/upg-100-mfe.vue`, DOM_LISTEN('doxa-upg-100-list:show-on-map')],
|
|
built: [['my-upg-100-list', 'doxa-upg-100-list:show-on-map']] },
|
|
{ composite: 'my-upg-100-list', id: 'composite -> map (doxa:select-zone)',
|
|
send: [`${PD}/my-upg-100-list/upg-100-mfe.vue`, DOM_SEND('doxa:select-zone')],
|
|
recv: [`${PD}/my-upg-100-list/upg-100-list.vue`, DOM_LISTEN('doxa:select-zone')],
|
|
built: [['my-upg-100-list', '"doxa:select-zone"']] },
|
|
// regions-dashboard: tabs
|
|
...['region-selected', 'country-selected'].map((n) => ({
|
|
composite: 'regions-dashboard', id: `list -> composite (${n})`,
|
|
send: [`${PD}/regions-dashboard/regions-list.vue`, DOM_SEND(n)],
|
|
recv: [`${PD}/regions-dashboard/regions-mfe.vue`, DOM_LISTEN(n)],
|
|
built: [['regions-dashboard', `"${n}"`]],
|
|
})),
|
|
{ composite: 'regions-dashboard', id: 'composite -> dashboards (doxa-regions-dashboard:select)',
|
|
send: [`${PD}/regions-dashboard/regions-mfe.vue`, /SELECT_EVENT = 'doxa-regions-dashboard:select'[\s\S]*dispatchEvent\(new CustomEvent\(SELECT_EVENT/],
|
|
recv: [`${PD}/regions-dashboard/region-dashboards.vue`, /SELECT_EVENT = 'doxa-regions-dashboard:select'[\s\S]*addEventListener\(SELECT_EVENT/],
|
|
built: [['regions-dashboard', 'doxa-regions-dashboard:select']] },
|
|
// gospel-resources (gospel-dashboard): children compiled in, provide/inject
|
|
{ composite: 'gospel-resources', id: 'map -> tables (composedFilter)',
|
|
send: [`${PD}/gospel-resources/resources-map.vue`, /composedFilter\.value\s*=/],
|
|
recv: [`${PD}/gospel-resources/data-tables.vue`, /watch\(\s*composedFilter/],
|
|
built: [['gospel-resources', '"composedFilter"']] },
|
|
]
|
|
|
|
// Composites whose children deliberately exchange nothing. The reason is printed on every run.
|
|
const NO_LINKS = {
|
|
'stack-demo': 'a demonstration stack: page knobs only; dashboard-plus-grid is the linked version',
|
|
}
|
|
|
|
// ── D: late getCurrentInstance() sites already routed to an owner ─────────────────────────────
|
|
const LATE_KNOWN = {
|
|
// emitFilterState runs from a watcher, so `map-filtered` is never dispatched (map -> grid dead).
|
|
[`${PD}/doxa-research-map/research-map.vue#emitFilterState`]: 'doxa-research-map owner, routed via dx-shepherd 2026-09-26',
|
|
// reflectDir runs from watch(textDir): a host switching lang to ar AFTER mount gets no dir=rtl.
|
|
[`${PD}/dashboard-plus-grid/dashboard-plus-grid.vue#reflectDir`]: 'composite owner, routed via dx-shepherd 2026-09-26',
|
|
[`${PD}/research-map-plus-grid/research-map-plus-grid.vue#reflectDir`]: 'composite owner, routed via dx-shepherd 2026-09-26',
|
|
}
|
|
|
|
const fails = []
|
|
const known = []
|
|
const read = (rel) => (existsSync(join(ROOT, rel)) ? readFileSync(join(ROOT, rel), 'utf8') : null)
|
|
const bundleCache = {}
|
|
const bundle = (b) => (bundleCache[b] ??= read(`public/${TREE_NAME}/${b}/${b}.js`))
|
|
|
|
// A + B
|
|
for (const l of LINKS) {
|
|
const tag = `${l.composite}: ${l.id}`
|
|
for (const [side, [file, re]] of [['sender', l.send], ['receiver', l.recv]]) {
|
|
const src = read(file)
|
|
if (src == null) fails.push(`${tag} — ${side} file is gone: ${file}`)
|
|
else if (!re.test(src)) fails.push(`${tag} — the ${side} no longer matches ${re} in ${file} (renamed on one side?)`)
|
|
}
|
|
for (const [b, lit] of l.built) {
|
|
const js = bundle(b)
|
|
if (js == null) fails.push(`${tag} — no built bundle public/${TREE_NAME}/${b}/${b}.js`)
|
|
else if (!js.includes(lit)) fails.push(`${tag} — built ${b}.js does not contain ${lit} (stale bundle, or renamed)`)
|
|
}
|
|
}
|
|
|
|
// C
|
|
const composites = []
|
|
for (const b of readdirSync(join(ROOT, PD))) {
|
|
let files = []
|
|
try { files = readdirSync(join(ROOT, PD, b)) } catch { continue }
|
|
for (const f of files.filter((f) => f.endsWith('.meta.json'))) {
|
|
try { if (JSON.parse(readFileSync(join(ROOT, PD, b, f), 'utf8')).type === 'nested-elements-mfe') composites.push(b) } catch { /* not ours to judge */ }
|
|
}
|
|
}
|
|
if (!composites.length) fails.push('found no capability-4 composites (vacuous run)')
|
|
for (const c of composites) {
|
|
const n = LINKS.filter((l) => l.composite === c).length
|
|
if (!n && !NO_LINKS[c]) fails.push(`${c}: unmapped composite — add its links to LINKS, or an entry to NO_LINKS saying why it has none`)
|
|
}
|
|
|
|
// D
|
|
function* sources(dir) {
|
|
for (const e of readdirSync(join(ROOT, dir), { withFileTypes: true })) {
|
|
const rel = `${dir}/${e.name}`
|
|
if (e.isDirectory()) { if (!/node_modules|^\./.test(e.name) && !/\/(scripts|dist)$/.test(rel)) yield* sources(rel) }
|
|
else if (/\.(vue|js)$/.test(e.name)) yield rel
|
|
}
|
|
}
|
|
const HOOK = /\b(on(Before)?(Mounted|Unmounted|Update|Updated)|onActivated|onDeactivated)\s*\(/
|
|
for (const rel of [...sources(PD), ...sources('library')]) {
|
|
const lines = readFileSync(join(ROOT, rel), 'utf8').split('\n')
|
|
lines.forEach((line, i) => {
|
|
if (!/getCurrentInstance\(\)/.test(line) || /^\s*(\/\/|\*|import\b)/.test(line)) return
|
|
// Walk the enclosing blocks outward by indentation.
|
|
let ind = line.search(/\S/)
|
|
let verdict = 'top'
|
|
let fnName = ''
|
|
// A one-liner opens its function on the SAME line as the call: read that prefix first.
|
|
const pre = line.slice(0, line.indexOf('getCurrentInstance()'))
|
|
const same = pre.match(/function\s+([A-Za-z0-9_$]+)\s*\(/)
|
|
if (HOOK.test(pre) || /\bsetup\s*\(/.test(pre)) verdict = 'ok'
|
|
else if (same) { verdict = /^use[A-Z]/.test(same[1]) ? 'ok' : 'late'; fnName = same[1] }
|
|
else if (/=>/.test(pre) || /\bfunction\s*\(/.test(pre)) { verdict = 'late'; fnName = (pre.match(/(?:const|let)\s+([A-Za-z0-9_$]+)\s*=/) || [])[1] || '(anonymous)' }
|
|
for (let j = i - 1; verdict === 'top' && j >= 0 && ind > 0; j--) {
|
|
const l = lines[j]
|
|
if (!l.trim() || /^\s*(\/\/|\*)/.test(l)) continue
|
|
const li = l.search(/\S/)
|
|
if (li >= ind) continue
|
|
ind = li
|
|
if (HOOK.test(l) || /\bsetup\s*\(/.test(l)) { verdict = 'ok'; break }
|
|
const m = l.match(/function\s+([A-Za-z0-9_$]+)\s*\(/)
|
|
if (m) { if (/^use[A-Z]/.test(m[1])) { verdict = 'ok'; break } verdict = 'late'; fnName = m[1]; break }
|
|
if (/=>\s*\{?\s*$/.test(l) || /\bfunction\s*\(/.test(l)) { verdict = 'late'; fnName = '(anonymous)'; break }
|
|
}
|
|
if (verdict !== 'late') return
|
|
const key = `${rel}#${fnName}`
|
|
const where = `${rel}:${i + 1} in ${fnName}()`
|
|
if (LATE_KNOWN[key]) known.push(`KNOWN [late getCurrentInstance] ${where} — owner: ${LATE_KNOWN[key]}`)
|
|
else fails.push(`${where}: getCurrentInstance() inside a plain function returns null when it runs from a watcher or an event. Capture \`const inst = getCurrentInstance()\` in setup and use inst here.`)
|
|
})
|
|
}
|
|
for (const k of Object.keys(LATE_KNOWN)) {
|
|
if (!known.some((m) => m.includes(k.split('#')[0]) && m.includes(k.split('#')[1] + '()'))) {
|
|
fails.push(`LATE_KNOWN entry no longer matches anything: ${k} — it was fixed; delete the entry so the gate enforces it`)
|
|
}
|
|
}
|
|
|
|
console.log(`COMPOSITE-INTEROP: ${composites.length} composite(s), ${LINKS.length} link(s) checked in source and in the built tree (${TREE_NAME})`)
|
|
for (const [c, why] of Object.entries(NO_LINKS)) console.log(` · ${c}: no links, by design — ${why}`)
|
|
for (const k of known) console.log(` ${k}`)
|
|
if (fails.length) {
|
|
console.log(`\nCOMPOSITE-INTEROP: FAIL (${fails.length})`)
|
|
for (const f of fails) console.log(` - ${f}`)
|
|
process.exit(1)
|
|
}
|
|
console.log('COMPOSITE-INTEROP: PASS — every link is present on both ends, in source and in the built bundles')
|