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.
176 lines
6.0 KiB
176 lines
6.0 KiB
/**
|
|
* 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<string, unknown>;
|
|
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);
|
|
});
|