/** * Morfo DOM-validation check. * * For each morfo at `src/uix/morfo/components/*.ts`: * 1. Navigate to the matching routed demo at `/uix/components/{kebab}`. * 2. For every `public` part declared in the morfo, query the DOM for * `[data-{component}-{part}]` (or `[data-{component}]` for root). * 3. For each matching element, verify: * - Every data-attr the morfo declares with `severity: 'required'` * is present. * - Every data-attr with `values: [...]` has a value in that set. * - No `data-{component}-*` attr is present that isn't declared in * the morfo (strict mode — excludes `data-_*` privates). * * Runs after `npm run smoke` passes. Requires `npm run dev` running. * Morfos without a current routed demo are reported as SKIP. * * Exit codes: * 0 — all routed morfos validate against their rendered DOM. * 1 — at least one morfo-vs-DOM discrepancy. * 2 — no dev server / couldn't load morfos / no routed morfos. */ import { chromium, type Page } from 'playwright'; import { EIDOS_ONLY_ATTRS } from './eidos-only-attrs'; import { existsSync, readdirSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; import type { Morfo, MorfoPart, MorfoData } from '../src/uix/morfo/types'; import { validateMorfo } from '../src/uix/morfo/schema'; const __dirname = dirname(fileURLToPath(import.meta.url)); const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components'); const ROUTES_DIR = join(__dirname, '..', 'web', 'routes'); const DEFAULT_ROUTE_PREFIX = '/uix/components'; const routePrefix = normaliseRoutePrefix(process.env.MORFO_ROUTE_PREFIX ?? DEFAULT_ROUTE_PREFIX); function normaliseRoutePrefix(value: string): string { const trimmed = value.trim().replace(/\/+$/, ''); if (!trimmed || trimmed === '/') return ''; return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; } function routeToPageFile(route: string): string { const segments = route .split('/') .filter(Boolean) .filter((segment) => !(segment.startsWith('(') && segment.endsWith(')'))); return join(ROUTES_DIR, ...segments, '+page.svelte'); } function routeForMorfo(morfo: Morfo): string | null { const route = `${routePrefix}/${morfo.kebab}`.replace(/\/+/g, '/'); return existsSync(routeToPageFile(route)) ? route : null; } async function probePort(start: number, end: number): Promise { for (let port = start; port <= end; port++) { try { const res = await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(500) }); if (res.ok || res.status === 404 || res.status === 500) { return `http://localhost:${port}`; } } catch { // try next } } return null; } async function loadMorfos(): Promise { const files = readdirSync(MORFOS_DIR).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')); const out: Morfo[] = []; for (const f of files) { // Use file:// URL on Windows — absolute paths starting with "g:" are // rejected by Node's ESM loader. const url = pathToFileURL(join(MORFOS_DIR, f)).href; const mod = (await import(url)) as Record; for (const v of Object.values(mod)) { if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) { out.push(validateMorfo(v)); } } } return out; } type Issue = { kind: 'missing' | 'bad-value' | 'undeclared'; message: string; /** * The house's instrument-rollout pattern (P0 lesson: measure, adjudicate, * THEN gate): a new pass sets this flag so its findings print and tally * WITHOUT failing the run, until its first harvest is adjudicated — then * the flag is deleted from that pass and it gates. The ARIA pass was born * advisory 2026-08-26 (258 findings), its crop was adjudicated through the * unified dossier (fila cero: the composition edge), and it FLIPPED to * gating 2026-08-27 at zero. Nothing sets this today; the plumbing stays * for the next pass's rollout. */ advisory?: boolean; }; /** * ARIA attrs the CONSUMER legally owns (the A-85 naming class: consumer-first * by vocabulary, no framework default) — their presence on a part without a * morfo declaration is not drift. */ const CONSUMER_NAMING_ARIA = new Set(['aria-label', 'aria-labelledby', 'aria-describedby']); /** * SIGNED exceptions of the ARIA pass — `"kebab.part" -> attr -> reason`. * Growing this map is a decision with an acta, never a convenience (the * focus/keyboard census discipline). Each row cites its cure's owner. */ const ARIA_PASS_EXCEPTIONS: Record> = { // The DEMO wires a combobox-shaped harness onto the input // (+page.svelte:312-315) — consumer ARIA on a framework element, which // this instrument cannot tell from component ARIA without an edge. Cure // owned by the demo (dossier L5-side, sources lane). 'search-field.input': { 'aria-controls': 'demo-written combobox harness — cure is the demo’s', 'aria-expanded': 'demo-written combobox harness — cure is the demo’s', 'aria-autocomplete': 'demo-written combobox harness — cure is the demo’s' }, // The eidos shell hand-writes the status div instead of composing // Palabras.Status, DELIBERATELY: composing would make the whole bar a // live region announcing on every keystroke, against palabras’ own // WCAG 4.1.3 doctrine. Shared-branch territory (palabras axis) — the // declaration is true of the soma part; the shell’s hand div is the // open row (dossier, causa 6). 'palabras.status': { 'aria-live': 'eidos shell hand-writes the div (4.1.3 doctrine) — palabras-axis row' } }; /** Flatten morfo parts into a flat array for validation lookup. */ function flatParts(parts: readonly MorfoPart[]): MorfoPart[] { const out: MorfoPart[] = []; for (const p of parts) { out.push(p); if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts)); } return out; } function dataAttrFor(kebab: string, part: MorfoPart): string { return part.kebab === 'provider' ? `data-${kebab}` : `data-${kebab}-${part.kebab}`; } /** * THE EDGE (fila cero, author-signed 2026-08-27): when a host part SHARES its * DOM element with a composed component (`chat-message.reaction-add` IS the * `Popover.Trigger`), the composed component's ARIA belongs to ITS morfo, not * the host's. The identity already travels through the single attr pipeline: * every composed part stamps its namespaced identifying attr on the same * element — so the edge is resolved from CO-LOCATED part-identifying attrs * (this is the data pass's "Allow other components' part attrs" clause, * generalized to the ARIA those parts declare). No new contract surface. * * Declared limits (named, not covered): * - Consumer-written ARIA on a framework element (search-field's demo) stays * flagged — the cure is the demo's, the instrument cannot tell consumers * from components without an edge. * - A part that never renders in any routed demo (RateButton class) is * invisible to ANY render-based resolution — this instrument measures what * mounts. * - Two co-located contracts declaring the SAME attr with different values is * a future check (named in the dossier), not this clause. */ type EdgeIndex = Map; function buildEdgeIndex(all: Morfo[]): EdgeIndex { const index: EdgeIndex = new Map(); for (const m of all) { for (const p of flatParts(m.parts).filter((p) => p.kind === 'public')) { const attr = dataAttrFor(m.kebab, p); // `data-a-b` can name both a.b and a-b.provider. Zero collisions in // today's 919-attr corpus (measured 2026-08-27); the day one is // born, the edge would resolve the WRONG contract in silence — // throw instead and force the adjudication. const prior = index.get(attr); if (prior) { throw new Error( `edge-index collision: "${attr}" names both ${prior.kebab}.${prior.part.kebab} and ${m.kebab}.${p.kebab}` ); } index.set(attr, { kebab: m.kebab, part: p }); } } return index; } // Tally of the parts a run could not look at (declared here because the // validator increments them; printed in the summary so the headline can never // speak for parts that never mounted). let unmountedParts = 0; const unmountedMorfos = new Set(); async function validateMorfoAgainstDom( page: Page, morfo: Morfo, edgeIndex: EdgeIndex ): Promise { const issues: Issue[] = []; const parts = flatParts(morfo.parts).filter((p) => p.kind === 'public'); // Collect every part-identifying attr across the morfo so a part can // legitimately carry a sibling's identifying attr (e.g. tag-group Link // is a variant of Item, the element carries both data-tag-group-link // and data-tag-group-item). const allPartAttrs = new Set(parts.map((p) => dataAttrFor(morfo.kebab, p))); for (const part of parts) { const partAttr = dataAttrFor(morfo.kebab, part); // All elements on the page that carry this part's identifying attr. // data-* feeds the gating pass; role/aria-* feeds the advisory aria pass. const elementsAttrs = await page.$$eval(`[${partAttr}]`, (nodes) => nodes.map((el) => Array.from(el.attributes) .filter( (a) => a.name.startsWith('data-') || a.name.startsWith('aria-') || a.name === 'role' ) .map((a) => ({ name: a.name, value: a.value })) ) ); if (elementsAttrs.length === 0) { // Not on the demo page — skip validation entirely. Morfo "optional" // vs "required" is about composition validity (whether a consumer // MAY omit the part), not about demo-page presence. Dialog.Content // is required in a valid composition but only mounts when // `open=true`, which the static demo page may not exercise. // // A dedicated permutation-matrix script (future) will exercise // each component through its state space and validate part // presence per permutation. For now, demo presence is advisory. // // BUT THE SKIP IS TALLIED (2026-08-27): this `continue` used to be // MUTE — it neither counted nor printed, and the run then reported // "All N routed morfos validate", a sentence that silently spoke // only for the parts that happened to mount. That is the house's // own "a guard that inspects NOTHING passes" class, living inside // the instrument the house uses to catch it. The summary now says // how many parts went unseen and — the number that turns a figure // into a diagnosis — across how many DISTINCT components: few // components means a demo problem, spread out means the headline // was speaking for a fraction all along. unmountedParts += 1; unmountedMorfos.add(morfo.kebab); continue; } const declared = new Set(part.data.map((d) => d.attr)); declared.add(partAttr); // the part-identifying attr itself // Sibling parts' identifying attrs are legitimate (Link-is-Item pattern). for (const siblingAttr of allPartAttrs) declared.add(siblingAttr); const declaredByAttr = new Map(part.data.map((d) => [d.attr, d])); for (const elAttrs of elementsAttrs) { // THE EDGE, resolved per element: the ARIA/role that any CO-LOCATED // part (another morfo sharing this element — or a sibling part of // this one, the Link-is-Item pattern) declares is OWNED at that // edge and never host drift. const edgeAria = new Set(); const edgeRoles = new Set(); for (const { name } of elAttrs) { if (name === partAttr) continue; const edge = edgeIndex.get(name); if (!edge) continue; for (const entry of edge.part.aria ?? []) edgeAria.add(entry.attr); if (edge.part.role) edgeRoles.add(edge.part.role); } // (a) required attrs present for (const data of part.data) { if ((data.severity ?? 'required') !== 'required') continue; const found = elAttrs.find((a) => a.name === data.attr); if (!found) { issues.push({ kind: 'missing', message: `${morfo.kebab}.${part.kebab}: required attr "${data.attr}" not emitted` }); } } // (b) enum-valued attrs have valid values for (const { name, value } of elAttrs) { const decl = declaredByAttr.get(name); if (decl?.values && !decl.values.includes(value)) { issues.push({ kind: 'bad-value', message: `${morfo.kebab}.${part.kebab}: "${name}" has value "${value}", morfo declares [${decl.values.join(', ')}]` }); } } // (c) no undeclared public data-{component}-* attrs for (const { name } of elAttrs) { if (!name.startsWith(`data-${morfo.kebab}`) && !name.startsWith('data-')) continue; if (name.startsWith(`data-_`)) continue; // private escape hatch if (declared.has(name)) continue; // Eidos-owned cross-cutting hooks (wrapper visual attrs, shared // styling surfaces) are sanctioned OUTSIDE the morfo — the signed // wrapper-attrs doctrine. One list, shared with eidos-lint-all, // so the two instruments agree (P0 fase A, audit 2026-08-26). if (EIDOS_ONLY_ATTRS.has(name)) continue; // Allow other components' part attrs (nested composition). if (!name.startsWith(`data-${morfo.kebab}`)) continue; issues.push({ kind: 'undeclared', message: `${morfo.kebab}.${part.kebab}: undeclared attr "${name}" emitted on element (not in morfo)` }); } // ── ARIA pass (P1·A11Y, ADVISORY until its first harvest is signed) ── // `part.aria` is now CLOSED to `ARIA_ATTR_VOCABULARY` (P1·CONTRATO): // the 134 plain-HTML entries this pass used to skip by hand // (`type` ×130, `tabindex` ×2, `for`, `contenteditable`) live in // `part.attrs` and the schema throws if one comes back. Nothing to // filter here any more. // `Set`, not `Set` — it is probed below with // rendered attribute names, which are plain strings. // // DECLARED WITHOUT A SOURCE IS DECLARED (decision 7, 2026-08-27): an // entry with no `value` hands the value to the provider's bag, so it // belongs in this set — checks (d) and (f) below key on `attr` only // and need no `value` branch. A rendered attr it names is contract, // not drift. // // WHAT (d) ACTUALLY DEMANDS, stated exactly (row 13, author-signed // 2026-08-27 — an earlier draft of this comment promised more than // the code does): (d) demands emission only for a `required` entry // with NO condition. So a sourceless entry is verified when it is // required and unconditional — the provider owning the value is // then the promise that it emits one — and is CONTRACT-ONLY when it // carries `severity: 'optional'` or a `condition`: nothing emits it // and nothing here demands it. That third state is deliberate (it // is the only way to declare «the provider owns this AND it is // conditional»), not an oversight, and `severity` is the dominant // filter of the two. const declaredAria = new Set((part.aria ?? []).map((a) => a.attr)); const exceptions = ARIA_PASS_EXCEPTIONS[`${morfo.kebab}.${part.kebab}`]; // (d) required UNCONDITIONAL aria entries present. Conditioned // entries are skipped — a static demo page may legitimately not // satisfy `part-present` / `prop-truthy`, same philosophy as the // data pass trusting `severity` to encode it. for (const entry of part.aria ?? []) { if ((entry.severity ?? 'required') !== 'required') continue; if (entry.condition) continue; if (exceptions?.[entry.attr]) continue; if (!elAttrs.some((a) => a.name === entry.attr)) { issues.push({ kind: 'missing', message: `${morfo.kebab}.${part.kebab}: required aria "${entry.attr}" not emitted` }); } } // (e) the part's declared role must be the rendered role; a rendered // role with no declaration is drift the contract cannot see — unless // a co-located part owns that role at the edge. const renderedRole = elAttrs.find((a) => a.name === 'role')?.value; if (part.role && renderedRole && renderedRole !== part.role) { if (!edgeRoles.has(renderedRole)) { issues.push({ kind: 'bad-value', message: `${morfo.kebab}.${part.kebab}: role "${renderedRole}" rendered, morfo declares "${part.role}"` }); } } else if (!part.role && renderedRole && !declaredAria.has('role')) { if (!edgeRoles.has(renderedRole)) { issues.push({ kind: 'undeclared', message: `${morfo.kebab}.${part.kebab}: role "${renderedRole}" rendered but the morfo declares none` }); } } // (f) rendered aria-* the morfo does not declare — except the A-85 // consumer-naming class, which the consumer legally owns, and the // attrs a co-located part declares (owned at the edge). for (const { name } of elAttrs) { if (!name.startsWith('aria-')) continue; if (declaredAria.has(name)) continue; if (CONSUMER_NAMING_ARIA.has(name)) continue; if (edgeAria.has(name)) continue; if (exceptions?.[name]) continue; issues.push({ kind: 'undeclared', message: `${morfo.kebab}.${part.kebab}: undeclared aria attr "${name}" emitted (not in morfo)` }); } } } return issues; } // ── Main ──────────────────────────────────────────────────────────────────── const BASE = process.argv[2] ?? (await probePort(5173, 5180)); if (!BASE) { console.error('Could not find a running dev server on 5173-5180.'); console.error('Start it with `npm run dev` in another terminal.'); process.exit(2); } console.error(`Using dev server at ${BASE}`); const morfos = await loadMorfos(); if (morfos.length === 0) { console.error('No morfos found under src/uix/morfo/components/'); process.exit(2); } console.error(`Loaded ${morfos.length} morfo${morfos.length === 1 ? '' : 's'}`); const edgeIndex = buildEdgeIndex(morfos); const browser = await chromium.launch(); const ctx = await browser.newContext(); const failures: Array<{ morfo: string; issues: Issue[] }> = []; const skipped: string[] = []; let checked = 0; let advisoryCount = 0; const advisoryMorfos = new Set(); for (const morfo of morfos) { const route = routeForMorfo(morfo); if (!route) { skipped.push(morfo.kebab); console.log(`SKIP ${morfo.kebab.padEnd(24)} no routed demo under ${routePrefix}`); continue; } checked += 1; const page = await ctx.newPage(); try { await page.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 }); await page.waitForTimeout(500); const allIssues = await validateMorfoAgainstDom(page, morfo, edgeIndex); // The aria pass is advisory (see the Issue type): printed and tallied // apart, never failing the run, until its first harvest is signed. const issues = allIssues.filter((i) => !i.advisory); const advisories = allIssues.filter((i) => i.advisory); if (advisories.length > 0) { advisoryCount += advisories.length; advisoryMorfos.add(morfo.kebab); } if (issues.length === 0) { const ariaNote = advisories.length > 0 ? ` (aria advisory: ${advisories.length})` : ''; console.log(`PASS ${morfo.kebab.padEnd(24)} ${route}${ariaNote}`); } else { console.log(`FAIL ${morfo.kebab.padEnd(24)} ${route}`); issues.forEach((i) => console.log(` [${i.kind}] ${i.message}`)); failures.push({ morfo: morfo.kebab, issues }); } advisories.forEach((i) => console.log(` [aria-advisory:${i.kind}] ${i.message}`)); } catch (e) { console.log(`ERROR ${morfo.kebab.padEnd(24)} ${(e as Error).message}`); failures.push({ morfo: morfo.kebab, issues: [{ kind: 'missing', message: `navigation error: ${(e as Error).message}` }] }); } finally { await page.close(); } } await browser.close(); console.log(''); // The headline can never speak for what the run could not look at: parts that // never mounted in their demo are reported BEFORE the verdict, with how many // distinct components they come from (few = a demo problem; spread out = the // headline was speaking for a fraction). Printed on every outcome, including // the green one — that is the whole point. if (unmountedParts > 0) { console.log( `${unmountedParts} declared part${unmountedParts === 1 ? '' : 's'} never mounted in ${unmountedMorfos.size} of the checked demos — NOT validated (nothing was looked at, so nothing passed).` ); } if (checked === 0) { console.log(`No routed morfos found under ${routePrefix}.`); process.exit(2); } else if (failures.length === 0) { console.log( `All ${checked} routed morfo${checked === 1 ? '' : 's'} validate against their demo DOM.` ); if (advisoryCount > 0) { console.log( `ARIA advisory: ${advisoryCount} finding${advisoryCount === 1 ? '' : 's'} across ${advisoryMorfos.size} morfos — adjudicate, then flip the pass to gating.` ); } if (skipped.length > 0) { console.log(`${skipped.length}/${morfos.length} morfos skipped without routed demo.`); } process.exit(0); } else { const totalIssues = failures.reduce((sum, f) => sum + f.issues.length, 0); console.log( `${failures.length}/${checked} routed morfos failed (${totalIssues} total issues): ${failures.map((f) => f.morfo).join(', ')}` ); if (advisoryCount > 0) { console.log( `ARIA advisory: ${advisoryCount} finding${advisoryCount === 1 ? '' : 's'} across ${advisoryMorfos.size} morfos.` ); } if (skipped.length > 0) { console.log(`${skipped.length}/${morfos.length} morfos skipped without routed demo.`); } process.exit(1); }