/** * The closed vocabulary an eidos recipe may NAME through the `data-event*` * stamp. * * `src/uix/sema/stamp.ts` writes `data-event` = the morfo event's `name`, plus * `data-event-family` / `data-event-intent` / `data-event-direction` from the * same signal. The * structural linter (`src/uix/eidos/lint.ts`) classifies those attrs as * "eidos-only" and never reads their VALUE — which is how * `[data-event='commit-resize']` survived in `splitter.css` from the * 2026-05-22 rename (`commit-resize` → `commit-set`, bd2e40366) without a * single test complaining. A recipe hooked to a name nobody emits is dead * CSS that looks alive. * * The check is CATALOGUE-wide, not per-component, and deliberately so: * composition means a node receives stamps from another component's morfo * (the card-group item carries `data-toggle-group-item` on the same node and * receives `commit-block`, which toggle-group declares; the calendar * nav-buttons receive `contact-activate` from the composed `Button`). A * per-component ERROR would be a dozen false positives. The mismatch is * still surfaced — as a WARN. */ import { readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { INTENTS } from '../src/uix/intent'; import { SEMA_DIRECTIONS, SEMA_FAMILY_POLICY } from '../src/uix/sema/types'; const __dirname = dirname(fileURLToPath(import.meta.url)); const MORFO_DIRS = [ join(__dirname, '..', 'src', 'uix', 'morfo', 'components'), join(__dirname, '..', 'src', 'uix', 'morfo', 'internal') ]; const FAMILIES: readonly string[] = Object.keys(SEMA_FAMILY_POLICY); const INTENT_VALUES: readonly string[] = INTENTS; /** * Imported, never restated. `scripts/` is outside the `tsconfig` graph, so a * literal `['forward', 'backward']` written here would be unchecked prose that * silently outlives the vocabulary it guards — the same shape of defect this * whole file exists to prevent. `SemaDirection` is derived FROM this array * (`sema/types.ts`), so the two cannot diverge. */ const DIRECTION_VALUES: readonly string[] = SEMA_DIRECTIONS; export interface EventVocabulary { /** event `name` → the morfo kebabs that declare it. */ readonly names: ReadonlyMap; } /** Import every morfo in the catalogue and index its declared event names. */ export async function loadEventVocabulary(): Promise { const names = new Map(); for (const dir of MORFO_DIRS) { for (const file of readdirSync(dir)) { if (!file.endsWith('.ts') || file.endsWith('.test.ts')) continue; // file:// URL — Node's ESM loader rejects bare Windows paths ("g:\…"). const mod = (await import(pathToFileURL(join(dir, file)).href)) as Record; for (const value of Object.values(mod)) { if (typeof value !== 'object' || value === null) continue; const morfo = value as { kebab?: unknown; parts?: unknown; events?: unknown }; if (typeof morfo.kebab !== 'string' || !Array.isArray(morfo.parts)) continue; if (!Array.isArray(morfo.events)) continue; for (const event of morfo.events as readonly { name?: unknown }[]) { if (typeof event?.name !== 'string') continue; const owners = names.get(event.name); if (owners) owners.push(morfo.kebab); else names.set(event.name, [morfo.kebab]); } } } } return { names }; } export interface EventValueFinding { readonly level: 'error' | 'warn'; /** The selector the attribute was authored in. */ readonly rule: string; readonly message: string; } const COMMENT = /\/\*[\s\S]*?\*\//g; const SELECTOR_LINE = /(?:^|\})\s*([^\s{}][^{}]*?)\s*\{/g; const EVENT_ATTR = /\[\s*(data-event-family|data-event-intent|data-event-direction|data-event)\s*([~^$*|]?)=\s*(?:'([^']*)'|"([^"]*)"|([^\]\s]+))/gi; /** * Validate every `data-event` / `-family` / `-intent` / `-direction` VALUE in * a CSS file. * * `ownKebab` (optional) turns "the name exists, but no morfo of this * component declares it" into a WARN — the composition case. */ export function lintEventValues( cssText: string, vocabulary: EventVocabulary, ownKebab?: string ): EventValueFinding[] { // Comments first: splitter.css documents the hook in prose right above the // rule, and a commented name is not a selector. const text = cssText.replace(COMMENT, ''); const out: EventValueFinding[] = []; for (const ruleMatch of text.matchAll(SELECTOR_LINE)) { const rule = ruleMatch[1].trim().replace(/\s+/g, ' '); for (const m of rule.matchAll(EVENT_ATTR)) { const attr = m[1].toLowerCase(); const op = m[2]; const value = m[3] ?? m[4] ?? m[5]; if (attr === 'data-event-family') { check(out, rule, attr, op, value, FAMILIES, 'a canon sema family'); continue; } if (attr === 'data-event-intent') { check(out, rule, attr, op, value, INTENT_VALUES, 'a canon intent'); continue; } if (attr === 'data-event-direction') { check(out, rule, attr, op, value, DIRECTION_VALUES, 'a sense of traversal'); continue; } const matched = matchable(value, op, vocabulary.names.keys()); if (!matched) { out.push(unmodelledOperator(rule, attr, op, value)); continue; } if (matched.length === 0) { out.push({ level: 'error', rule, message: `[${attr}${op}='${value}'] — no morfo declares an event ${op === '^' ? 'starting with' : 'named'} '${value}'.` }); continue; } if (!ownKebab) continue; const owners = [...new Set(matched.flatMap((name) => vocabulary.names.get(name) ?? []))]; if (owners.includes(ownKebab)) continue; out.push({ level: 'warn', rule, message: `[${attr}${op}='${value}'] — declared by ${owners.join(', ')}, not by the '${ownKebab}' morfo (composition, or drift).` }); } } return out; } function check( out: EventValueFinding[], rule: string, attr: string, op: string, value: string, vocabulary: readonly string[], label: string ): void { const matched = matchable(value, op, vocabulary); if (!matched) { out.push(unmodelledOperator(rule, attr, op, value)); return; } if (matched.length > 0) return; out.push({ level: 'error', rule, message: `[${attr}${op}='${value}'] — '${value}' is not ${label} {${vocabulary.join(', ')}}.` }); } /** * The vocabulary entries this attribute selector can ever match. * `undefined` — an operator this guard does not model (nothing uses one today). */ function matchable(value: string, op: string, vocabulary: Iterable): string[] | undefined { if (op === '') return [...vocabulary].filter((entry) => entry === value); if (op === '^') return [...vocabulary].filter((entry) => entry.startsWith(value)); return undefined; } function unmodelledOperator( rule: string, attr: string, op: string, value: string ): EventValueFinding { return { level: 'warn', rule, message: `[${attr}${op}='${value}'] — operator "${op}=" is not modelled; value left unvalidated.` }; } export function formatEventFindings(findings: readonly EventValueFinding[], indent = ' '): string { const lines: string[] = []; for (const f of findings) { lines.push(`${indent}${f.level === 'error' ? '✗' : '·'} ${f.rule}`); lines.push(`${indent} ${f.message}`); } return lines.join('\n'); }