doxa-maps/internal/_composed-tiers.mjs
Doxa Maps d314c44043 internal/ keeps the reason and drops the speaker: no gate quotes or cites the Driver
Every reference to the Driver in internal/ is reworded the same way as in
library/: the reason stays, the speaker and the quote go, an open decision
reads "provisional, pending a product decision", and agent names and decision-
sheet rows are dropped. Where a gate printed the word, its message now names
the reviewer or the ruling instead.

Three spots were code, not comments, and changed with their callers:
- the element census's allowlist field `driver` is now `ruling`, in the gate
  and both presets (same validation: match, ruling, date and card required);
- check:i18n-never-translated's DRIVER-GATED marker is DECISION-PENDING, in
  the entries and in the regex that counts them (its output is otherwise
  byte-identical);
- the package.json description of the census says "ruling".

public-hygiene-check keeps its two patterns for the word: they are what keep
it out of the served folder.
2026-09-29 05:26:25 +00:00

98 lines
6.1 KiB
JavaScript

/**
* _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 (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 }
}