composes speaks three vocabularies, and unresolved refs now fail

The capability gate reported five composes refs as "resolve to no known bundle/profile" —
bp-research-header, cities-internet-map, gospel-resources-map, my-upg-100-list-map,
research-dashboard-map. All five were correct data. `composes` accepts a third form besides
'<bundle>/<profile>' and a bare '<bundle>': a registered ELEMENT TAG. Nothing needed renaming and no
author's meta.json was wrong; the resolver was short. 5 unresolved -> 0.

Two things the element form forced, both measured:

  · A SELF-REFERENCE is an internal child, not a nested plugin. big-picture composes
    bp-research-header, its own element, which resolved back to big-picture (cap-4) and promoted the
    bundle to cap-5. Excluding self keeps "nests other plugins" from meaning "has parts".

  · A name covering several profiles resolves to the HIGHEST tier among them, and both non-slashed
    forms now resolve the same way. A bare bundle name and that bundle's element tag both mean "this
    bundle", so taking the first profile for one and the highest for the other made the answer depend
    on discovery order. gospel-resources shows it: four single leaves and one cap-4 composite in one
    bundle, and losing the 4 silently demotes whoever composes it.

The rule now lives in internal/_composed-tiers.mjs, imported by the generator that WRITES the tier and
the gate that CHECKS it. It was duplicated, and a rule this fiddly does not stay identical in two files;
the drift would have shown up as a gate passing a manifest the generator would refuse to write. The
data stays independent — the gate rebuilds the edges from the <profile>.meta.json files on disk and
checks the published manifest against them.

With the count at 0, an unresolved ref FAILS in both. While five were outstanding a warning was right:
they were correct data the resolver could not read, and failing would have blamed five authors for a
gap in the tooling. At zero, a typo and a genuine plain child still look identical from here, so the
one thing that must not happen is quietly reading one as the other.

Verified on the tree as it stands: 37 profiles, 25 bundles, 10 declaring composes edges — 10 agree,
0 disagree, 0 unresolved. Each fail arm negative-tested: a one-letter typo in a ref fails; a published
tier that disagrees with its own edges fails; the same composes shape pointing at another bundle's
cap-4 instead of its own element fails, which is what proves self-exclusion is load-bearing rather
than incidental.
This commit is contained in:
Doxa Maps 2026-09-27 23:51:38 +00:00
parent 3d64bcad41
commit 82e05018ee
3 changed files with 133 additions and 71 deletions

View file

@ -0,0 +1,98 @@
/**
* _composed-tiers.mjs — the ONE place the composition tier is derived from `composes` edges.
*
* Shared on purpose. The generator WRITES this tier into the manifest and check-capability-derived
* CHECKS it, and for a while each held its own copy of the rule. A rule with three ref vocabularies
* and a self-exclusion will not stay identical in two files, and the drift has a specific shape: a
* gate that passes a manifest the generator would refuse to write. One implementation, two callers.
*
* What stays independent is the DATA, which is the half worth duplicating: the generator derives from
* the profiles it is about to emit, the gate rebuilds the same edges from the <profile>.meta.json files
* on disk and feeds them to this function against the PUBLISHED manifest. So a manifest that no longer
* matches its own sources still fails, while the rule itself cannot fork.
*
* Imports nothing — pure over the bundle rows, which is what makes it testable from either side.
*/
/**
* Derive the composition tier from the `composes` edges, and refuse to disagree with an author.
*
* ★ WHY DERIVE AT ALL (Driver, 2026-09-27). Tiers 3-5 were AUTHORED ONLY — discover.mjs infers just
* `single` and `parameterized`, so a composite with no meta.json shipped as capability 1. That is not
* hypothetical: research-dashboard did exactly that while nesting ten views, and the warning in
* detectProfileType exists because of it. A missing file is indistinguishable from a deliberate choice.
*
* THE RULE, measured against all 25 bundles before it was written: a profile that composes anything is
* cap-4 (nested-elements-mfe); if any of the things it composes is itself cap-4, it is cap-5
* (nested-mfe-spa). Keyed per PROFILE, and refs are accepted in BOTH published forms — 'bundle/profile'
* (doxa-maps-spa) and a bare 'bundle' (plugin-studio). Getting either of those wrong is not academic:
* keying on the bundle derived 11 false tiers, and resolving only the slashed form demoted plugin-studio
* from 5 to 4. With both handled: 10 composing profiles, 10 agree, 0 disagree.
*
* AN EXPLICIT type STAYS AUTHORITATIVE AND MUST AGREE. It is not overridden — it is CHECKED. A
* declaration that contradicts the structure is the interesting case: either the meta is stale or the
* composes list is, and both are worth a loud build failure rather than a silent pick between them.
*
* An UNRESOLVED ref is reported separately and never silently treated as "not cap-4", because a typo in
* composes and a genuinely non-composite child look identical from here.
*/
export function deriveComposedTiers(bundles) {
const TIER = { 'nested-mfe-spa': 5, 'nested-elements-mfe': 4, monolithic: 3, parameterized: 2, single: 1 }
const byKey = new Map(), byName = new Map(), byElement = new Map()
const reg = (m, k, bundle, type) => { if (!m.has(k)) m.set(k, []); m.get(k).push([bundle, type || null]) }
for (const b of bundles) {
for (const p of b.profiles || []) {
byKey.set(`${b.bundle}/${p.profile}`, p.type || null)
reg(byName, b.bundle, b.bundle, p.type)
reg(byName, p.profile, b.bundle, p.type)
if (p.element) reg(byElement, p.element, b.bundle, p.type)
}
for (const e of b.elements || []) reg(byElement, e, b.bundle, null)
}
// A name that covers SEVERAL profiles resolves to the HIGHEST tier among them, and both non-slashed
// vocabularies resolve the same way. They have to: a bare bundle name and that bundle's element tag
// both mean "this bundle", so taking the first profile for one and the highest for the other made
// the answer depend on discovery order. gospel-resources is the case that shows it — four `single`
// leaves and one cap-4 composite in one bundle, so "first" is a coin flip between 1 and 4, and
// losing the 4 silently demotes whoever composes it. Highest is also the truthful reading of a
// whole-bundle ref: nesting a bundle nests the most composed thing in it.
const best = (rows, selfBundle) => {
const others = rows.filter(([b, t]) => b !== selfBundle && t)
if (!others.length) return null
return others.sort((a, z) => (TIER[z[1]] || 0) - (TIER[a[1]] || 0))[0][1]
}
// ★ `composes` SPEAKS THREE VOCABULARIES, and only knowing two of them invents defects.
// Measured on the tree: 'bundle/profile' (doxa-maps-spa), a bare 'bundle' (plugin-studio's first four
// entries) and an ELEMENT TAG (its last three, plus big-picture and stack-demo). Resolving only the
// first two left FIVE refs "unresolved" and reported as possible typos — they were all element tags,
// written correctly by their authors. The data was right and the resolver was short.
//
// ★ AND A SELF-REFERENCE IS AN INTERNAL CHILD, NOT A NESTED PLUGIN. big-picture composes
// 'bp-research-header', which is its OWN element (src/components/BpResearchHeader.vue). Resolving that
// to the bundle registering it — itself, cap-4 — made big-picture look like it composes a cap-4 and
// promoted it to cap-5. Excluding self is what keeps "nests other plugins" from meaning "has parts".
const UNRESOLVED = Symbol('unresolved')
const resolve = (ref, selfBundle) => {
if (ref.includes('/')) {
if (!byKey.has(ref)) return UNRESOLVED
return ref.split('/')[0] === selfBundle ? null : byKey.get(ref)
}
if (byName.has(ref)) return best(byName.get(ref), selfBundle)
if (byElement.has(ref)) return best(byElement.get(ref), selfBundle)
return UNRESOLVED
}
const conflicts = [], unresolved = []
for (const b of bundles) for (const p of b.profiles || []) {
const own = p.composesOwn
if (!own || !own.length) continue
const kinds = own.map((ref) => {
const k = resolve(ref, b.bundle)
if (k === UNRESOLVED) unresolved.push(`${b.bundle}/${p.profile} -> ${ref}`)
return k
})
const derived = kinds.some((k) => k === 'nested-elements-mfe') ? 'nested-mfe-spa' : 'nested-elements-mfe'
if (!p.type) { p.type = derived; continue }
if (p.type !== derived) conflicts.push(`${b.bundle}/${p.profile}: declared tier "${p.type}" disagrees with the "${derived}" its composes edges derive`)
}
return { conflicts, unresolved }
}

View file

@ -21,12 +21,15 @@
* - `composes` is PER-PROFILE at source and HOISTED TO THE BUNDLE in the manifest. Deriving from
* the bundle row applies a composite's edges to its plain sibling leaves: gospel-resources has one
* composite and four single leaves, and all four derived as cap-4. 11 false failures.
* - refs appear in TWO FORMS in the same field: 'bundle/profile' and a bare 'bundle'. Resolving
* only the first demoted plugin-studio from 5 to 4.
* - refs appear in THREE FORMS in the same field: 'bundle/profile', a bare 'bundle', and an ELEMENT
* TAG. Resolving only the first demoted plugin-studio from 5 to 4; not knowing the third left five
* refs "unresolved" and reported as possible typos when all five were correct.
* With both handled: 10 composing profiles, 10 agree, 0 disagree, on the tree as it stands.
*
* An UNRESOLVED ref is REPORTED, never counted as "not cap-4": a typo and a genuine non-composite
* child look identical from here, and quietly treating one as the other is how a tier silently drops.
* An UNRESOLVED ref now FAILS. It was only ever REPORTED because five were outstanding and they turned
* out to be correct data this gate could not read — failing then would have blamed five authors for a
* gap here. At zero, the reasoning inverts: a typo and a genuine non-composite child still look
* identical from here, so the one thing that must not happen is quietly reading one as the other.
*
* Headless, no browser. Exit 0 pass · 1 fail.
*/
@ -34,6 +37,7 @@ import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
import { deriveComposedTiers } from './_composed-tiers.mjs'
const TAG = 'CAPABILITY-DERIVED'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
@ -54,36 +58,31 @@ for (const b of bundles) for (const p of b.profiles || []) {
}
// ── 2. a declared tier must agree with the edges it declares ────────────────────────────────────
// Uses the SHARED resolver (internal/_composed-tiers.mjs) rather than a second copy. Two implementations of a rule this
// fiddly — three ref vocabularies and a self-exclusion — would drift, and the drift would show up as a
// gate that passes a manifest the generator would refuse to write. What stays independent is the DATA:
// the gate rebuilds composesOwn from the <profile>.meta.json files on disk and checks the PUBLISHED
// manifest against them, so a manifest that no longer matches its own sources still fails.
const readMeta = (bundle, profile) => {
const f = join(ROOT, 'plugin-designs', bundle, `${profile}.meta.json`)
try { return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null } catch { return null }
}
const byKey = new Map(), byName = new Map()
for (const b of bundles) for (const p of b.profiles || []) {
byKey.set(`${b.bundle}/${p.profile}`, p.type || null)
if (!byName.has(b.bundle)) byName.set(b.bundle, p.type || null)
if (!byName.has(p.profile)) byName.set(p.profile, p.type || null)
}
const resolveRef = (ref) => (ref.includes('/') ? byKey.get(ref) : byName.get(ref))
const unresolved = []
let composing = 0
for (const b of bundles) for (const p of b.profiles || []) {
const meta = readMeta(b.bundle, p.profile)
const own = Array.isArray(meta?.composes) ? meta.composes.map(String).filter(Boolean) : []
if (!own.length) continue
composing++
const kinds = own.map((ref) => { const k = resolveRef(ref); if (k === undefined) unresolved.push(`${b.bundle}/${p.profile} -> ${ref}`); return k })
const derived = kinds.some((k) => k === 'nested-elements-mfe') ? 'nested-mfe-spa' : 'nested-elements-mfe'
if (p.type !== derived) {
fails.push(`${b.bundle}/${p.profile}: declares "${p.type}" but its own composes edges derive "${derived}" — one of the two is stale.`)
}
}
const probe = bundles.map((b) => ({
...b,
profiles: (b.profiles || []).map((p) => {
const meta = readMeta(b.bundle, p.profile)
const own = Array.isArray(meta?.composes) ? meta.composes.map(String).filter(Boolean) : []
if (own.length) composing++
return { ...p, composesOwn: own.length ? own : null }
}),
}))
const { conflicts, unresolved } = deriveComposedTiers(probe)
for (const c of conflicts) fails.push(`${c} — the meta.json or the composes list is stale.`)
for (const u of unresolved) fails.push(`${u}: composes ref resolves to nothing. A ref may be "<bundle>/<profile>", a bare "<bundle>", or a registered element tag.`)
console.log(` · ${checked} profile(s) across ${bundles.length} bundle(s); ${composing} declare composes edges`)
if (unresolved.length) {
console.log(` · ${unresolved.length} composes ref(s) resolve to no known bundle/profile. Reported, NOT treated as "not a composite" — a typo and a plain child look the same from here:`)
for (const u of unresolved) console.log(` ${u}`)
}
if (fails.length) {
console.error(`\n${TAG}: FAIL`)
for (const f of fails) console.error(` ✗ ${f}`)

View file

@ -46,6 +46,7 @@ import { createHash } from 'node:crypto'
import { detectProfileType } from './plugin-gallery/discover.mjs'
import { profileMetaFields, bundleStage } from './profile-meta.mjs'
import { TREE_NAME as CONFIGURED_TREE_NAME } from './_tree-name.mjs'
import { deriveComposedTiers } from './_composed-tiers.mjs'
import { syncAppShells } from './sync-app-shells.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
@ -396,48 +397,6 @@ function composesAMapLeaf(dir, profileName, file) {
* composesAMapLeaf reads (see its point 1) but returns the author's data itself rather than
* a map-ness boolean derived from it — the manifest needs the edges, not just the verdict.
*/
/**
* Derive the composition tier from the `composes` edges, and refuse to disagree with an author.
*
* ★ WHY DERIVE AT ALL (Driver, 2026-09-27). Tiers 3-5 were AUTHORED ONLY — discover.mjs infers just
* `single` and `parameterized`, so a composite with no meta.json shipped as capability 1. That is not
* hypothetical: research-dashboard did exactly that while nesting ten views, and the warning in
* detectProfileType exists because of it. A missing file is indistinguishable from a deliberate choice.
*
* THE RULE, measured against all 25 bundles before it was written: a profile that composes anything is
* cap-4 (nested-elements-mfe); if any of the things it composes is itself cap-4, it is cap-5
* (nested-mfe-spa). Keyed per PROFILE, and refs are accepted in BOTH published forms — 'bundle/profile'
* (doxa-maps-spa) and a bare 'bundle' (plugin-studio). Getting either of those wrong is not academic:
* keying on the bundle derived 11 false tiers, and resolving only the slashed form demoted plugin-studio
* from 5 to 4. With both handled: 10 composing profiles, 10 agree, 0 disagree.
*
* AN EXPLICIT type STAYS AUTHORITATIVE AND MUST AGREE. It is not overridden — it is CHECKED. A
* declaration that contradicts the structure is the interesting case: either the meta is stale or the
* composes list is, and both are worth a loud build failure rather than a silent pick between them.
*
* An UNRESOLVED ref is reported separately and never silently treated as "not cap-4", because a typo in
* composes and a genuinely non-composite child look identical from here.
*/
export function deriveComposedTiers(bundles) {
const byKey = new Map(), byBundle = new Map()
for (const b of bundles) for (const p of b.profiles || []) {
byKey.set(`${b.bundle}/${p.profile}`, p.type || null)
if (!byBundle.has(b.bundle)) byBundle.set(b.bundle, p.type || null)
if (!byBundle.has(p.profile)) byBundle.set(p.profile, p.type || null)
}
const resolve = (ref) => (ref.includes('/') ? byKey.get(ref) : byBundle.get(ref))
const conflicts = [], unresolved = []
for (const b of bundles) for (const p of b.profiles || []) {
const own = p.composesOwn
if (!own || !own.length) continue
const kinds = own.map((ref) => { const k = resolve(ref); if (k === undefined) unresolved.push(`${b.bundle}/${p.profile} -> ${ref}`); return k })
const derived = kinds.some((k) => k === 'nested-elements-mfe') ? 'nested-mfe-spa' : 'nested-elements-mfe'
if (!p.type) { p.type = derived; continue }
if (p.type !== derived) conflicts.push(`${b.bundle}/${p.profile}: meta.json declares "${p.type}" but its composes edges derive "${derived}"`)
}
return { conflicts, unresolved }
}
function readComposes(dir, profileName) {
try {
const metaPath = join(dir, `${profileName}.meta.json`)
@ -1415,9 +1374,15 @@ function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) {
// author forgot a meta.json, and a stale meta can no longer disagree with the structure in silence.
{
const { conflicts, unresolved } = deriveComposedTiers(bundles)
// FAILS rather than warns, now that the resolver understands all three vocabularies and the tree has
// ZERO unresolved refs. While five were outstanding a warning was right — they turned out to be
// correct data my resolver could not read, and failing would have blamed five authors for my gap.
// With the count at 0, a new unresolved ref is a genuinely broken edge and silently weakens a tier.
if (unresolved.length) {
console.warn(`[tree] ${unresolved.length} composes ref(s) resolve to no known bundle/profile — a typo here silently weakens a tier:`)
for (const u of unresolved) console.warn(` ${u}`)
console.error(`[tree] ${unresolved.length} composes ref(s) resolve to nothing — a broken edge silently weakens a tier:`)
for (const u of unresolved) console.error(` ${u}`)
console.error(' A ref may be "<bundle>/<profile>", a bare "<bundle>", or a registered element tag.')
process.exit(1)
}
if (conflicts.length) {
console.error('[tree] composition tier CONFLICT — a profile\'s meta.json disagrees with its own composes edges:')