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.
323 lines
11 KiB
323 lines
11 KiB
/**
|
|
* 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<string, string> = {
|
|
// Empty — Plan B commit 2 resolved `button.commit-action` to
|
|
// `button.contact-activate` per book cap. 22 §10-11.
|
|
};
|
|
|
|
async function loadMorfos(): Promise<Morfo[]> {
|
|
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<string, unknown>;
|
|
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);
|