/** * 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([ '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(); 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(); const partByKebab = new Map(); for (const part of flat) { kebabs.add(part.kebab); partByKebab.set(part.kebab, part); } const prewriteDLAByPart = new Map>(); 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(); 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(); 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` ); } } }