/** * Morfo vocabulary consistency check. * * Three 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. * * Allowlisted entries are tracked deliberate exceptions, each with a * TODO pointing at where they will be resolved. * * Exit codes: * 0 — every declared verb is canonical (or allowlisted). * 1 — at least one declared verb drifts from canon AND is not allowlisted. */ import { 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'); /** * 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 = { // TODO(Button): `commit-action` uses verb `action`, not in canon. // Per book cap. 22 §11 "el intent fuerte no debería vivir en el contacto, // sino en la señal o consecuencia posterior." Deciding between // (a) `contact-activate` + intent stays as visual chip variant only // (b) keep `commit` family but pick a canonical verb (e.g. acknowledge) // is an API decision that needs design sign-off — see Plan B commit 2. 'button:commit-action': 'Pending Button intent/family design decision (Plan B commit 2)' }; 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[] = []; 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 (dataFindings.length === 0 && verbFindings.length === 0 && nameFindings.length === 0) { console.log( 'All enum values match canonical vocabularies and all event names are canonical.' ); } process.exit(hadHardErrors ? 1 : 0);