You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/scripts/morfo-check.ts

518 lines
21 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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<string | null> {
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<Morfo[]> {
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<string, unknown>;
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<string, Record<string, string>> = {
// 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<string, { kebab: string; part: MorfoPart }>;
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<string>();
async function validateMorfoAgainstDom(
page: Page,
morfo: Morfo,
edgeIndex: EdgeIndex
): Promise<Issue[]> {
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 <a> 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<string, MorfoData>(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<string>();
const edgeRoles = new Set<string>();
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<string>`, not `Set<MorfoAriaAttr>` — 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<string>((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<string>();
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);
}

Powered by TurnKey Linux.