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.
901 lines
34 KiB
901 lines
34 KiB
/**
|
|
* Component completion audit.
|
|
*
|
|
* Walks every UIX component (morfo + eidos wrapper + recipe CSS + demo)
|
|
* and scores it against the rules in
|
|
* `src/uix/COMPONENT_COMPLETION_CHECKLIST.md`.
|
|
*
|
|
* Output: `tmp/component-audit.md` — markdown report with summary table
|
|
* + per-component findings.
|
|
*
|
|
* Usage:
|
|
* node --import tsx/esm scripts/component-audit.ts
|
|
* node --import tsx/esm scripts/component-audit.ts --only rating-group,calendar
|
|
* node --import tsx/esm scripts/component-audit.ts --severity error
|
|
*
|
|
* Approach: text/regex parsing of source files. Doesn't dynamically import
|
|
* morfos (avoids full TS resolution + circular dep risk). The morfo files
|
|
* are declarative `as const satisfies Morfo` exports, so regex catches
|
|
* the structural facts we need. Cross-validation against canonical
|
|
* vocabularies via static imports of `INTENTS`, `SEMA_FAMILIES`,
|
|
* `SEMA_VERBS`, `ARCHETYPE_VOCABULARY`.
|
|
*/
|
|
|
|
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { INTENTS } from '../src/uix/intent.ts';
|
|
import { SEMA_FAMILIES } from '../src/uix/sema/event.ts';
|
|
import { SEMA_VERBS } from '../src/uix/sema/verbs.ts';
|
|
import { ARCHETYPE_VOCABULARY } from '../src/uix/morfo/types.ts';
|
|
|
|
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const REPO = join(__dirname, '..');
|
|
const MORFO_DIR = join(REPO, 'src/uix/morfo/components');
|
|
const EIDOS_DIR = join(REPO, 'src/uix/eidos/components');
|
|
const DEMO_DIR = join(REPO, 'web/routes/uix/components');
|
|
const OUT = join(REPO, 'tmp/component-audit.md');
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
type Severity = 'error' | 'warn' | 'info';
|
|
|
|
interface CheckResult {
|
|
rule: string;
|
|
severity: Severity;
|
|
passed: boolean;
|
|
detail?: string;
|
|
}
|
|
|
|
interface ComponentReport {
|
|
component: string;
|
|
kebab: string;
|
|
exists: { morfo: boolean; eidos: boolean; demo: boolean };
|
|
interactive: boolean;
|
|
passive: boolean;
|
|
checks: CheckResult[];
|
|
}
|
|
|
|
// ─── CLI flags ──────────────────────────────────────────────────────────────
|
|
|
|
const args = process.argv.slice(2);
|
|
const onlyArg = args.find((a) => a.startsWith('--only'));
|
|
const onlyList = onlyArg
|
|
? (onlyArg.includes('=') ? onlyArg.split('=')[1] : args[args.indexOf(onlyArg) + 1])
|
|
?.split(',')
|
|
.map((s) => s.trim())
|
|
: undefined;
|
|
|
|
const severityArg = args.find((a) => a.startsWith('--severity'));
|
|
const minSeverity = (severityArg
|
|
? severityArg.includes('=')
|
|
? severityArg.split('=')[1]
|
|
: args[args.indexOf(severityArg) + 1]
|
|
: undefined) as Severity | undefined;
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function tryRead(path: string): string | null {
|
|
try {
|
|
return readFileSync(path, 'utf8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function pass(rule: string, severity: Severity, detail?: string): CheckResult {
|
|
return { rule, severity, passed: true, ...(detail ? { detail } : {}) };
|
|
}
|
|
|
|
function fail(rule: string, severity: Severity, detail: string): CheckResult {
|
|
return { rule, severity, passed: false, detail };
|
|
}
|
|
|
|
// Match `name: '...'` or `name: "..."` inside morfo event blocks.
|
|
const EVENT_NAME_RE = /name:\s*['"]([\w-]+)['"]/g;
|
|
// Match family field
|
|
const EVENT_FAMILY_RE = /family:\s*['"]([\w-]+)['"]/g;
|
|
// Match verb field
|
|
const EVENT_VERB_RE = /verb:\s*['"]([\w-]+)['"]/g;
|
|
// Sequence field
|
|
const EVENT_SEQUENCE_RE = /sequence:\s*['"](pre|coincident|post)['"]/g;
|
|
// Part kebab
|
|
const PART_KEBAB_RE = /kebab:\s*['"]([\w-]+)['"]/g;
|
|
// Archetype
|
|
const PART_ARCH_RE = /archetype:\s*['"]([\w-]+)['"]/g;
|
|
// keyboard keys
|
|
const KEYBOARD_KEY_RE = /key:\s*['"]([^'"]+)['"]/g;
|
|
|
|
const VALENCED_FAMILIES = new Set(['commit', 'signal']);
|
|
const ALL_VERBS = new Set<string>(
|
|
(Object.values(SEMA_VERBS as Record<string, readonly string[]>) as readonly string[][]).flat()
|
|
);
|
|
const VERBS_BY_FAMILY = SEMA_VERBS as Record<string, readonly string[]>;
|
|
const ARCHETYPE_SET = new Set(ARCHETYPE_VOCABULARY);
|
|
const INTENT_SET = new Set(INTENTS);
|
|
|
|
// ─── Component discovery ────────────────────────────────────────────────────
|
|
|
|
function listComponents(): { kebab: string; morfoPath: string }[] {
|
|
const entries = readdirSync(MORFO_DIR);
|
|
return entries
|
|
.filter((e) => e.endsWith('.ts') && !e.endsWith('.test.ts'))
|
|
.map((e) => ({ kebab: e.replace(/\.ts$/, ''), morfoPath: join(MORFO_DIR, e) }))
|
|
.filter((c) => (onlyList ? onlyList.includes(c.kebab) : true))
|
|
.sort((a, b) => a.kebab.localeCompare(b.kebab));
|
|
}
|
|
|
|
// ─── Checks ─────────────────────────────────────────────────────────────────
|
|
|
|
function checkMorfo(kebab: string, src: string, info: ComponentReport): CheckResult[] {
|
|
const out: CheckResult[] = [];
|
|
|
|
// A-1.1: Morfo const export
|
|
const morfoVarMatch = src.match(/export const (\w+Morfo)\b/);
|
|
if (!morfoVarMatch) {
|
|
out.push(fail('A-1.1', 'error', 'No `export const {Name}Morfo` found'));
|
|
return out;
|
|
}
|
|
out.push(pass('A-1.1', 'error'));
|
|
|
|
// A-1.2: name + kebab + scope
|
|
const hasName = /\bname:\s*['"][\w-]+['"]/.test(src);
|
|
const hasKebab = /\bkebab:\s*['"][\w-]+['"]/.test(src);
|
|
const hasScope = /\bscope:\s*\[/.test(src);
|
|
if (hasName && hasKebab && hasScope) out.push(pass('A-1.2', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'A-1.2',
|
|
'error',
|
|
`Missing: ${[!hasName && 'name', !hasKebab && 'kebab', !hasScope && 'scope'].filter(Boolean).join(', ')}`
|
|
)
|
|
);
|
|
|
|
// A-1.3: morfo.texts.label is a valid component-scoped idlangref.
|
|
// Old `translations: { label: { es, en } }` is illegal — caught by tsc, but
|
|
// flag it here too for explicit migration error messaging.
|
|
const hasLegacyTranslations = /^\ttranslations:\s*\{/m.test(src);
|
|
if (hasLegacyTranslations) {
|
|
out.push(
|
|
fail(
|
|
'A-1.3',
|
|
'error',
|
|
'Legacy `translations:` field present. Migrate to `texts:` idlangrefs (see translations:check).'
|
|
)
|
|
);
|
|
} else {
|
|
const labelRefMatch = src.match(/^\t\ttexts:[\s\S]*?\blabel:\s*['"`](#\?[^'"`]+)['"`]/m);
|
|
// Use compact form when texts block exists; the indentation is one tab
|
|
// deeper than `\t`.
|
|
const compactLabel = src.match(
|
|
/^\ttexts:\s*\{[\s\S]*?\blabel:\s*['"`](#\?[^'"`]+)['"`]/m
|
|
);
|
|
const hit = labelRefMatch ?? compactLabel;
|
|
if (hit) {
|
|
const ref = hit[1];
|
|
const validShape = /^#\?(?:components\.[a-z][a-z0-9-]*|common)\..+\|.+$/.test(ref);
|
|
if (validShape) out.push(pass('A-1.3', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'A-1.3',
|
|
'error',
|
|
`texts.label idlangref malformed: ${ref}. Expected '#?components.{kebab}.label|Fallback' or '#?common.…|Fallback'.`
|
|
)
|
|
);
|
|
} else {
|
|
// Pure visual primitives (scope=['eidos'] only, no events, no
|
|
// interactive surface) don't own text slots — the label lives on
|
|
// the consumer (e.g. the button hosting the icon). Skip A-1.3 for
|
|
// them.
|
|
const scopeMatch = src.match(/scope:\s*\[([^\]]+)\]/);
|
|
const scopes = scopeMatch
|
|
? scopeMatch[1].split(',').map((s) => s.trim().replace(/['"]/g, ''))
|
|
: [];
|
|
const isEidosOnlyLeaf =
|
|
scopes.length === 1 && scopes[0] === 'eidos' && !/events:\s*\[/.test(src);
|
|
if (isEidosOnlyLeaf) {
|
|
out.push(pass('A-1.3', 'error', 'eidos-only passive primitive — no text slot'));
|
|
} else {
|
|
out.push(
|
|
fail(
|
|
'A-1.3',
|
|
'error',
|
|
'No `texts.label` declared. Add `texts: { label: \"#?components.{kebab}.label|Fallback\" }` and ship the entry in src/uix/langs/components/{kebab}.ts.'
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A-1.4: apg URL for interactive components
|
|
const hasApg = /\bapg:\s*['"]https?:/.test(src);
|
|
if (info.interactive) {
|
|
if (hasApg) out.push(pass('A-1.4', 'warn'));
|
|
else out.push(fail('A-1.4', 'warn', 'No apg URL declared for interactive component'));
|
|
}
|
|
|
|
// A-2.1: at least one Provider part — kebab='provider'. Archetype can vary
|
|
// (icon uses 'image', viewport uses 'provider', etc.) so don't enforce that.
|
|
const hasProvider = /kebab:\s*['"]provider['"]/m.test(src);
|
|
if (hasProvider) out.push(pass('A-2.1', 'error'));
|
|
else out.push(fail('A-2.1', 'error', 'No part with kebab: "provider" found'));
|
|
|
|
// A-2.5: archetypes ∈ ARCHETYPE_VOCABULARY
|
|
const archetypes = [...src.matchAll(PART_ARCH_RE)].map((m) => m[1]);
|
|
const invalidArchs = archetypes.filter((a) => !ARCHETYPE_SET.has(a as never));
|
|
if (invalidArchs.length === 0) out.push(pass('A-2.5', 'error'));
|
|
else out.push(fail('A-2.5', 'error', `Invented archetypes: ${[...new Set(invalidArchs)].join(', ')}`));
|
|
|
|
// A-3.1: events.length >= 1 if interactive — only count EVENT blocks, not part names
|
|
const eventBlocks = extractEventBlocks(src);
|
|
const eventNames = eventBlocks
|
|
.map((b) => b.match(/name:\s*['"]([\w-]+)['"]/)?.[1])
|
|
.filter((n): n is string => Boolean(n));
|
|
const declaredEvents = eventBlocks.length;
|
|
if (info.interactive && declaredEvents === 0) {
|
|
out.push(fail('A-3.1', 'error', 'Interactive component has 0 events'));
|
|
} else {
|
|
out.push(pass('A-3.1', 'error', `${declaredEvents} events declared`));
|
|
}
|
|
|
|
// A-3.3 + A-3.4: family + verb canonical — only inside event blocks
|
|
const families = eventBlocks
|
|
.map((b) => b.match(/family:\s*['"]([\w-]+)['"]/)?.[1])
|
|
.filter((f): f is string => Boolean(f));
|
|
const verbs = eventBlocks
|
|
.map((b) => b.match(/verb:\s*['"]([\w-]+)['"]/)?.[1])
|
|
.filter((v): v is string => Boolean(v));
|
|
const invalidFamilies = families.filter((f) => !SEMA_FAMILIES.includes(f as never));
|
|
if (invalidFamilies.length === 0) out.push(pass('A-3.3', 'error'));
|
|
else out.push(fail('A-3.3', 'error', `Invented families: ${[...new Set(invalidFamilies)].join(', ')}`));
|
|
|
|
const invalidVerbs = verbs.filter((v) => !ALL_VERBS.has(v));
|
|
if (invalidVerbs.length === 0) out.push(pass('A-3.4', 'error'));
|
|
else out.push(fail('A-3.4', 'error', `Verbs not in SEMA_VERBS: ${[...new Set(invalidVerbs)].join(', ')}`));
|
|
|
|
// Cross-family verb integrity (verb must belong to declared family in same event)
|
|
const familyVerbMismatches: string[] = [];
|
|
for (const block of eventBlocks) {
|
|
const fm = block.match(/family:\s*['"]([\w-]+)['"]/)?.[1];
|
|
const vm = block.match(/verb:\s*['"]([\w-]+)['"]/)?.[1];
|
|
if (fm && vm && VERBS_BY_FAMILY[fm] && !VERBS_BY_FAMILY[fm].includes(vm)) {
|
|
familyVerbMismatches.push(`${fm}.${vm}`);
|
|
}
|
|
}
|
|
if (familyVerbMismatches.length === 0) out.push(pass('A-3.4b', 'warn'));
|
|
else out.push(fail('A-3.4b', 'warn', `Verb/family mismatch: ${familyVerbMismatches.join(', ')}`));
|
|
|
|
// A-3.5: sequence declared — only inside event blocks
|
|
const sequences = eventBlocks
|
|
.map((b) => b.match(/sequence:\s*['"](pre|coincident|post)['"]/)?.[1])
|
|
.filter((s): s is string => Boolean(s));
|
|
if (info.interactive && declaredEvents > 0) {
|
|
if (sequences.length === declaredEvents) {
|
|
out.push(pass('A-3.5', 'warn'));
|
|
} else {
|
|
out.push(
|
|
fail(
|
|
'A-3.5',
|
|
'warn',
|
|
`${declaredEvents} events, ${sequences.length} sequences declared (some events missing sequence)`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// A-3.6: event name follows {verb}-{x} or {family}-{verb} pattern
|
|
const badNames = eventNames.filter((n) => {
|
|
if (!n.includes('-')) return true;
|
|
const [head] = n.split('-');
|
|
return !ALL_VERBS.has(head) && !SEMA_FAMILIES.includes(head as never);
|
|
});
|
|
if (badNames.length === 0) out.push(pass('A-3.6', 'warn'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'A-3.6',
|
|
'warn',
|
|
`Event names not matching {verb}-X or {family}-X: ${badNames.join(', ')}`
|
|
)
|
|
);
|
|
|
|
// A-3.7: event/keyboard coverage heuristic.
|
|
//
|
|
// Per the COMPONENT_GUIDE: "every distinct keyboard action that mutates
|
|
// state has a corresponding semantic event. Pure focus moves don't need
|
|
// an event." So we COUNT only the actions that mutate state — arrow
|
|
// navigation, page/month/year shifts, home/end inside a grid are pure
|
|
// focus moves and must not inflate the ratio.
|
|
const allKeyboardActions = [...src.matchAll(/action:\s*['"]([\w-]+)['"]/g)].map((m) => m[1]);
|
|
const focusMoveActions = new Set([
|
|
// Calendar / date grid navigation
|
|
'next-day',
|
|
'prev-day',
|
|
'next-week',
|
|
'prev-week',
|
|
'next-month',
|
|
'prev-month',
|
|
'next-year',
|
|
'prev-year',
|
|
'first-day-of-week',
|
|
'last-day-of-week',
|
|
// Generic listbox / menu / tablist navigation
|
|
'next-item',
|
|
'prev-item',
|
|
'first-item',
|
|
'last-item',
|
|
'next-tab',
|
|
'prev-tab',
|
|
'first-tab',
|
|
'last-tab',
|
|
// Generic focus moves
|
|
'focus-next',
|
|
'focus-prev',
|
|
'focus-first',
|
|
'focus-last',
|
|
'focus-up',
|
|
'focus-down',
|
|
'focus-left',
|
|
'focus-right',
|
|
// Popover/dialog open/close delegated to the containing overlay layer:
|
|
// the surrounding popover / dialog morfo already declares the
|
|
// emerge events. A combobox / select / menu that hosts Escape →
|
|
// close shouldn't be flagged for under-declaration when the
|
|
// overlay layer owns the sema event.
|
|
'open',
|
|
'close',
|
|
'dismiss',
|
|
'toggle'
|
|
]);
|
|
const mutatingKeyboardActions = allKeyboardActions.filter((a) => !focusMoveActions.has(a));
|
|
const keys = [...src.matchAll(KEYBOARD_KEY_RE)].length;
|
|
const mutatingKeyCount = mutatingKeyboardActions.length;
|
|
if (info.interactive) {
|
|
// Only count MUTATING actions toward the ratio. A calendar with 10
|
|
// keys / 2 mutating (Enter, Space) / 1 commit-select event passes
|
|
// because Enter and Space both map to the same `select` action.
|
|
if (
|
|
mutatingKeyCount >= 4 &&
|
|
declaredEvents > 0 &&
|
|
mutatingKeyCount >= declaredEvents * 2
|
|
) {
|
|
out.push(
|
|
fail(
|
|
'A-3.7',
|
|
'error',
|
|
`${mutatingKeyCount} mutating keyboard actions but only ${declaredEvents} semantic events — likely under-declared (total keys ${keys})`
|
|
)
|
|
);
|
|
} else {
|
|
out.push(
|
|
pass(
|
|
'A-3.7',
|
|
'error',
|
|
`${keys} keys (${mutatingKeyCount} mutating) / ${declaredEvents} events`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// A-3.10: intent valenced families — only inside event blocks
|
|
const intentMatches = eventBlocks
|
|
.map((b) => b.match(/intent:\s*['"]([\w-]+)['"]/)?.[1])
|
|
.filter((i): i is string => Boolean(i));
|
|
const invalidIntents = intentMatches.filter((i) => !INTENT_SET.has(i as never));
|
|
if (invalidIntents.length === 0) out.push(pass('A-3.10', 'warn'));
|
|
else out.push(fail('A-3.10', 'warn', `Invalid intents: ${[...new Set(invalidIntents)].join(', ')}`));
|
|
|
|
// A-4.4: keyboard key normalization
|
|
const allKeys = [...src.matchAll(KEYBOARD_KEY_RE)].map((m) => m[1]);
|
|
const badKeys = allKeys.filter((k) => /^(Spacebar|Esc|Del|Ins|PgUp|PgDn)$/.test(k));
|
|
if (badKeys.length === 0) out.push(pass('A-4.4', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'A-4.4',
|
|
'error',
|
|
`Non-standard key values: ${[...new Set(badKeys)].join(', ')}. Use KeyboardEvent.key names: ' '/Escape/Delete/...`
|
|
)
|
|
);
|
|
|
|
return out;
|
|
}
|
|
|
|
function countEventBlocks(src: string): number {
|
|
// Count top-level entries in events: [...] array. We use the named matches —
|
|
// each event has a `name:` field, so count those that appear inside the
|
|
// events array. Heuristic: count `name:` occurrences that aren't immediately
|
|
// nested inside another known nested structure.
|
|
// Simpler approach: events: [ ... ] block extraction + counting top-level
|
|
// objects.
|
|
return extractEventBlocks(src).length;
|
|
}
|
|
|
|
function extractEventBlocks(src: string): string[] {
|
|
// Find `events: [` then walk brackets until matching `]`. Then split top-
|
|
// level `{ ... }` blocks.
|
|
const eventsIdx = src.indexOf('events:');
|
|
if (eventsIdx === -1) return [];
|
|
const arrStart = src.indexOf('[', eventsIdx);
|
|
if (arrStart === -1) return [];
|
|
let depth = 0;
|
|
let i = arrStart;
|
|
for (; i < src.length; i++) {
|
|
const ch = src[i];
|
|
if (ch === '[') depth++;
|
|
else if (ch === ']') {
|
|
depth--;
|
|
if (depth === 0) break;
|
|
}
|
|
}
|
|
const arrContent = src.slice(arrStart + 1, i);
|
|
const blocks: string[] = [];
|
|
let blockDepth = 0;
|
|
let blockStart = -1;
|
|
for (let j = 0; j < arrContent.length; j++) {
|
|
const ch = arrContent[j];
|
|
if (ch === '{') {
|
|
if (blockDepth === 0) blockStart = j;
|
|
blockDepth++;
|
|
} else if (ch === '}') {
|
|
blockDepth--;
|
|
if (blockDepth === 0 && blockStart !== -1) {
|
|
blocks.push(arrContent.slice(blockStart, j + 1));
|
|
blockStart = -1;
|
|
}
|
|
}
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function checkEidos(kebab: string, info: ComponentReport): CheckResult[] {
|
|
const out: CheckResult[] = [];
|
|
const dir = join(EIDOS_DIR, kebab);
|
|
if (!existsSync(dir)) {
|
|
out.push(fail('E-existence', 'error', `No eidos wrapper directory at ${dir}`));
|
|
return out;
|
|
}
|
|
|
|
// E-1.1: has {name}.svelte root file
|
|
const rootSveltePath = join(dir, `${kebab}.svelte`);
|
|
if (existsSync(rootSveltePath)) out.push(pass('E-1.1', 'error'));
|
|
else out.push(fail('E-1.1', 'error', `Missing root ${kebab}.svelte`));
|
|
|
|
// E-1.2 + E-1.3 + E-1.4: index.ts shape
|
|
const indexPath = join(dir, 'index.ts');
|
|
const idx = tryRead(indexPath);
|
|
if (!idx) {
|
|
out.push(fail('E-1.2', 'error', 'Missing index.ts'));
|
|
} else {
|
|
// E-1.2: NO Object.assign on wrapper components
|
|
const usesObjectAssign = /Object\.assign\s*\(\s*\w+Component\b/.test(idx) || /Object\.assign\s*\(\s*\w+Root\b/.test(idx);
|
|
if (!usesObjectAssign) out.push(pass('E-1.2', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'E-1.2',
|
|
'error',
|
|
'Uses Object.assign on component constructor — hydration glitch. Use explicit .Part = Part assignment.'
|
|
)
|
|
);
|
|
|
|
// E-1.3: named + default export of {Name}
|
|
// Allow: `export { X }`, `export const X`, `export type X`, `export default X`,
|
|
// `export * from ...`, and re-exports via `export { default as X }`.
|
|
const namedExport =
|
|
/export\s*\{\s*[A-Z]\w*\s*[,}]/.test(idx) ||
|
|
/export\s+const\s+[A-Z]/.test(idx) ||
|
|
/export\s*\*\s*from/.test(idx) ||
|
|
/export\s*\{\s*default\s+as/.test(idx);
|
|
const defaultExport =
|
|
/export\s+default\s+\w+/.test(idx) ||
|
|
/export\s+default\s+function/.test(idx) ||
|
|
/export\s*\{\s*[A-Z]\w*\s+as\s+default\b/.test(idx) ||
|
|
// Re-exporting the default of another module: `export { default } from './x.svelte';`
|
|
/export\s*\{\s*default\s*\}\s*from/.test(idx);
|
|
if (namedExport && defaultExport) out.push(pass('E-1.3', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'E-1.3',
|
|
'error',
|
|
`Missing ${[!namedExport && 'named', !defaultExport && 'default'].filter(Boolean).join(' + ')} export`
|
|
)
|
|
);
|
|
|
|
// E-1.4: no Provider/Base/Root/Parts exports
|
|
const badExports = ['Provider', 'Base', 'Parts'].filter((tag) =>
|
|
new RegExp(`\\bexport\\s*\\{[^}]*\\b${tag}\\b`).test(idx)
|
|
);
|
|
if (badExports.length === 0) out.push(pass('E-1.4', 'error'));
|
|
else out.push(fail('E-1.4', 'error', `Forbidden exports: ${badExports.join(', ')}`));
|
|
}
|
|
|
|
// E-2.1: types.ts exists
|
|
const typesPath = join(dir, 'types.ts');
|
|
if (existsSync(typesPath)) out.push(pass('E-2.1', 'error'));
|
|
else out.push(fail('E-2.1', 'error', 'Missing types.ts'));
|
|
|
|
// E-2.2: {name}.css exists + imported from eidos/index.css
|
|
const recipePath = join(dir, `${kebab}.css`);
|
|
if (existsSync(recipePath)) {
|
|
const eidosIndexCss = tryRead(join(REPO, 'src/uix/eidos/index.css'));
|
|
if (eidosIndexCss && eidosIndexCss.includes(`components/${kebab}/${kebab}.css`)) {
|
|
out.push(pass('E-2.2', 'error'));
|
|
} else {
|
|
out.push(fail('E-2.2', 'error', `${kebab}.css exists but not imported from eidos/index.css`));
|
|
}
|
|
} else {
|
|
out.push(fail('E-2.2', 'error', `Missing ${kebab}.css`));
|
|
}
|
|
|
|
// E-2.3: README.md exists
|
|
const readmePath = join(dir, 'README.md');
|
|
if (existsSync(readmePath)) out.push(pass('E-2.3', 'error'));
|
|
else out.push(fail('E-2.3', 'error', 'Missing README.md'));
|
|
|
|
return out;
|
|
}
|
|
|
|
function checkRecipe(kebab: string, info: ComponentReport): CheckResult[] {
|
|
const out: CheckResult[] = [];
|
|
const recipePath = join(EIDOS_DIR, kebab, `${kebab}.css`);
|
|
const css = tryRead(recipePath);
|
|
if (!css) return out; // E-2.2 will catch missing file
|
|
|
|
// R-1.1: root selector
|
|
const hasRoot = new RegExp(`\\[data-${kebab}\\b`).test(css);
|
|
if (hasRoot) out.push(pass('R-1.1', 'error'));
|
|
else out.push(fail('R-1.1', 'error', `Missing root selector [data-${kebab}]`));
|
|
|
|
// R-1.2: data-disabled state styled if interactive
|
|
if (info.interactive) {
|
|
const hasDisabled = /\[data-disabled\b/.test(css);
|
|
if (hasDisabled) out.push(pass('R-1.2', 'error'));
|
|
else out.push(fail('R-1.2', 'error', 'No [data-disabled] styles'));
|
|
|
|
// R-1.5: focus-visible
|
|
const hasFocus = /:focus-visible\b/.test(css);
|
|
if (hasFocus) out.push(pass('R-1.5', 'error'));
|
|
else out.push(fail('R-1.5', 'error', 'No :focus-visible styles'));
|
|
}
|
|
|
|
// R-1.3: data-readonly if applicable
|
|
const morfoSrc = tryRead(join(MORFO_DIR, `${kebab}.ts`)) ?? '';
|
|
if (morfoSrc.includes('data-readonly')) {
|
|
const hasReadonly = /\[data-readonly\b/.test(css);
|
|
if (hasReadonly) out.push(pass('R-1.3', 'warn'));
|
|
else out.push(fail('R-1.3', 'warn', '[data-readonly] declared in morfo but not styled'));
|
|
}
|
|
|
|
// R-1.4: data-invalid if applicable
|
|
if (morfoSrc.includes('data-invalid')) {
|
|
const hasInvalid = /\[data-invalid\b/.test(css);
|
|
if (hasInvalid) out.push(pass('R-1.4', 'warn'));
|
|
else out.push(fail('R-1.4', 'warn', '[data-invalid] declared in morfo but not styled'));
|
|
}
|
|
|
|
// R-2.1: no raw colors (hex/rgb/named)
|
|
const rawColors: string[] = [];
|
|
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
|
|
for (const m of css.matchAll(HEX_RE)) {
|
|
// Allow rgba(0,0,0,0) and white/black in shorthand? Just flag all hex.
|
|
rawColors.push(m[0]);
|
|
}
|
|
const RGB_RE = /\b(rgba?\(\s*\d)/g;
|
|
for (const m of css.matchAll(RGB_RE)) rawColors.push(m[1]);
|
|
// Allow `currentColor`, `transparent`, `inherit`
|
|
if (rawColors.length === 0) out.push(pass('R-2.1', 'error'));
|
|
else
|
|
out.push(
|
|
fail(
|
|
'R-2.1',
|
|
'error',
|
|
`Raw colors detected (${rawColors.length}): ${rawColors.slice(0, 5).join(', ')}${rawColors.length > 5 ? '...' : ''}`
|
|
)
|
|
);
|
|
|
|
// R-2.5: no --eidos-* or --soma-* invented vars
|
|
const badVars: string[] = [];
|
|
for (const m of css.matchAll(/--(eidos|soma|air)-[\w-]+/g)) badVars.push(m[0]);
|
|
if (badVars.length === 0) out.push(pass('R-2.5', 'error'));
|
|
else out.push(fail('R-2.5', 'error', `Invented vars: ${[...new Set(badVars)].slice(0, 5).join(', ')}`));
|
|
|
|
// R-3.2: no legacy color names
|
|
const legacy: string[] = [];
|
|
for (const cls of ['success', 'warning', 'danger', 'info']) {
|
|
const re = new RegExp(`\\[data-color=['"]${cls}['"]\\]`);
|
|
if (re.test(css)) legacy.push(cls);
|
|
}
|
|
if (legacy.length === 0) out.push(pass('R-3.2', 'error'));
|
|
else out.push(fail('R-3.2', 'error', `Legacy color names: ${legacy.join(', ')}`));
|
|
|
|
return out;
|
|
}
|
|
|
|
function checkDemo(kebab: string, info: ComponentReport): CheckResult[] {
|
|
const out: CheckResult[] = [];
|
|
const demoPath = join(DEMO_DIR, kebab, '+page.svelte');
|
|
const src = tryRead(demoPath);
|
|
if (!src) {
|
|
out.push(fail('D-existence', 'error', `No demo page at ${demoPath}`));
|
|
return out;
|
|
}
|
|
|
|
// D-1.1: data-uix-canvas-inner outer
|
|
if (/<div\s+data-uix-canvas-inner\b/.test(src)) out.push(pass('D-1.1', 'error'));
|
|
else out.push(fail('D-1.1', 'error', 'Missing outer <div data-uix-canvas-inner>'));
|
|
|
|
// D-1.2: Tab union exact
|
|
const tabUnionRe = /type Tab\s*=\s*'live'\s*\|\s*'api'\s*\|\s*'morfo'\s*\|\s*'sema'\s*\|\s*'recipe'\s*\|\s*'a11y'/;
|
|
if (tabUnionRe.test(src)) out.push(pass('D-1.2', 'error'));
|
|
else out.push(fail('D-1.2', 'error', 'Tab union not matching canonical 6-tab template'));
|
|
|
|
// D-1.3: imports compileMorfo
|
|
if (/import\s*\{\s*compileMorfo\b/.test(src)) out.push(pass('D-1.3', 'error'));
|
|
else out.push(fail('D-1.3', 'error', 'No `import { compileMorfo }`'));
|
|
|
|
// D-1.5: MutationObserver
|
|
if (info.interactive) {
|
|
if (/new\s+MutationObserver/.test(src)) out.push(pass('D-1.5', 'error'));
|
|
else out.push(fail('D-1.5', 'error', 'No MutationObserver for trace strip'));
|
|
}
|
|
|
|
// D-1.6: stage between header and tablist
|
|
if (/data-uix-stage\b/.test(src)) out.push(pass('D-1.6', 'error'));
|
|
else out.push(fail('D-1.6', 'error', 'No <div data-uix-stage>'));
|
|
|
|
// D-1.7: trace strip for interactive
|
|
if (info.interactive) {
|
|
if (/data-uix-stage-trace\b/.test(src)) out.push(pass('D-1.7', 'error'));
|
|
else out.push(fail('D-1.7', 'error', 'No <div data-uix-stage-trace>'));
|
|
}
|
|
|
|
// D-2.x: header
|
|
const hasEyebrow = /data-uix-eyebrow\b/.test(src);
|
|
const hasTitle = /data-uix-page-title\b/.test(src);
|
|
const hasLede = /data-uix-page-lede\b/.test(src);
|
|
const hasMeta = /data-uix-page-meta\b/.test(src);
|
|
if (hasEyebrow) out.push(pass('D-2.1', 'error'));
|
|
else out.push(fail('D-2.1', 'error', 'Missing data-uix-eyebrow'));
|
|
if (hasTitle && hasLede) out.push(pass('D-2.2', 'error'));
|
|
else out.push(fail('D-2.2', 'error', 'Missing title or lede in header'));
|
|
if (hasMeta) out.push(pass('D-2.3', 'error'));
|
|
else out.push(fail('D-2.3', 'error', 'Missing data-uix-page-meta pills'));
|
|
|
|
// D-3.1: both snippets derived
|
|
const hasSomaSnip = /somaSnippet\b/.test(src);
|
|
const hasEidosSnip = /eidosSnippet\b/.test(src);
|
|
if (hasSomaSnip && hasEidosSnip) out.push(pass('D-3.1', 'error'));
|
|
else out.push(fail('D-3.1', 'error', `Missing ${!hasSomaSnip ? 'somaSnippet' : ''} ${!hasEidosSnip ? 'eidosSnippet' : ''}`.trim()));
|
|
|
|
// D-4.3: Play buttons emit via uix.events.emit
|
|
if (info.interactive) {
|
|
const hasPlay = /uix\.events\??\.emit/.test(src) || /events\.emit\(/.test(src);
|
|
if (hasPlay) out.push(pass('D-4.3', 'error'));
|
|
else out.push(fail('D-4.3', 'warn', 'No uix.events.emit(...) — Play buttons may not be wired'));
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function checkReadme(kebab: string, info: ComponentReport): CheckResult[] {
|
|
const out: CheckResult[] = [];
|
|
const path = join(EIDOS_DIR, kebab, 'README.md');
|
|
const src = tryRead(path);
|
|
if (!src) return out; // E-2.3 catches
|
|
|
|
// F-1.1 Baseline
|
|
if (/##\s*Baseline\b/m.test(src)) out.push(pass('F-1.1', 'error'));
|
|
else out.push(fail('F-1.1', 'error', 'Missing ## Baseline section'));
|
|
|
|
// F-1.2 Comparativa (3+ external references).
|
|
// Use `\n## ` as the section terminator. The previous regex used `$` with
|
|
// the `m` flag, which matched end-of-line and truncated the section body
|
|
// to just the header.
|
|
const compMatch = src.match(/##\s*Comparativa\b[\s\S]*?(?=\n##\s|$)/);
|
|
if (compMatch) {
|
|
// Count rows in the comparison table
|
|
const rows = compMatch[0].match(/^\|/gm) || [];
|
|
const refCount = Math.max(0, rows.length - 2); // header + separator
|
|
if (refCount >= 3) out.push(pass('F-1.2', 'error', `${refCount} references`));
|
|
else out.push(fail('F-1.2', 'error', `Only ${refCount} references in ## Comparativa (need ≥3)`));
|
|
} else {
|
|
out.push(fail('F-1.2', 'error', 'Missing ## Comparativa section'));
|
|
}
|
|
|
|
// F-1.3 Decisiones
|
|
if (/##\s*Decisiones\b/m.test(src)) out.push(pass('F-1.3', 'warn'));
|
|
else out.push(fail('F-1.3', 'warn', 'Missing ## Decisiones section'));
|
|
|
|
// F-1.4 Gaps with disposition. Same regex fix as Comparativa above.
|
|
const gapsMatch = src.match(/##\s*Gaps\b[\s\S]*?(?=\n##\s|$)/);
|
|
if (gapsMatch) {
|
|
const dispositions = (gapsMatch[0].match(/\b(implementar|diferir|descartar)\b/gi) || []).length;
|
|
if (dispositions >= 1) out.push(pass('F-1.4', 'error', `${dispositions} disposition markers`));
|
|
else out.push(fail('F-1.4', 'error', '## Gaps lacks disposition markers (implementar/diferir/descartar)'));
|
|
} else {
|
|
out.push(fail('F-1.4', 'error', 'Missing ## Gaps section'));
|
|
}
|
|
|
|
// F-1.5: passive components must justify
|
|
if (info.passive) {
|
|
if (/##\s*Passive\s+justification\b/im.test(src)) {
|
|
out.push(pass('F-1.5', 'error'));
|
|
} else {
|
|
out.push(fail('F-1.5', 'error', 'Passive component lacks ## Passive justification'));
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
// ─── Build report per component ─────────────────────────────────────────────
|
|
|
|
function buildReport(kebab: string): ComponentReport {
|
|
const morfoPath = join(MORFO_DIR, `${kebab}.ts`);
|
|
const eidosDir = join(EIDOS_DIR, kebab);
|
|
const demoPath = join(DEMO_DIR, kebab, '+page.svelte');
|
|
const morfoSrc = tryRead(morfoPath) ?? '';
|
|
|
|
// Classify: interactive if has events or keyboard keys
|
|
const eventCount = countEventBlocks(morfoSrc);
|
|
const keyCount = [...morfoSrc.matchAll(KEYBOARD_KEY_RE)].length;
|
|
const interactive = eventCount > 0 || keyCount > 0;
|
|
const passive = !interactive;
|
|
|
|
const report: ComponentReport = {
|
|
component: morfoSrc.match(/\bname:\s*['"]([^'"]+)['"]/)?.[1] ?? kebab,
|
|
kebab,
|
|
exists: {
|
|
morfo: morfoSrc.length > 0,
|
|
eidos: existsSync(eidosDir),
|
|
demo: existsSync(demoPath)
|
|
},
|
|
interactive,
|
|
passive,
|
|
checks: []
|
|
};
|
|
|
|
if (morfoSrc) report.checks.push(...checkMorfo(kebab, morfoSrc, report));
|
|
if (report.exists.eidos) {
|
|
report.checks.push(...checkEidos(kebab, report));
|
|
report.checks.push(...checkRecipe(kebab, report));
|
|
report.checks.push(...checkReadme(kebab, report));
|
|
}
|
|
if (report.exists.demo) report.checks.push(...checkDemo(kebab, report));
|
|
|
|
return report;
|
|
}
|
|
|
|
// ─── Verdict ────────────────────────────────────────────────────────────────
|
|
|
|
function verdict(r: ComponentReport): 'PASS' | 'NEEDS-WORK' | 'BROKEN' {
|
|
const errors = r.checks.filter((c) => !c.passed && c.severity === 'error').length;
|
|
const warns = r.checks.filter((c) => !c.passed && c.severity === 'warn').length;
|
|
if (errors > 5) return 'BROKEN';
|
|
if (errors > 0 || warns > 3) return 'NEEDS-WORK';
|
|
return 'PASS';
|
|
}
|
|
|
|
// ─── Markdown rendering ─────────────────────────────────────────────────────
|
|
|
|
function renderMarkdown(reports: ComponentReport[]): string {
|
|
const lines: string[] = [];
|
|
lines.push('# UIX component audit report');
|
|
lines.push('');
|
|
lines.push(`> Generated by \`scripts/component-audit.ts\` against \`src/uix/COMPONENT_COMPLETION_CHECKLIST.md\`.`);
|
|
lines.push(`> Date: ${new Date().toISOString()}`);
|
|
lines.push('');
|
|
|
|
// Summary table
|
|
const passCount = reports.filter((r) => verdict(r) === 'PASS').length;
|
|
const needsCount = reports.filter((r) => verdict(r) === 'NEEDS-WORK').length;
|
|
const brokenCount = reports.filter((r) => verdict(r) === 'BROKEN').length;
|
|
lines.push('## Summary');
|
|
lines.push('');
|
|
lines.push(`- **Total components audited**: ${reports.length}`);
|
|
lines.push(`- **PASS**: ${passCount}`);
|
|
lines.push(`- **NEEDS-WORK**: ${needsCount}`);
|
|
lines.push(`- **BROKEN**: ${brokenCount}`);
|
|
lines.push('');
|
|
|
|
// Sorted: BROKEN first, then NEEDS-WORK, then PASS
|
|
const order = { BROKEN: 0, 'NEEDS-WORK': 1, PASS: 2 };
|
|
reports.sort((a, b) => order[verdict(a)] - order[verdict(b)] || a.kebab.localeCompare(b.kebab));
|
|
|
|
lines.push('## Component scoreboard');
|
|
lines.push('');
|
|
lines.push('| Component | Verdict | Errors | Warns | Demo | Eidos | Type |');
|
|
lines.push('| --- | --- | ---: | ---: | :-: | :-: | --- |');
|
|
for (const r of reports) {
|
|
const errors = r.checks.filter((c) => !c.passed && c.severity === 'error').length;
|
|
const warns = r.checks.filter((c) => !c.passed && c.severity === 'warn').length;
|
|
const v = verdict(r);
|
|
const demo = r.exists.demo ? '✓' : '—';
|
|
const eidos = r.exists.eidos ? '✓' : '—';
|
|
const type = r.passive ? 'passive' : 'interactive';
|
|
lines.push(`| \`${r.kebab}\` | **${v}** | ${errors} | ${warns} | ${demo} | ${eidos} | ${type} |`);
|
|
}
|
|
lines.push('');
|
|
|
|
// Detail per component
|
|
lines.push('## Detail per component');
|
|
lines.push('');
|
|
for (const r of reports) {
|
|
const v = verdict(r);
|
|
const errors = r.checks.filter((c) => !c.passed && c.severity === 'error');
|
|
const warns = r.checks.filter((c) => !c.passed && c.severity === 'warn');
|
|
if (v === 'PASS' && errors.length === 0 && warns.length === 0) continue;
|
|
|
|
lines.push(`### ${r.kebab} — ${v}`);
|
|
lines.push('');
|
|
lines.push(
|
|
`- Morfo: ${r.exists.morfo ? '✓' : '—'} · Eidos wrapper: ${r.exists.eidos ? '✓' : '—'} · Demo: ${r.exists.demo ? '✓' : '—'} · Type: ${r.interactive ? 'interactive' : 'passive'}`
|
|
);
|
|
lines.push('');
|
|
if (errors.length) {
|
|
lines.push('**Errors:**');
|
|
lines.push('');
|
|
for (const c of errors) {
|
|
const filtered = minSeverity && severityRank(minSeverity) > severityRank('error');
|
|
if (filtered) continue;
|
|
lines.push(`- \`${c.rule}\` — ${c.detail}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
if (warns.length && (!minSeverity || severityRank(minSeverity) <= severityRank('warn'))) {
|
|
lines.push('**Warnings:**');
|
|
lines.push('');
|
|
for (const c of warns) {
|
|
lines.push(`- \`${c.rule}\` — ${c.detail}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function severityRank(s: Severity): number {
|
|
return s === 'error' ? 0 : s === 'warn' ? 1 : 2;
|
|
}
|
|
|
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
|
|
function main(): void {
|
|
const components = listComponents();
|
|
const reports: ComponentReport[] = [];
|
|
for (const c of components) {
|
|
try {
|
|
reports.push(buildReport(c.kebab));
|
|
} catch (err) {
|
|
console.error(`[component-audit] Failed on ${c.kebab}:`, err);
|
|
}
|
|
}
|
|
|
|
const md = renderMarkdown(reports);
|
|
mkdirSync(dirname(OUT), { recursive: true });
|
|
writeFileSync(OUT, md);
|
|
|
|
// Console summary
|
|
const broken = reports.filter((r) => verdict(r) === 'BROKEN').length;
|
|
const needs = reports.filter((r) => verdict(r) === 'NEEDS-WORK').length;
|
|
const pass = reports.filter((r) => verdict(r) === 'PASS').length;
|
|
console.log(`[component-audit] ${reports.length} components`);
|
|
console.log(` PASS: ${pass}`);
|
|
console.log(` NEEDS-WORK: ${needs}`);
|
|
console.log(` BROKEN: ${broken}`);
|
|
console.log(` Report: ${OUT}`);
|
|
|
|
// Exit non-zero if any BROKEN
|
|
process.exit(broken > 0 ? 1 : 0);
|
|
}
|
|
|
|
main();
|