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

feat(morfo,sema,eidos): todo evento dice de que familia es, y el cruce por fin se ve El framework llamaba a la misma cosa de dos maneras: `open` pelado en ocho componentes y `emerge-open` en tres. No era estetica — un preset de movimiento engancha el nombre con `^=`, asi que el dialecto pelado no casaba con ninguna firma y simplemente no animaba, sin romper una sola prueba. Los 40 nombres sin prefijo pasan a `{familia}-{verbo}[-{matiz}]`: 256 eventos, 256 con prefijo, 0 ambiguos. El plan decia 36 y decia `handle-drag-start`; eran 40, y el canon (c25) dice que esos verbos son `pick` y `drop` — `handle-pick` y `handle-drop` ya existian en 10 y 4 componentes. `validateMorfo` cierra la puerta: un `events[].name` que no empiece por su familia ahora lanza. Visto fallar antes con un nombre pelado inyectado. Lo que el renombrado destapo, y va aqui tambien: - La receta del splitter enganchaba `commit-resize`, muerto desde `bd2e40366`. No casaba desde mayo y nadie chillo. Reescrita por FAMILIA, como slider y knob, y `eidos-lint` valida ahora el VALOR de `data-event*` contra el catalogo de morfos — el guard que lo habria cazado en su dia. - La familia `shift` era muda en el canal visual, contra su propia doctrina (c27: el cruce debe percibirse; c34 tipifica el «shift invisible»). Su mapa ya describia la firma que le faltaba y su sonido por defecto es `slide`. Ahora tiene firma direccional: sexto atributo del sello (`data-event-direction`, `forward`|`backward`, por emision) y deslizamiento de 320ms RTL-safe por `:dir()`. Medido: LTR -30px/+30px, RTL los invierte. - El sello de `shift-navigate` pasa del BOTON al `grid` en los cuatro calendarios. Medido: el boton recibia `contact-activate` y 8,5 ms despues —media trama— el `shift-navigate` pisaba la misma ranura y el `press-squeeze` moria sin pintar un fotograma. Una superficie, una ranura (A-36). - 101 contradicciones docs<->morfo adjudicadas con evidencia (git log, docs de decision, el componente vivo). Las docs desfasadas, corregidas; los nueve DEFECTOS de codigo obsoleto quedan abiertos y sin tocar. - `SoundDirection` -> `SoundContour`: era un contorno de tono, no un sentido, y habia tres cosas distintas deletreadas «direction». check en su linea base con 0 errores nuevos por diferencia de conjuntos · docs:check 0/0 · eidos-lint invalid 0 · el censo y las escenas de navegador medidas con raton real y rAF vivo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
/**
* 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.