diff --git a/internal/_composed-tiers.mjs b/internal/_composed-tiers.mjs new file mode 100644 index 000000000..2d4b2c9a7 --- /dev/null +++ b/internal/_composed-tiers.mjs @@ -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 .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 } +} diff --git a/internal/check-capability-derived.mjs b/internal/check-capability-derived.mjs index babfc6ace..6e9d42f55 100644 --- a/internal/check-capability-derived.mjs +++ b/internal/check-capability-derived.mjs @@ -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 .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 "/", a bare "", 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}`) diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 7713b6152..1bf0b5f2b 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -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 "/", a bare "", 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:')