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.
237 lines
7.5 KiB
237 lines
7.5 KiB
|
6 months ago
|
/**
|
||
|
|
* 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`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|