/** * Morfo vocabulary + coverage consistency check. * * Four checks in one script: * * 1. **data attr enums** (WARN) — walks every morfo's `data` declarations * and reports any attr with `values: string[]` whose set is NOT one of * the canonical vocabularies declared in `CANONICAL_VOCABULARIES`. * * 2. **declared semantic.verb against canon** (FAIL) — every morfo * `events[].semantic.verb` must be in the canon defined in * `sema/verbs.ts` (which mirrors «Diseñando lo que ocurre» cap. 22-29). * This is the doctrinal contract: cross-family selectors and sema * cascades subscribe by family+verb, so a non-canonical verb is a * real semantic drift, not just a naming choice. Fails unless the * `{morfo}:{eventName}` pair is in `EVENT_NAME_ALLOWLIST`. * * 3. **event name shape** (WARN) — checks each `events[].name` against * the convention `{family}-{verb}[-{variant}]` or `{verb}[-{variant}]`. * Names that don't parse are WARN: subscribers grep by name, so drift * here hurts discoverability, but the canonical truth lives in * `semantic.verb` (check #2). The book authorises verb-level variation * (cap. 8 §1: "Algunas diferencias pertenecen al verbo, a la fase, al * intent o a la realización"), so a domain-specific label like * `commit-clear` whose declared verb is `reset` is acceptable. * * 4. **sema coverage** (FAIL + WARN) — per author decision 2026-05-26: * `scope: ['sema']` declares **participation** in sema. The * `expression` field on the morfo declares how that participation * materialises (`pack` / `family-default` / `delegated` / `none`). * The check enforces: * * - **FAIL** when a morfo declares `events[]` but does NOT include * `'sema'` in its `scope[]`. Events without scope:sema is * incoherent — the morfo declared semantic events but refuses to * participate in the sema layer. * - **WARN** when a morfo has `scope:sema + events` but NO sema pack * file (`src/uix/sema/components/{kebab}.ts`) AND NO `expression` * field. Absence of pack must be explicit — either via the pack * file or via `expression` declaring the intent. * - **OK** when: (a) pack file exists, (b) `expression` is set to * `family-default` / `delegated` / `none`, or (c) the morfo has * no events. * * Allowlisted entries are tracked deliberate exceptions, each with a * TODO pointing at where they will be resolved. * * Exit codes: * 0 — verb canon and sema coverage rules satisfied (warnings allowed). * 1 — at least one declared verb drifts from canon AND is not * allowlisted, OR at least one morfo declares events without * scope:sema. */ import { existsSync, readdirSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; import type { Morfo, MorfoPart } from '../src/uix/morfo/types'; import { validateMorfo, CANONICAL_VOCABULARIES } from '../src/uix/morfo/schema'; import { validateEventName, SEMA_VERBS } from '../src/uix/sema/verbs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components'); const SEMA_PACKS_DIR = join(__dirname, '..', 'src', 'uix', 'sema', 'components'); /** * Event names that knowingly diverge from the canonical vocabulary, * tracked as explicit technical debt. Each entry must reference WHERE * the divergence is resolved or formally accepted. * * Add new entries only after a documented design discussion — the point * of this allowlist is to make the divergence visible, not to dodge the * canon. Remove an entry the moment the underlying morfo is updated. */ const EVENT_NAME_ALLOWLIST: Record = { // Empty — Plan B commit 2 resolved `button.commit-action` to // `button.contact-activate` per book cap. 22 §10-11. }; async function loadMorfos(): Promise { const files = readdirSync(MORFOS_DIR).filter( (f) => f.endsWith('.ts') && !f.endsWith('.test.ts') ); const out: Morfo[] = []; for (const f of files) { const url = pathToFileURL(join(MORFOS_DIR, f)).href; const mod = (await import(url)) as Record; for (const v of Object.values(mod)) { if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) { out.push(validateMorfo(v)); } } } return out; } function flatParts(parts: readonly MorfoPart[]): MorfoPart[] { const out: MorfoPart[] = []; for (const p of parts) { out.push(p); if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts)); } return out; } type Classification = | { kind: 'match'; vocabulary: string } | { kind: 'subset'; vocabulary: string; extra: string[] } | { kind: 'superset'; vocabulary: string; missing: string[] } | { kind: 'unknown' }; function classify(values: readonly string[]): Classification { const valueSet = new Set(values); for (const [name, canonical] of Object.entries(CANONICAL_VOCABULARIES)) { const canonicalSet = new Set(canonical); if ( canonicalSet.size === valueSet.size && [...valueSet].every((v) => canonicalSet.has(v)) ) { return { kind: 'match', vocabulary: name }; } // Enum extends a canonical (added values) if ([...canonicalSet].every((v) => valueSet.has(v))) { const extra = [...valueSet].filter((v) => !canonicalSet.has(v)); if (extra.length > 0 && extra.length <= 3) { return { kind: 'subset', vocabulary: name, extra }; } } // Enum shrinks a canonical (removed values) if ([...valueSet].every((v) => canonicalSet.has(v))) { const missing = [...canonicalSet].filter((v) => !valueSet.has(v)); if (missing.length > 0 && missing.length <= 3) { return { kind: 'superset', vocabulary: name, missing }; } } } return { kind: 'unknown' }; } // ── Main ──────────────────────────────────────────────────────────────────── const morfos = await loadMorfos(); console.error( `Scanning ${morfos.length} morfo${morfos.length === 1 ? '' : 's'} for vocabulary divergence...` ); // ── 1. data attr enums (WARN only) ───────────────────────────────────────── type DataFinding = { morfo: string; part: string; attr: string; values: readonly string[]; classification: Classification; }; /** * Attrs whose value sets are per-component by design, not shared vocabulary. * Skipped by the consistency check — Dialog's `saved|cancelled|…` is correctly * different from Toast's `dismissed|auto-timeout|action`. */ const PER_COMPONENT_ATTRS = new Set(['data-last-action']); const dataFindings: DataFinding[] = []; for (const morfo of morfos) { for (const part of flatParts(morfo.parts)) { for (const data of part.data) { if (!data.values) continue; if (PER_COMPONENT_ATTRS.has(data.attr)) continue; const classification = classify(data.values); if (classification.kind !== 'match') { dataFindings.push({ morfo: morfo.kebab, part: part.kebab, attr: data.attr, values: data.values, classification }); } } } } // ── 2. declared semantic.verb canon (FAIL) ───────────────────────────────── type VerbFinding = { morfo: string; eventName: string; declaredFamily: string; declaredVerb: string; allowlisted: boolean; allowlistReason?: string; }; const verbFindings: VerbFinding[] = []; // ── 3. event name shape (WARN) ───────────────────────────────────────────── type NameFinding = { morfo: string; eventName: string; parsedFamily: string | undefined; parsedVerb: string | undefined; }; const nameFindings: NameFinding[] = []; // ── 4. sema coverage (FAIL events-without-scope, WARN no-pack-no-expression) ── type CoverageFailFinding = { morfo: string; eventsCount: number; scope: readonly string[]; }; type CoverageWarnFinding = { morfo: string; eventsCount: number; }; const coverageFails: CoverageFailFinding[] = []; const coverageWarns: CoverageWarnFinding[] = []; // S11d (checkpoint 2026-07-07): pack↔expression coherence. A pack file is the // component's declared refinement — the morfo's `expression` must say `'pack'`. // Any other value contradicts the shipped pack (gradient-picker declared // 'delegated' while owning one — the vocabulary lied); absence leaves the // participation implicit (gradient-builder, splitter). type CoherenceFailFinding = { morfo: string; expression: string }; type CoherenceWarnFinding = { morfo: string; eventsCount: number }; const coherenceFails: CoherenceFailFinding[] = []; const coherenceWarns: CoherenceWarnFinding[] = []; // S10 (checkpoint 2026-07-07): `data-state` attrs are enums by nature — a // declaration without `values` can't be validated by the compiler and eidos // can't select per value with any guarantee. type StateEnumFinding = { morfo: string; part: string }; const stateEnumWarns: StateEnumFinding[] = []; function hasSemaPack(kebab: string): boolean { return existsSync(join(SEMA_PACKS_DIR, `${kebab}.ts`)); } for (const morfo of morfos) { const eventsCount = morfo.events?.length ?? 0; const hasPack = hasSemaPack(morfo.kebab); const hasExpression = typeof morfo.expression === 'string' && morfo.expression.length > 0; // S11d — evaluated regardless of event count (a pack over 0 events is // dead weight and must surface too). if (hasPack && hasExpression && morfo.expression !== 'pack') { coherenceFails.push({ morfo: morfo.kebab, expression: morfo.expression as string }); } else if (hasPack && !hasExpression) { coherenceWarns.push({ morfo: morfo.kebab, eventsCount }); } if (eventsCount === 0) continue; // no events → coverage not applicable const hasSemaScope = morfo.scope.includes('sema'); if (!hasSemaScope) { coverageFails.push({ morfo: morfo.kebab, eventsCount, scope: morfo.scope }); continue; } if (!hasPack && !hasExpression) { coverageWarns.push({ morfo: morfo.kebab, eventsCount }); } } for (const morfo of morfos) { for (const part of flatParts(morfo.parts)) { for (const d of part.data ?? []) { if (d.attr === 'data-state' && !('values' in d && Array.isArray(d.values))) { stateEnumWarns.push({ morfo: morfo.kebab, part: part.kebab }); } } } } for (const morfo of morfos) { for (const evt of morfo.events ?? []) { const declaredFamily = evt.semantic?.family; const declaredVerb = evt.semantic?.verb; // Check #2 — declared verb must be in the family's canon. if (declaredFamily && declaredVerb) { const familyVerbs = SEMA_VERBS[declaredFamily as keyof typeof SEMA_VERBS] as | readonly string[] | undefined; if (familyVerbs && !familyVerbs.includes(declaredVerb)) { const allowlistKey = `${morfo.kebab}:${evt.name}`; const allowlistReason = EVENT_NAME_ALLOWLIST[allowlistKey]; verbFindings.push({ morfo: morfo.kebab, eventName: evt.name, declaredFamily, declaredVerb, allowlisted: allowlistReason !== undefined, allowlistReason }); } } // Check #3 — event name shape (WARN only). const parsed = validateEventName(evt.name); if (!parsed.matchesCanonical) { nameFindings.push({ morfo: morfo.kebab, eventName: evt.name, parsedFamily: parsed.family, parsedVerb: parsed.verb }); } } } // ── Reporting ────────────────────────────────────────────────────────────── let hadHardErrors = false; if (dataFindings.length > 0) { console.log(''); console.log( `Found ${dataFindings.length} divergent data attr enum${dataFindings.length === 1 ? '' : 's'} (warn):` ); console.log(''); for (const f of dataFindings) { const loc = `${f.morfo}.${f.part}.${f.attr}`; const values = `[${f.values.join(', ')}]`; switch (f.classification.kind) { case 'subset': console.log( `WARN ${loc} ${values} extends "${f.classification.vocabulary}" with: ${f.classification.extra.join(', ')}` ); break; case 'superset': console.log( `WARN ${loc} ${values} shrinks "${f.classification.vocabulary}" missing: ${f.classification.missing.join(', ')}` ); break; case 'unknown': console.log( `WARN ${loc} ${values} no canonical vocabulary matches. Consider if this should use one of: ${Object.keys(CANONICAL_VOCABULARIES).join(', ')}` ); break; } } console.log(''); console.log( `These are WARNINGS — novel vocabularies may be legitimate. If the value set SHOULD match a canonical vocabulary, align it. If the component introduces a NEW canonical vocabulary, add it to CANONICAL_VOCABULARIES in src/uix/morfo/schema.ts.` ); } if (verbFindings.length > 0) { console.log(''); const drift = verbFindings.filter((f) => !f.allowlisted); const allowlisted = verbFindings.filter((f) => f.allowlisted); if (allowlisted.length > 0) { console.log( `Found ${allowlisted.length} allowlisted declared-verb divergence${allowlisted.length === 1 ? '' : 's'}:` ); console.log(''); for (const f of allowlisted) { console.log( `ALLOW ${f.morfo}.${f.eventName} family=${f.declaredFamily} verb=${f.declaredVerb} → ${f.allowlistReason}` ); } } if (drift.length > 0) { console.log(''); console.log( `Found ${drift.length} non-canonical declared verb${drift.length === 1 ? '' : 's'} (FAIL):` ); console.log(''); for (const f of drift) { console.log( `FAIL ${f.morfo}.${f.eventName} family=${f.declaredFamily} verb=${f.declaredVerb}` ); console.log( ` Verb "${f.declaredVerb}" is not in SEMA_VERBS.${f.declaredFamily} (see src/uix/sema/verbs.ts).` ); } console.log(''); console.log( `Declared semantic.verb must be in the canon in sema/verbs.ts (which mirrors «Diseñando lo que ocurre» cap. 22-29). If a new verb is genuinely needed in the canon, add it after a design discussion and ensure at least two components share it. If the divergence is deliberate and time-bounded, add it to EVENT_NAME_ALLOWLIST in this script with a TODO.` ); hadHardErrors = true; } } if (nameFindings.length > 0) { console.log(''); console.log( `Found ${nameFindings.length} event name${nameFindings.length === 1 ? '' : 's'} that don't follow the canonical shape (warn):` ); console.log(''); for (const f of nameFindings) { console.log(`WARN ${f.morfo}.${f.eventName} does not parse to {family}-{verb}[-{variant}]`); } console.log(''); console.log( `These are WARNINGS — the declared semantic.verb is canonical, but the event name doesn't surface the family+verb (which makes \`[data-event^="..."]\` selectors and verb-grouped sema cascades harder to author). Consider renaming to \`{family}-{verb}-{domain-label}\`.` ); } if (coverageFails.length > 0) { console.log(''); console.log( `Found ${coverageFails.length} morfo${coverageFails.length === 1 ? '' : 's'} with events but no \`scope: ['sema']\` (FAIL):` ); console.log(''); for (const f of coverageFails) { console.log( `FAIL ${f.morfo} ${f.eventsCount} event${f.eventsCount === 1 ? '' : 's'} scope=[${f.scope.join(', ')}]` ); console.log( ` Morfos that declare events MUST include 'sema' in scope[]. Events without sema scope is incoherent — the morfo declared semantic events but refuses to participate in the sema layer.` ); } hadHardErrors = true; } if (coverageWarns.length > 0) { console.log(''); console.log( `Found ${coverageWarns.length} morfo${coverageWarns.length === 1 ? '' : 's'} with scope:sema + events but no pack and no expression field (warn):` ); console.log(''); for (const f of coverageWarns) { console.log( `WARN ${f.morfo} ${f.eventsCount} event${f.eventsCount === 1 ? '' : 's'}, no pack file, no expression declared` ); } console.log(''); console.log( `These are WARNINGS — the absence of a pack is ambiguous. Either create a sema pack at src/uix/sema/components/{kebab}.ts to refine the perceptual signature, OR declare \`expression: 'family-default' | 'delegated' | 'none'\` on the morfo to make the absence explicit. Per author decision 2026-05-26, scope:sema declares participation, NOT obligation to ship a pack — expression makes the choice visible.` ); } if (coherenceFails.length > 0) { console.log(''); console.log( `Found ${coherenceFails.length} morfo${coherenceFails.length === 1 ? '' : 's'} whose expression contradicts their shipped sema pack (FAIL):` ); console.log(''); for (const f of coherenceFails) { console.log(`FAIL ${f.morfo} expression='${f.expression}' but src/uix/sema/components/${f.morfo}.ts exists`); } console.log(''); console.log( `A pack file IS the component's refinement — the morfo must declare \`expression: 'pack'\`. Either fix the expression or delete the pack (checkpoint verdict S11d, 2026-07-07; participation doctrine in docs/architecture/sema.md).` ); hadHardErrors = true; } if (coherenceWarns.length > 0) { console.log(''); console.log( `Found ${coherenceWarns.length} morfo${coherenceWarns.length === 1 ? '' : 's'} with a sema pack but no expression field (warn):` ); console.log(''); for (const f of coherenceWarns) { console.log(`WARN ${f.morfo} pack file exists, expression undeclared — declare \`expression: 'pack'\``); } } if (stateEnumWarns.length > 0) { console.log(''); console.log( `Found ${stateEnumWarns.length} \`data-state\` declaration${stateEnumWarns.length === 1 ? '' : 's'} without a \`values\` enum (warn):` ); console.log(''); for (const f of stateEnumWarns) { console.log( `WARN ${f.morfo}.${f.part} data-state without values[] — the compiler can't validate and eidos can't select per value (checkpoint verdict S10, 2026-07-07)` ); } } if ( dataFindings.length === 0 && verbFindings.length === 0 && nameFindings.length === 0 && coverageFails.length === 0 && coverageWarns.length === 0 && coherenceFails.length === 0 && coherenceWarns.length === 0 && stateEnumWarns.length === 0 ) { console.log( 'All enum values match canonical vocabularies, all event names are canonical, and sema coverage is explicit.' ); } process.exit(hadHardErrors ? 1 : 0);