/** * Lint every eidos component CSS against its morfo contract. * * Usage: npx tsx scripts/eidos-lint-all.ts * * Prints a per-component summary so the drift between eidos and the * morfo declarations is visible at a glance. * * Also validates the `data-event*` VALUES against the morfo catalogue and * the sema canon — see `scripts/eidos-event-vocabulary.ts`. */ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; import { compileMorfo } from '../src/uix/morfo/compile'; import { lintEidosCss } from '../src/uix/eidos/lint'; import type { Morfo } from '../src/uix/morfo/types'; import { formatEventFindings, lintEventValues, loadEventVocabulary } from './eidos-event-vocabulary'; // Shared with morfo-check so the two instruments cannot drift (P0 fase A, // audit 2026-08-26) — the list lives in ./eidos-only-attrs.ts. import { EIDOS_ONLY_ATTRS } from './eidos-only-attrs'; const RUNTIME_SIGNAL_ATTRS = new Set([ 'data-disabled', 'data-invalid', 'data-last-action', 'data-orientation', 'data-readonly', 'data-side', 'data-state' ]); const EIDOS_ONLY_PART_SUFFIXES = [ 'footer', 'handle', 'header', 'indicator', 'label', 'main', // waveform F3: the two copies of the wave path (base + progress-clipped) — // decorative svg primitives, rule-of-4 compliant. 'played', 'remaining', 'row', 'row-label', 'status' ] as const; async function main() { const eidosDir = resolve('src/uix/eidos/components'); const cssFiles = readdirSync(eidosDir, { withFileTypes: true }).flatMap((entry) => { if (entry.isFile() && entry.name.endsWith('.css')) { const name = entry.name.replace(/\.css$/, ''); return [{ name, path: resolve(eidosDir, entry.name) }]; } if (entry.isDirectory()) { const path = resolve(eidosDir, entry.name, `${entry.name}.css`); return existsSync(path) ? [{ name: entry.name, path }] : []; } return []; }); console.log('component morfo eidos invalid unused'); console.log('─────────────────────────────────────────────────────'); let totalInvalid = 0; let totalEidosOnly = 0; let totalUnused = 0; const driftHotspots: { name: string; eidosOnly: string[]; invalid: string[] }[] = []; for (const file of cssFiles) { const name = file.name; const morfoPath = resolve(`src/uix/morfo/components/${name}.ts`); try { readFileSync(morfoPath); } catch { console.log(`${name.padEnd(22)}(no morfo file — skipped)`); continue; } const mod = (await import(`../src/uix/morfo/components/${name}`)) as Record; const exportName = `${name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())}Morfo`; const morfo = mod[exportName] as Morfo | undefined; if (!morfo) { console.log(`${name.padEnd(22)}(${exportName} not exported — skipped)`); continue; } const compiled = compileMorfo(morfo); const partMarkers = new Set(Object.values(compiled.parts.attrs)); const css = readFileSync(file.path, 'utf-8'); const report = lintEidosCss(css, compiled); console.log( `${name.padEnd(22)}${String(report.counts.morfoBacked).padEnd(7)}${String(report.counts.eidosOnly).padEnd(7)}${String(report.counts.invalid).padEnd(9)}${report.unusedFromContract.length}` ); totalInvalid += report.counts.invalid; totalEidosOnly += report.counts.eidosOnly; totalUnused += report.unusedFromContract.length; // Filter eidos-only that look like drift (data-* on a part where the morfo // never declared the attr — vs design tokens like data-color/-size/-variant). const drift = report.findings.filter( (f) => f.status === 'eidos-only' && (f.eidosOnlyAttrs ?? f.attrs).some( (a) => a.attr.startsWith('data-') && !partMarkers.has(a.attr) && !isAllowedEidosOnlyAttr(a.attr) ) ); const invalid = report.findings.filter((f) => f.status === 'invalid'); if (drift.length || invalid.length) { driftHotspots.push({ name, eidosOnly: drift.map((f) => f.rule), invalid: invalid.map((f) => `${f.rule} — ${f.reason}`) }); } } console.log('─────────────────────────────────────────────────────'); console.log( `totals${' '.repeat(16)} ${totalEidosOnly} ${totalInvalid} ${totalUnused}` ); if (driftHotspots.length) { console.log('\nDrift hotspots (eidos uses data-* attrs the morfo does not declare):'); for (const h of driftHotspots) { if (!h.eidosOnly.length && !h.invalid.length) continue; console.log(`\n ${h.name}`); for (const r of h.invalid) console.log(` ✗ ${r}`); for (const r of h.eidosOnly) console.log(` · ${r}`); } } else { console.log('\nNo drift hotspots beyond documented Eidos-only attrs/parts.'); } // `data-event*` VALUE check. Its own pass over the same files: it needs no // compiled morfo, so it also covers the CSS skipped above for lack of one. const vocabulary = await loadEventVocabulary(); let deadEvents = 0; const eventBlocks: string[] = []; for (const file of cssFiles) { const findings = lintEventValues(readFileSync(file.path, 'utf-8'), vocabulary, file.name); if (!findings.length) continue; deadEvents += findings.filter((f) => f.level === 'error').length; eventBlocks.push(` ${file.name}\n${formatEventFindings(findings, ' ')}`); } if (eventBlocks.length) { console.log('\ndata-event* vocabulary (value must exist in a morfo / the canon):'); for (const block of eventBlocks) console.log(`\n${block}`); } else { console.log('\nEvery data-event* value in a recipe exists in the morfo catalogue.'); } if (totalInvalid > 0 || deadEvents > 0) process.exit(1); } function isAllowedEidosOnlyAttr(attr: string): boolean { if (EIDOS_ONLY_ATTRS.has(attr)) return true; if (RUNTIME_SIGNAL_ATTRS.has(attr)) return true; return EIDOS_ONLY_PART_SUFFIXES.some((suffix) => attr.endsWith(`-${suffix}`)); } main().catch((err) => { console.error(err); process.exit(2); });