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/eidos-event-vocabulary.ts

207 lines
7.2 KiB

/**
* 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<string, readonly string[]>;
}
/** Import every morfo in the catalogue and index its declared event names. */
export async function loadEventVocabulary(): Promise<EventVocabulary> {
const names = new Map<string, string[]>();
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<string, unknown>;
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>): 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');
}

Powered by TurnKey Linux.