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.
svelte-kit-vice/scripts/component-audit.ts

825 lines
31 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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 {
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
const keys = [...src.matchAll(KEYBOARD_KEY_RE)].length;
if (info.interactive) {
// Heuristic: if keyboard keys >= 2× events AND >= 4 total keys, likely
// under-declared events
if (keys >= 4 && declaredEvents > 0 && keys >= declaredEvents * 2) {
out.push(
fail(
'A-3.7',
'error',
`${keys} keyboard keys declared but only ${declaredEvents} semantic events — likely under-declared`
)
);
} else {
out.push(pass('A-3.7', 'error', `${keys} keys / ${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);
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();

Powered by TurnKey Linux.