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/src/uix/sema/validation.ts

237 lines
7.5 KiB

/**
* Sema invariants validator.
*
* Sema es autónoma. Los invariantes internos (nombres únicos, eventos
* canónicos) se validan sin morfo. Cuando se pasa morfo como contexto
* opcional, se añaden los cross-checks (parts, data[], states[],
* data-last-action.values[]).
*
* El validador recibe el morfo por su **forma estructural** (el tipo
* `MorfoContext` de abajo), no por su tipo `Morfo`. Así sema sigue sin
* depender del módulo morfo a nivel de tipos: cualquier valor que tenga
* `kebab` + `parts` servirá. En la práctica el llamador pasa un `Morfo`
* y TypeScript lo acepta por compatibilidad estructural.
*
* Importante: `validateSema()` asume que el `spec` ya está tipado por
* TypeScript (`as const satisfies SemaSpec`). A diferencia de
* `validateMorfo()`, no hace decode completo del shape runtime; valida
* invariantes semánticos y referencias cruzadas sobre entrada tipada.
*/
import type { SemaSpec, SemaEventLabel } from './types';
// Estructura mínima que el validador necesita del morfo para hacer los
// cross-checks. Redeclarada aquí (no importada de morfo) para que sema
// no tenga dependencia de tipos con morfo.
interface MorfoPartLike {
kebab: string;
states?: readonly string[];
data: readonly { attr: string; values?: readonly string[] }[];
parts?: readonly MorfoPartLike[];
}
interface MorfoContext {
kebab: string;
parts: readonly MorfoPartLike[];
}
/** Thrown when a sema invariant fails. */
export class SemaInvariantError extends Error {
constructor(message: string) {
super(message);
this.name = 'SemaInvariantError';
}
}
/** Los 22 eventos canónicos. Debe mantenerse en sync con `SemaEventLabel`. */
const SEMA_EVENT_LABELS = new Set<SemaEventLabel>([
'contact-neutral',
'contact-threat',
'contact-risk',
'contact-affirm',
'contact-fulfill',
'commit-neutral',
'commit-threat',
'commit-risk',
'commit-affirm',
'commit-fulfill',
'alert-neutral',
'alert-threat',
'alert-risk',
'alert-affirm',
'alert-fulfill',
'handle-neutral',
'handle-threat',
'handle-risk',
'handle-affirm',
'handle-fulfill',
'emerge',
'sustain'
]);
function flattenParts(parts: readonly MorfoPartLike[]): MorfoPartLike[] {
const out: MorfoPartLike[] = [];
for (const p of parts) {
out.push(p);
if (p.parts && p.parts.length > 0) out.push(...flattenParts(p.parts));
}
return out;
}
/**
* Valida los invariantes de un `SemaSpec`.
*
* **Invariantes internos** (siempre):
* 1. `name` único dentro del spec.
* 2. `event` pertenece al vocabulario canónico.
*
* **Invariantes cross-morfo** (sólo si se pasa `morfo`):
* 3. `spec.kebab === morfo.kebab`.
* 4. `action.target.target` resuelve a una parte del morfo.
* 5. `prewrite[].part.target` resuelve.
* 6. `prewrite[].attr` existe en `data[]` del part destino.
* 7. `prewrite[].value` ∈ `values[]` si el attr es enumerable.
* 8. `commits.part.target` resuelve.
* 9. `commits.value` ∈ `states[]` si `commits.attr === 'data-state'`.
* 10. `data-last-action.values[]` == unión de prewrites que escriben a ese
* attr (ambas direcciones).
* 11. `sustains[].target.target` y `sustains[].activeWhen.part.target` resuelven.
*/
export function validateSema(spec: SemaSpec, morfo?: MorfoContext): void {
// 1. Nombres únicos (siempre).
const actionNames = new Set<string>();
for (const action of spec.actions) {
if (actionNames.has(action.name)) {
throw new SemaInvariantError(
`sema: duplicate action name "${action.name}" in "${spec.kebab}"`
);
}
actionNames.add(action.name);
}
// 2. Eventos canónicos (siempre).
for (const action of spec.actions) {
if (!SEMA_EVENT_LABELS.has(action.event)) {
throw new SemaInvariantError(
`sema.actions["${action.name}"]: event "${action.event}" is not a valid SemaEventLabel`
);
}
}
// Resto depende de tener contexto de morfo.
if (!morfo) return;
// 3. kebab coincide.
if (morfo.kebab !== spec.kebab) {
throw new SemaInvariantError(
`sema: spec.kebab "${spec.kebab}" does not match morfo.kebab "${morfo.kebab}"`
);
}
const flat = flattenParts(morfo.parts);
const kebabs = new Set<string>();
const partByKebab = new Map<string, MorfoPartLike>();
for (const part of flat) {
kebabs.add(part.kebab);
partByKebab.set(part.kebab, part);
}
const prewriteDLAByPart = new Map<string, Set<string>>();
for (const action of spec.actions) {
const ctx = `sema.actions["${action.name}"]`;
// 4. target.
if (!kebabs.has(action.target.target)) {
throw new SemaInvariantError(
`${ctx}: target "${action.target.target}" does not match any part in "${morfo.kebab}"`
);
}
// 5-7. prewrites.
for (const pw of action.prewrite ?? []) {
const pwCtx = `${ctx}.prewrite[${pw.attr}]`;
if (!kebabs.has(pw.part.target)) {
throw new SemaInvariantError(
`${pwCtx}: part "${pw.part.target}" does not match any part in "${morfo.kebab}"`
);
}
const targetPart = partByKebab.get(pw.part.target);
if (!targetPart) continue;
const dataEntry = targetPart.data.find((d) => d.attr === pw.attr);
if (!dataEntry) {
throw new SemaInvariantError(
`${pwCtx}: attr "${pw.attr}" not declared in part "${pw.part.target}"'s data[] (declare it before referencing)`
);
}
if (dataEntry.values && !dataEntry.values.includes(pw.value)) {
throw new SemaInvariantError(
`${pwCtx}: value "${pw.value}" not in declared values [${dataEntry.values.join(', ')}]`
);
}
if (pw.attr === 'data-last-action') {
const s = prewriteDLAByPart.get(pw.part.target) ?? new Set<string>();
s.add(pw.value);
prewriteDLAByPart.set(pw.part.target, s);
}
}
// 8-9. commits.
if (action.commits) {
const cCtx = `${ctx}.commits`;
if (!kebabs.has(action.commits.part.target)) {
throw new SemaInvariantError(
`${cCtx}: part "${action.commits.part.target}" does not match any part in "${morfo.kebab}"`
);
}
if (action.commits.attr === 'data-state') {
const targetPart = partByKebab.get(action.commits.part.target);
const states = targetPart?.states ?? [];
if (!states.includes(action.commits.value)) {
throw new SemaInvariantError(
`${cCtx}: value "${action.commits.value}" not in states of "${action.commits.part.target}" (declared: ${states.join(', ') || '∅'})`
);
}
}
}
}
// 10. data-last-action.values[] == unión de prewrites que lo escriben.
for (const part of flat) {
const partKebab = part.kebab;
const dla = part.data.find((d) => d.attr === 'data-last-action');
if (!dla?.values) continue;
const written = prewriteDLAByPart.get(partKebab) ?? new Set<string>();
const declared = new Set(dla.values);
for (const v of written) {
if (!declared.has(v)) {
throw new SemaInvariantError(
`sema: prewrite writes "${v}" to data-last-action on "${partKebab}", but the part's values[] does not include it (declared: ${[...declared].join(', ')})`
);
}
}
for (const v of declared) {
if (!written.has(v)) {
throw new SemaInvariantError(
`sema: part "${partKebab}" declares data-last-action value "${v}" but no sema action prewrites it — values[] must equal the union of prewrites`
);
}
}
}
// 11. sustains.
for (const sustain of spec.sustains ?? []) {
const ctx = `sema.sustains["${sustain.name}"]`;
if (!kebabs.has(sustain.target.target)) {
throw new SemaInvariantError(
`${ctx}: target "${sustain.target.target}" does not match any part`
);
}
if (!kebabs.has(sustain.activeWhen.part.target)) {
throw new SemaInvariantError(
`${ctx}: activeWhen.part "${sustain.activeWhen.part.target}" does not match any part`
);
}
}
}

Powered by TurnKey Linux.