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

274 lines
9.9 KiB

/**
* 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 };
/** 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}`;
}
async function validateMorfoAgainstDom(page: Page, morfo: Morfo): 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.
const elementsAttrs = await page.$$eval(`[${partAttr}]`, (nodes) =>
nodes.map((el) =>
Array.from(el.attributes)
.filter((a) => a.name.startsWith('data-'))
.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.
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) {
// (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)`
});
}
}
}
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 browser = await chromium.launch();
const ctx = await browser.newContext();
const failures: Array<{ morfo: string; issues: Issue[] }> = [];
const skipped: string[] = [];
let checked = 0;
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 issues = await validateMorfoAgainstDom(page, morfo);
if (issues.length === 0) {
console.log(`PASS ${morfo.kebab.padEnd(24)} ${route}`);
} else {
console.log(`FAIL ${morfo.kebab.padEnd(24)} ${route}`);
issues.forEach((i) => console.log(` [${i.kind}] ${i.message}`));
failures.push({ morfo: morfo.kebab, issues });
}
} 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('');
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 (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 (skipped.length > 0) {
console.log(`${skipped.length}/${morfos.length} morfos skipped without routed demo.`);
}
process.exit(1);
}

Powered by TurnKey Linux.