/** * Component completion audit. * * Walks every UIX component (morfo + eidos wrapper + recipe CSS + demo) * and scores it against the rules in * `docs/guides/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'; import { censusAudit, names as censusNames, type CensusRow, type NameFinding } from './theming-census.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( Object.values(SEMA_VERBS as Record).flat() ); const VERBS_BY_FAMILY = SEMA_VERBS as Record; const ARCHETYPE_SET = new Set(ARCHETYPE_VOCABULARY); const INTENT_SET = new Set(INTENTS); // ─── Declared --color-* tokens (loaded once for R-2.6) ────────────────────── // // R-2.6 norm: any `var(--color-X)` referenced by a component CSS, recipe // or eidos foundation must have X declared in `generated/base.css`. // The set is the closed contract emitted by `appendThemeColorDeclarations` // + recipe-token generators in `src/uix/eidos/lib/render-css.ts`. const GENERATED_CSS = join(REPO, 'src/uix/eidos/generated/base.css'); const DECLARED_COLOR_TOKENS: Set = (() => { const set = new Set(); const src = tryRead(GENERATED_CSS); if (!src) return set; for (const m of src.matchAll(/\s--color-([a-z0-9-]+):/g)) set.add(m[1]); return set; })(); // ─── Recipe Contract shared state (R-4.x) ─────────────────────────────────── // // R-4.x rules enforce `src/uix/eidos/RECIPE_CONTRACT.md` — the transversal // theming systems every recipe must consume (state-layer, tokenized elevation, // opacity token, logical axes, motion channel) instead of hand-rolling its own // idiom. ALL R-4.x graduated to `error`: R-4.1/4.2/4.3/4.4/4.6 after the // 2026-07-02 mechanical backfill, R-4.5 after the motion migration emptied // its 15-component backlog (every recipe now consumes the channel's presets, // signatures or registered keyframes — see RECIPE_CONTRACT.md §2). // Active parallel dev tracks — UNFINISHED components deliberately outside the // completion machinery (same exclusion recipe-css-contract.test.ts uses). // Excluded from the audited catalog + verdicts so WIP noise doesn't read as // catalog breakage; auditable on demand via an explicit `--only words`. const WIP_TRACKS = new Set(['words', 'palabras', 'chronos']); // R-4.4 — physical-axis token keys declared in `lib/recipes/base.ts`, // attributed to their component block. Logical axes (`padding-inline` / // `padding-block`) are the canon; physical axes break RTL. Covers BOTH the // spelled-out grammar (`padding-x-*` / `margin-y` / `inset-x`) AND the // abbreviated segments (`px` / `py` / `mx` / `my` as full key segments — // e.g. `'trigger-px-sm'`), which the original regex missed: 198 abbreviated // keys evaded it until the 2026-07-06 normalization codemod emptied them // (user decision — one vocabulary, excluded tracks included). Segment // anchoring keeps legal keys like `padding-xs` (an axis-less size step) out. // Attribution: top-level component keys in the recipe set sit at one-tab // indentation (`\taccordion: {`); every axis-token line is charged to the last // component key seen above it. const RECIPES_BASE_PATH = join(REPO, 'src/uix/eidos/lib/recipes/base.ts'); const PHYSICAL_AXIS_TOKEN_RE = /'(?:[a-z0-9-]*(?:padding|margin|inset)-(?:x|y)(?:-[a-z0-9-]+)?|(?:[a-z0-9-]+-)?(?:p|m)(?:x|y)(?:-[a-z0-9-]+)?)'\s*:/; const PHYSICAL_AXIS_TOKENS: Map = (() => { const map = new Map(); const src = tryRead(RECIPES_BASE_PATH); if (!src) return map; let component: string | null = null; for (const line of src.split(/\r?\n/)) { const key = line.match(/^\t'?([a-z][a-z0-9-]*)'?:\s*\{\s*$/); if (key) { component = key[1]; continue; } if (!component) continue; const hit = line.match(PHYSICAL_AXIS_TOKEN_RE); if (hit) { const token = hit[0].replace(/['\s:]/g, ''); const list = map.get(component) ?? []; list.push(token); map.set(component, list); } } return map; })(); // R-5.3 — la GRAMÁTICA de los nombres de token (D-TH.6, firmada 2026-08-20). // A diferencia del guard de eventos, que valida pertenencia a un vocabulario // cerrado, éste valida una FORMA: slot de tinta `fg`, modificador interactivo // DELANTE, y lo dimensional/contextual detrás. La gramática NO se reimplementa // aquí: se consume del censo (`theming-census --names`), que es la misma // fuente que ejecutó el codemod — dos implementaciones de una gramática son // dos gramáticas que acaban discrepando. // // Las claves en cola de migración a la capa de estado (§38) se reportan // APARTE: no son deuda de nombre, son knobs que van a desaparecer, y // renombrarlos sería churn sobre lo condenado. const NAME_FINDINGS: Map = await (async () => { const map = new Map(); let findings: NameFinding[] = []; try { findings = await censusNames(); } catch { return map; } for (const f of findings) { const entry = map.get(f.component) ?? { deviated: [], stateLayer: [] }; if (f.klass === 'ink' || f.klass === 'modifier' || f.klass === 'both') entry.deviated.push(f); else if (f.klass === 'state-layer') entry.stateLayer.push(f); map.set(f.component, entry); } return map; })(); // R-5.1 / R-5.2 — theme REACH, checked against the DEBT LEDGER (signed // 2026-08-25). As with R-5.3, the classification is NOT reimplemented here: it // is consumed from the census (`theming-census`), the same source that // generated the baseline. Two implementations of one measure are two measures. // // The ledger (`scripts/theming-census-debt.ts`) registers every declaration // that does not reach a theme, one per key (1154 at the 2026-08-26 baseline: // global + literal + non-derived private, over 76 components). It is NOT an // exceptions file — an exception asserts «this is fine» and every entry // asserts the opposite —: it is registered debt, the written act that lets // R-5.1 be `error` without declaring the indebted recipes broken. The full // doctrine lives in that file's header. // // Precedence between the two acts, spelled out because it is not obvious: // · the README valve `R-5.1 exception:` / `R-5.2 exception:` stays INTACT // and is honoured like on every R-x rule of this file — it is a // component-level signature and this row respects it; // · the catalogue floor (`theming-reach-floor.test.ts`) reads NO READMEs: it // measures against the ledger alone. A valve can soften the ROW without // blinding the RATCHET, which is the one thing that must not be lost; // · no valve excuses a STALE: it is not a deviation to sign, it is wrong // bookkeeping — the entry claims a knob is broken that is already fixed. const CENSUS_STATE = (() => { try { return censusAudit(); } catch (err) { // A census that fails to boot is the census's problem, not this audit's: // the two rows are skipped instead of accusing 162 components. But a // skipped guard must never be a SILENT one (the prepareWith lesson): say // so loudly — the floor test, which calls censusAudit() directly, stays // red meanwhile as the mechanical backstop. console.warn( `[component-audit] censusAudit() failed — R-5.1/R-5.2 SKIPPED for every component: ${ String((err as Error)?.message ?? err).split('\n')[0] }` ); return null; } })(); const CENSUS_ROWS = new Map( (CENSUS_STATE?.rows ?? []).map((r) => [r.component, r]) ); const DEBT_BY_COMPONENT = CENSUS_STATE?.debt.byComponent ?? new Map(); // ─── 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) : !WIP_TRACKS.has(c.kebab))) .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 for interactive components — a pattern URL, or the explicit // `'none — {rationale}'` form for widgets the APG doesn't cover // (checkpoint C5, 2026-07-07). The morfo carries the decision; README // exceptions are no longer honored for this rule — absence always flags. const apgValue = src.match(/\bapg:\s*['"]([^'"]+)['"]/)?.[1]; if (info.interactive) { if (apgValue && /^https?:/.test(apgValue)) out.push(pass('A-1.4', 'warn')); else if (apgValue && /^none\s*—\s*\S/.test(apgValue)) out.push(pass('A-1.4', 'warn', `no official pattern: ${apgValue}`)); else if (apgValue) out.push( fail('A-1.4', 'warn', `apg malformed: '${apgValue}' — use a URL or 'none — {rationale}'`) ); else out.push( fail('A-1.4', 'warn', "No apg declared — cite the pattern URL or 'none — {rationale}'") ); } // 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. `expression: 'delegated'` exonerates: a composite whose // perceptual events live in its children's contracts (checkpoint S11b, // 2026-07-07; participation doctrine in docs/architecture/sema.md). 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; const expression = src.match(/\bexpression:\s*['"]([a-z-]+)['"]/)?.[1]; // Scope gate: events fire through the SOMA runtime — a `scope: ['eidos']` // morfo architecturally cannot declare functional events, so A-3.1 only // applies when 'soma' is in scope (badge/qr-code remove buttons are plain // DOM affordances; growing them a soma layer is a separate decision). const hasSomaScope = /\bscope:\s*\[[^\]]*['"]soma['"]/.test(src); if (info.interactive && declaredEvents === 0 && hasSomaScope) { if (expression === 'delegated') { out.push(pass('A-3.1', 'error', "0 events — delegated composite (expression: 'delegated')")); } else { out.push( fail( 'A-3.1', 'error', "Interactive component has 0 events (declare them, or `expression: 'delegated'` when a composed child owns them)" ) ); } } else if (info.interactive && declaredEvents === 0) { out.push( pass('A-3.1', 'error', 'eidos-native (no soma runtime — functional events not possible)') ); } 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}`, `{family}-{verb}`, OR is a // bare canonical verb on its own (e.g. `present`, `open`, `close`). // Bare verbs are valid when the event has no variant to disambiguate. const badNames = eventNames.filter((n) => { if (!n.includes('-')) { // Accept bare canonical verbs and bare family names. return !ALL_VERBS.has(n) && !SEMA_FAMILIES.includes(n as never); } 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', // Month-grid / year-grid intra-grid navigation. Same semantics as // next-day / first-day-of-week in a Calendar — these move focus // inside the grid, they don't mutate the selected value. 'next-row', 'prev-row', 'first-month', 'last-month', 'first-year', 'last-year', 'next-page', 'prev-page', // 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', // Segment / character navigation (date-field, time-field, color-field, // number-field, pin-input). These are focus moves within the input, // not value mutations. 'next-segment', 'prev-segment', 'first-segment', 'last-segment', 'next-char', 'prev-char', // Drag / drop sub-actions handled by a single commit event family. 'pick-up', 'drop-here', 'drop-cancel', 'move-up', 'move-down', 'move-left', 'move-right', // Generic next/prev navigation (drag-drop reorder, generic lists). 'next', 'prev', // Grid / tree row+cell navigation. 'next-row', 'prev-row', 'next-cell', 'prev-cell', 'first-row', 'last-row', 'first-cell', 'last-cell', 'page-up', 'page-down', // Value-update keys collapsed into a single commit event (slider / // number-field / rating-group / splitter all emit a single // `commit-set` / `commit-resize` regardless of which arrow or // PageUp/Down the user pressed). 'increment', 'decrement', 'increment-large', 'decrement-large', 'resize', 'minimize', 'maximize', // Drag-drop activate/cancel are covered by `commit-drag-start` / // `commit-drop` / `commit-cancel` declared on the morfo. 'activate', 'cancel', // 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' ]); // DISTINCT mutating actions — the rule's own stated semantics ("Enter and // Space both map to the same `select` action"): one action bound to many // keys is ONE mutation to cover, not N. float-panel's `kb-move` rides 13 // keys yet maps to a single handle gesture — counting occurrences flagged // it as under-declared while the wiring was complete. const mutatingKeyboardActions = new Set( allKeyboardActions.filter((a) => !focusMoveActions.has(a)) ); const keys = [...src.matchAll(KEYBOARD_KEY_RE)].length; const mutatingKeyCount = mutatingKeyboardActions.size; 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) || // `export type { X }` — type exports satisfy named-export for // single-part components (Toggle, Switch) that only ship types // alongside the default component. /export\s+type\s*\{\s*[A-Z]\w*/.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 + is wired. Two sanctioned wirings: // (a) current pattern — the component's own wrapper imports it // (`import './{kebab}.css'` in {kebab}.svelte or a sibling module); // (b) legacy/layout primitives — imported from eidos/index.css. // The index.css-only check was stale (pre-wrapper-import architecture) // and mass-failed 120 components that follow the current pattern. const recipePath = join(dir, `${kebab}.css`); if (existsSync(recipePath)) { const eidosIndexCss = tryRead(join(REPO, 'src/uix/eidos/index.css')); const inIndex = Boolean( eidosIndexCss && eidosIndexCss.includes(`components/${kebab}/${kebab}.css`) ); let importedLocally = false; if (!inIndex) { for (const entry of readdirSync(dir)) { if (!/\.(svelte|ts)$/.test(entry) || entry.endsWith('.css')) continue; const src = tryRead(join(dir, entry)); if (src && src.includes(`./${kebab}.css`)) { importedLocally = true; break; } } } if (inIndex || importedLocally) { out.push(pass('E-2.2', 'error', inIndex ? 'imported from index.css' : 'imported by wrapper')); } else { out.push( fail( 'E-2.2', 'error', `${kebab}.css exists but no import found (neither the wrapper nor eidos/index.css imports it)` ) ); } } else { const cssExc = readmeException(kebab, 'E-2.2'); if (cssExc) out.push(pass('E-2.2', 'error', `exception documented: ${cssExc}`)); 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; } /** * Deliberate deviations: a rule can be waived by a documented exception in the * component README — the same valve A-2.3 / R-1.7 already use. Greppable form: * `R-1.1 exception: ` (anywhere in the component's README.md) * The audit then reports the rule as PASS with the documented reason, so the * deviation is an explicit contract instead of a silent divergence. */ function readmeException(kebab: string, ruleId: string): string | null { const readme = tryRead(join(EIDOS_DIR, kebab, 'README.md')); if (!readme) return null; const m = readme.match(new RegExp('`?' + ruleId + ' exception`?:\\s*([^\\n]+)')); return m ? m[1].trim() : null; } function checkRecipe(kebab: string, info: ComponentReport): CheckResult[] { const out: CheckResult[] = []; const dir = join(EIDOS_DIR, kebab); const mainCss = tryRead(join(dir, `${kebab}.css`)); if (!mainCss) return out; // E-2.2 will catch missing file // R-* discipline covers EVERY stylesheet the component ships, not just the // main recipe — secondary sheets (calendar-select.css, …-spectrum.css) were // invisible to the scanner (clean-room 2026-07-10, THM-1). Main recipe // first, then siblings in stable order. const siblingCss = readdirSync(dir) .filter((f) => f.endsWith('.css') && f !== `${kebab}.css`) .sort() .map((f) => tryRead(join(dir, f)) ?? ''); const css = [mainCss, ...siblingCss].join('\n'); // R-1.1: root selector const hasRoot = new RegExp(`\\[data-${kebab}\\b`).test(css); const rootExc = readmeException(kebab, 'R-1.1'); if (hasRoot) out.push(pass('R-1.1', 'error')); else if (rootExc) out.push(pass('R-1.1', 'error', `exception documented: ${rootExc}`)); else out.push(fail('R-1.1', 'error', `Missing root selector [data-${kebab}]`)); // R-1.2: data-disabled state styled. Only required when the morfo // actually declares `data-disabled` on any part — otherwise the audit // would demand defensive styling for state the contract never emits. const morfoDeclaresDisabled = (tryRead(join(MORFO_DIR, `${kebab}.ts`)) ?? '').includes( 'data-disabled' ); if (info.interactive && morfoDeclaresDisabled) { const hasDisabled = /\[data-disabled\b/.test(css); const disabledExc = readmeException(kebab, 'R-1.2'); if (hasDisabled) out.push(pass('R-1.2', 'error')); else if (disabledExc) out.push(pass('R-1.2', 'error', `exception documented: ${disabledExc}`)); else out.push(fail('R-1.2', 'error', 'No [data-disabled] styles')); } if (info.interactive) { // R-1.5: a visible focus treatment. Four sanctioned evidences (the // foundation doctrine in archetypes.css): the `:focus-visible` pseudo, // the field shell's `:focus-within`, a `:has(...:focus…)` control rule, // or the canonical state-attr ring (`[data-focused]` + focus-ring // tokens, written by soma — the spin-field/search-field pattern). const hasFocus = /:focus-visible\b|:focus-within\b|:has\([^)]*:focus|\[data-focused\]/.test( css ); const focusExc = readmeException(kebab, 'R-1.5'); if (hasFocus) out.push(pass('R-1.5', 'error')); else if (focusExc) out.push(pass('R-1.5', 'error', `exception documented: ${focusExc}`)); else out.push( fail( 'R-1.5', 'error', 'No focus treatment (:focus-visible / :focus-within / [data-focused])' ) ); } // 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); const roExc = readmeException(kebab, 'R-1.3'); if (hasReadonly) out.push(pass('R-1.3', 'warn')); else if (roExc) out.push(pass('R-1.3', 'warn', `exception documented: ${roExc}`)); 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); const invExc = readmeException(kebab, 'R-1.4'); if (hasInvalid) out.push(pass('R-1.4', 'warn')); else if (invExc) out.push(pass('R-1.4', 'warn', `exception documented: ${invExc}`)); 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). Escape valve (same as the R-4.x // rules): a `/* literal: */` on the declaration line marks a // physically-fixed color — the doctrine's explicit exception (e.g. the // natural-time-picker daylight skies). const rawColors: string[] = []; // Scan comment-blanked text (offsets preserved) so prose mentioning a color // can't false-positive; the escape-valve lookup runs on the ORIGINAL line. const colorScanCss = blankCssComments(css); const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g; for (const m of colorScanCss.matchAll(HEX_RE)) { if (/literal:/i.test(declarationWindow(css, m.index ?? 0, m[0].length))) continue; rawColors.push(m[0]); } // Function notation: rgb()/rgba() plus the modern spaces (hsl/hwb/lab/lch/ // oklab/oklch) are raw colors all the same. Only literal channel values // count — `rgb(var(--x) / 0.5)` composes a token and passes. `color-mix()` // stays out for the same reason. const COLOR_FN_RE = /\b((?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*[\d.])/g; for (const m of colorScanCss.matchAll(COLOR_FN_RE)) { if (/literal:/i.test(declarationWindow(css, m.index ?? 0, m[1].length))) continue; 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-2.6: every var(--color-X) is declared in generated/base.css. // Catches typos and references to roles that never made it into the // theme contract (the 2026-05-21 bug had 17 broken refs sitting // silently until visual inspection). // // ACTA 2026-08-27 — the needle tolerates whitespace after `var(`. This file // carried the SAME defect `830cd7671` corrected in the census: THREE needles // here demanded a literal `var(--` (this one, R-4.3 and R-4.6), and prettier // breaks a long declaration right after the paren, so `var( --color-x )` // slipped past. In the census a miss MISCLASSIFIED a knob; in the two regex // needles here the check simply stops LOOKING, which is worse in kind — a // guard that inspects nothing passes. Measured BEFORE changing anything: // zero victims at all three sites. The corpus holds 15 prettier-broken // `var( --` today and not one of them is a bare `--color-*`, a raw-layer ref // or a `:hover` state-layer. Corrected anyway, for M0's reason: a needle left // blind keeps the bug armed for the next line prettier decides to break. // The tolerance is COPIED, not invented — `recipe-css-contract.test.ts:150` // already reads `var\(\s*(--[a-z][a-z0-9-]+)\s*[,)]`, and `\s*` on BOTH sides // is what the break actually produces (`var( --x, … )`). if (DECLARED_COLOR_TOKENS.size > 0) { const undefRefs: string[] = []; for (const m of css.matchAll(/var\(\s*--color-([a-z0-9-]+)\s*\)/g)) { if (!DECLARED_COLOR_TOKENS.has(m[1])) undefRefs.push(m[1]); } if (undefRefs.length === 0) out.push(pass('R-2.6', 'error')); else out.push( fail( 'R-2.6', 'error', `Undeclared --color-* refs (${undefRefs.length}): ${[...new Set(undefRefs)].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(', ')}`)); // R-2.7: no literal typography in recipes. // // Eidos foundation owns one type-system anchor (`--font-size-*`, // `--font-weight-*`, `--leading-ui`, `--font-ui`, named styles via // `--style-{name}-*`). Recipes MUST consume that anchor via tokens // or component-scoped tokens that themselves resolve to it. // // A raw literal (`font-size: 12px`, `line-height: 1.4`, // `letter-spacing: 0.02em`, `font-weight: 500`) breaks the // vertebration: changing the foundation no longer propagates here. // // Allowed escape valves — these don't count as drift: // - `inherit`, `initial`, `unset`, `currentColor` (CSS keywords) // - `0` / `0px` / `0em` / `1` (numeric identities — no semantic // typography intent; common for tight-leading on icons or // zero-tracking on monospace) // - any value wrapped in `var(...)` (the whole point) // - any value with a comment `/* literal: */` on the // same line (explicit opt-out for justified exceptions) // // Note: `font-family` is checked but the audit isn't strict about // generic-keyword fallbacks (`sans-serif`, `serif`, `monospace`) // since those only appear inside `var(--font-family-*, sans-serif)` // fallback chains, which are themselves a `var()` and already pass. const TYPO_PROPS = ['font-size', 'font-weight', 'line-height', 'letter-spacing'] as const; const ALLOWED_LITERALS = new Set(['0', '0px', '0em', '0rem', '1', 'inherit', 'initial', 'unset']); const literalTypoOffenses: string[] = []; for (const line of css.split(/\r?\n/)) { // Strip trailing comments so we can look for the escape valve const m = line.match( /^\s*(font-size|font-weight|line-height|letter-spacing)\s*:\s*([^;]+?);?\s*(\/\*.*\*\/)?\s*$/ ); if (!m) continue; const [, prop, rawValue, comment] = m; const value = rawValue.trim(); if (value.startsWith('var(')) continue; if (ALLOWED_LITERALS.has(value)) continue; if (comment && /literal:/i.test(comment)) continue; literalTypoOffenses.push(`${prop}: ${value}`); void TYPO_PROPS; // silence unused-var on the tuple if linter cares } if (literalTypoOffenses.length === 0) out.push(pass('R-2.7', 'warn')); else out.push( fail( 'R-2.7', 'warn', `Literal typography (${literalTypoOffenses.length}): ${literalTypoOffenses.slice(0, 5).join('; ')}${literalTypoOffenses.length > 5 ? '...' : ''}. Use a recipe token or named-style var, or add a /* literal: */ comment.` ) ); // R-4.x — Recipe Contract (transversal systems). WIP tracks excluded even // under an explicit `--only` run (the contract applies when the track lands). if (!WIP_TRACKS.has(kebab)) { out.push(...checkRecipeContract(kebab, css, info)); } return out; } // ─── Recipe Contract checks (R-4.x — src/uix/eidos/RECIPE_CONTRACT.md) ────── /** The full source line(s) spanning a match — used to find escape-valve comments. */ function declarationWindow(css: string, start: number, matchLength: number): string { const lineStart = css.lastIndexOf('\n', start) + 1; const lineEnd = css.indexOf('\n', start + matchLength); return css.slice(lineStart, lineEnd === -1 ? css.length : lineEnd); } /** Blank out CSS comments preserving offsets — so structural scans (e.g. the * R-4.5 `@keyframes` finder) don't match prose inside comments, while * escape-valve lookups still run against the ORIGINAL text at the same index. */ function blankCssComments(css: string): string { return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')); } /** Blank out every `@keyframes … { … }` block (brace-balanced), preserving offsets. */ function stripKeyframesBlocks(css: string): string { let out = css; const re = /@keyframes\s+[\w-]+\s*\{/g; let m: RegExpExecArray | null; while ((m = re.exec(out)) !== null) { let depth = 0; let i = m.index + m[0].length - 1; // at the opening brace for (; i < out.length; i++) { if (out[i] === '{') depth++; else if (out[i] === '}') { depth--; if (depth === 0) break; } } const blanked = out.slice(m.index, i + 1).replace(/[^\n]/g, ' '); out = out.slice(0, m.index) + blanked + out.slice(i + 1); re.lastIndex = m.index + blanked.length; } return out; } function checkRecipeContract(kebab: string, css: string, info: ComponentReport): CheckResult[] { const out: CheckResult[] = []; // R-4.1: no literal box-shadow. Elevation composes `var(--shadow-*)` / // `var(--depth-{plane}-*)`; the inset-ring pattern carries `var()` too, so // any tokenized value passes. Escape valve: `/* literal: */` on // the declaration's line. const shadowOffenses: string[] = []; for (const m of css.matchAll(/box-shadow\s*:\s*([^;{}]+)/g)) { const value = m[1].trim(); if (value.includes('var(')) continue; if (/^(none|inherit|initial|unset|revert(?:-layer)?)$/.test(value)) continue; if (/literal:/i.test(declarationWindow(css, m.index ?? 0, m[0].length))) continue; shadowOffenses.push(value.length > 48 ? `${value.slice(0, 48)}…` : value); } if (shadowOffenses.length === 0) out.push(pass('R-4.1', 'error')); else out.push( fail( 'R-4.1', 'error', `Literal box-shadow (${shadowOffenses.length}): ${shadowOffenses.slice(0, 3).join(' · ')}${shadowOffenses.length > 3 ? '…' : ''}. Use var(--shadow-*) / var(--depth-{plane}-*), or add /* literal: */.` ) ); // R-4.2: no literal fractional opacity outside @keyframes. Disabled / // muted states consume `var(--opacity-*)`. 0 and 1 are visibility // toggles, not tokens — allowed. const noKeyframes = stripKeyframesBlocks(css); const opacityOffenses: string[] = []; for (const m of noKeyframes.matchAll(/(? */.` ) ); // R-4.3: hover backgrounds are the state-layer or a palette token. Scan // innermost rule blocks whose selector carries `:hover`; flag background // values with no `var(` at all (raw color / gradient) and hand-rolled // `color-mix(… currentColor …)` state-layer imitations. if (info.interactive) { const hoverOffenses: string[] = []; for (const block of noKeyframes.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { const selector = block[1]; if (!selector.includes(':hover')) continue; const body = block[2]; const bodyOffset = (block.index ?? 0) + block[0].indexOf(body); for (const decl of body.matchAll(/background(?:-color|-image)?\s*:\s*([^;]+);/g)) { const value = decl[1].trim(); if (/^(none|transparent|inherit|initial|unset|revert(?:-layer)?)$/.test(value)) continue; const window = declarationWindow( noKeyframes, bodyOffset + (decl.index ?? 0), decl[0].length ); if (/literal:/i.test(window)) continue; // Tolerant `var(\s*--`: same needle fix as R-2.6 (ACTA above). This site // is the FRAGILE one — `value` is `decl[1].trim()` straight off the // source, never normalized, so a broken line arrives with its newline // and tabs intact. Not a `/g` regex: `.test()` has no use for the flag, // and a global needle keeps `lastIndex` BETWEEN CALLS on the same regex // OBJECT — measured, `/a/g` as a module const answers true, FALSE, true // on three `.test('a')`. An inline literal is re-created on every // evaluation, so this site would survive the flag by accident; the trap // bites once a needle is hoisted, which is where every named needle in // this file lives (`PHYSICAL_AXIS_TOKEN_RE`, non-global; the `/g` ones // are read with `matchAll`, which does not move the source's index). // The census's own ACTA states the rule for ITS needles, all of them // hoisted, where the carry is real. const handRolledStateLayer = /color-mix\([^)]*currentColor/i.test(value) && !/var\(\s*--state-/.test(value); if (value.includes('var(') && !handRolledStateLayer) continue; hoverOffenses.push(value.length > 48 ? `${value.slice(0, 48)}…` : value); } } if (hoverOffenses.length === 0) out.push(pass('R-4.3', 'error')); else out.push( fail( 'R-4.3', 'error', `:hover background outside the contract (${hoverOffenses.length}): ${hoverOffenses.slice(0, 3).join(' · ')}${hoverOffenses.length > 3 ? '…' : ''}. Neutral tier → var(--state-hover); valenced tier → palette token.` ) ); } // R-4.4: no physical-axis token keys in this component's recipe block // (lib/recipes/base.ts). Logical axes (padding-inline/-block) are canon. const axisTokens = PHYSICAL_AXIS_TOKENS.get(kebab) ?? []; if (axisTokens.length === 0) out.push(pass('R-4.4', 'error')); else out.push( fail( 'R-4.4', 'error', `Physical-axis recipe tokens (${axisTokens.length}): ${[...new Set(axisTokens)].slice(0, 4).join(', ')}${axisTokens.length > 4 ? '…' : ''}. Rename to padding-inline/-block (logical axes).` ) ); // R-5.3: los nombres de token siguen la gramática firmada (D-TH.6). const nameEntry = NAME_FINDINGS.get(kebab); const deviatedNames = nameEntry?.deviated ?? []; const queuedNames = nameEntry?.stateLayer ?? []; if (deviatedNames.length === 0) out.push( pass( 'R-5.3', 'error', queuedNames.length ? `${queuedNames.length} knob(s) de hover neutro en cola de migración a la capa de estado (§38) — no son deuda de nombre` : undefined ) ); else out.push( fail( 'R-5.3', 'error', `Nombres de token fuera de la gramática (${deviatedNames.length}): ${deviatedNames .slice(0, 3) .map((f) => `${f.key} → ${f.to}`) .join( ' · ' )}${deviatedNames.length > 3 ? '…' : ''}. Tinta = fg; el modificador interactivo va DELANTE (theming §6.7 r7).` ) ); // R-5.1: ninguna declaración de apariencia queda fuera de alcance SIN ACTA. // El acta es una de tres: el token público (alcanza), la anotación // `/* literal: */` sobre la declaración (desviación firmada), o una // entrada del ledger de deuda (deuda registrada). Una cuarta declaración // —ni token, ni anotación, ni entrada— es regresión, y con el ledger a cero // deltas eso es exactamente lo que significa `newDebt`. const debtEntry = DEBT_BY_COMPONENT.get(kebab) as | { newDebt: string[]; stale: string[]; registered: number } | undefined; const newDebt = debtEntry?.newDebt ?? []; const staleDebt = debtEntry?.stale ?? []; const registeredDebt = debtEntry?.registered ?? 0; const ledgerNote = registeredDebt ? `${registeredDebt} knob(s) sin alcance REGISTRADOS en el ledger de deuda (no es alcance, es deuda contada)` : undefined; const debtExc = readmeException(kebab, 'R-5.1'); if (staleDebt.length > 0) // Antes que la válvula a propósito: una entrada obsoleta no se firma, se // borra — y el mensaje dice cuál, para que borrarla sea una línea. out.push( fail( 'R-5.1', 'error', `Ledger de deuda STALE (${staleDebt.length}): ${staleDebt.slice(0, 3).join(' · ')}${staleDebt.length > 3 ? '…' : ''}. Ese knob ya no es deuda (tokenizado, anotado o muerto) — borra la(s) línea(s) en scripts/theming-census-debt.ts.` ) ); else if (newDebt.length === 0) out.push(pass('R-5.1', 'error', ledgerNote)); else if (debtExc) out.push(pass('R-5.1', 'error', `exception documented: ${debtExc}`)); else out.push( fail( 'R-5.1', 'error', `Knobs fuera de alcance sin acta (${newDebt.length}): ${newDebt.slice(0, 3).join(' · ')}${newDebt.length > 3 ? '…' : ''}. Tokeniza (var(--${kebab}-…)), anota /* literal: */, o firma la entrada en scripts/theming-census-debt.ts.` ) ); // R-5.2: todo componente con receta TEMATIZABLE tiene contrato escrito. // «Tematizable» es lo medido, no lo supuesto: un knob `system` o // `structural` no cuenta, y por eso `heading` / `text` / `display` —cuya // superficie ES la capa tipográfica `--style-*`— pasan sin acuñar nada, y // las cinco recetas estructurales también. Un componente sin clave pública // pasa cuando cada uno de sus knobs tematizables está en el ledger: la // entrada del ledger ES su registro escrito (`field-langs`, 37 de 37). const censusRow = CENSUS_ROWS.get(kebab); if (censusRow) { const themeable = censusRow.public + censusRow.private + censusRow.global + censusRow.literal; const contractExc = readmeException(kebab, 'R-5.2'); if (themeable === 0) out.push( pass( 'R-5.2', 'error', `nada que declarar: sus ${censusRow.knobs} knob(s) son sistema transversal o estructurales` ) ); else if (censusRow.contractKeys > 0) out.push(pass('R-5.2', 'error')); else if (contractExc) out.push(pass('R-5.2', 'error', `exception documented: ${contractExc}`)); else if (newDebt.length === 0) // No public key, but every knob accounted for. The breakdown is named // instead of passing in silence. NOTE (2026-08-26, closed-space law): the // `var(--{c}-x, fallback)` idiom this branch used to describe is no // longer sanctioned — `recipe-css-contract.test.ts` now bites an // own-name outside the contract WITH or WITHOUT fallback, and the // transitional names lived in its PENDING_PRIVATE_RENAME registry // (dismantled at zero later that same day). After // that law the only component this branch can reach with `public > 0` // is a WIP track outside this audit's catalogue; the wording stays as // honest belt-and-braces for whatever future state reaches it. out.push( pass( 'R-5.2', 'error', `sin clave pública en base.ts; sus ${themeable} knob(s) tematizables: ${censusRow.public} referencia pública con fallback · ${censusRow.private} privado · ${censusRow.global + censusRow.literal} en el ledger de deuda` ) ); else out.push( fail( 'R-5.2', 'error', `Sin entrada en lib/recipes/base.ts y ${newDebt.length} de sus ${themeable} knob(s) tematizables sin acta. Declara el contrato del componente, o registra la deuda.` ) ); } // R-4.5: local @keyframes need a `/* functional: */` annotation — // perceptual signatures live in EidosConfig.motion (generated, themeable), // not in component CSS. Look back 300 chars for the annotation. const unannotated: string[] = []; // Scan on comment-blanked css (offsets preserved) so prose mentioning // "@keyframes" inside a comment never counts; the annotation lookback // runs against the ORIGINAL text (annotations live in comments). const commentFree = blankCssComments(css); for (const m of commentFree.matchAll(/@keyframes\s+([\w-]+)/g)) { const at = m.index ?? 0; const lookback = css.slice(Math.max(0, at - 300), at); if (/functional:/i.test(lookback)) continue; unannotated.push(m[1]); } if (unannotated.length === 0) out.push(pass('R-4.5', 'error')); else out.push( fail( 'R-4.5', 'error', `@keyframes without /* functional: … */ annotation: ${unannotated.slice(0, 4).join(', ')}${unannotated.length > 4 ? '…' : ''}. Perceptual signatures belong in EidosConfig.motion.` ) ); // R-4.7: `!important` needs a same-line `/* important: */` // annotation (THM-5, clean-room 2026-07-10 — 15 shipped uses had no // governing rule). The declaration wins EVERY cascade fight, so the // reason must be written where it happens (soma inline styles, the // group's cross-recipe radius fusion, reduced-motion kills, …). // Scanned on comment-blanked text so prose mentioning `!important` // never counts; the valve lookup runs on the ORIGINAL line. const importantScan = blankCssComments(noKeyframes); const importantOffenses: string[] = []; for (const m of importantScan.matchAll(/!important/g)) { const line = declarationWindow(noKeyframes, m.index ?? 0, m[0].length); if (/important:/i.test(line)) continue; importantOffenses.push(line.trim().slice(0, 48)); } if (importantOffenses.length === 0) out.push(pass('R-4.7', 'error')); else out.push( fail( 'R-4.7', 'error', `Unannotated !important (${importantOffenses.length}): ${importantOffenses.slice(0, 3).join(' · ')}${importantOffenses.length > 3 ? '…' : ''}. Add /* important: */ on the line.` ) ); // R-4.6: no direct raw-layer color refs in component CSS — consume // `var(--color-{role}-{slot})` or recipe tokens instead. const rawLayerRefs: string[] = []; // Tolerant `var(\s*--`: same needle fix as R-2.6 (ACTA above). The report // strips the `var(` by PATTERN and not by a fixed 4, so a match that spans a // prettier break does not print its own whitespace back at the reader. for (const m of css.matchAll(/var\(\s*--(scale|primitive)-[a-z0-9-]+/g)) { if (/literal:/i.test(declarationWindow(css, m.index ?? 0, m[0].length))) continue; rawLayerRefs.push(m[0].replace(/^var\(\s*/, '')); } if (rawLayerRefs.length === 0) out.push(pass('R-4.6', 'error')); else out.push( fail( 'R-4.6', 'error', `Direct raw-layer refs (${rawLayerRefs.length}): ${[...new Set(rawLayerRefs)].slice(0, 3).join(', ')}${rawLayerRefs.length > 3 ? '…' : ''}. Consume var(--color-{role}-{slot}) or a recipe token.` ) ); 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 (/')); // D-1.2: Tab union matches a canonical template. The v2 layout // (DEMO_AUTHORING_GUIDE §type Tab) is the 9-tab canonical; the v1 6-tab // union remains accepted while the demo migration is in flight — the old // exact-6-tab check was stale and failed every ALREADY-migrated demo. const tabUnionV2Re = /type Tab\s*=\s*'live'\s*\|\s*'system'\s*\|\s*'motion'\s*\|\s*'sema'\s*\|\s*'services'\s*\|\s*'api'\s*\|\s*'morfo'\s*\|\s*'recipe'\s*\|\s*'a11y'/; const tabUnionV1Re = /type Tab\s*=\s*'live'\s*\|\s*'api'\s*\|\s*'morfo'\s*\|\s*'sema'\s*\|\s*'recipe'\s*\|\s*'a11y'/; if (tabUnionV2Re.test(src)) out.push(pass('D-1.2', 'error', 'v2 9-tab template')); else if (tabUnionV1Re.test(src)) out.push(pass('D-1.2', 'error', 'legacy v1 6-tab template (pending v2 migration)')); else out.push( fail('D-1.2', 'error', 'Tab union matches neither the v2 9-tab nor the v1 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
')); // 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
')); } // 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: snippet parity. The v2 template derives a single `eidosSnippet` // (guide §12 — "the snippet", singular); v1 demos additionally carry a // `somaSnippet`. Require the eidos snippet; treat soma as optional so the // migrated demos stop mass-failing against the stale two-snippet check. const hasSomaSnip = /somaSnippet\b/.test(src); const hasEidosSnip = /eidosSnippet\b/.test(src); if (hasEidosSnip) { out.push( pass('D-3.1', 'error', hasSomaSnip ? 'soma + eidos snippets (v1)' : 'eidos snippet (v2)') ); } else { out.push(fail('D-3.1', 'error', 'Missing eidosSnippet')); } // 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')); } // D-7.4: chip parity vs type union. // // For every chip-controlled prop (`size`, `variant`, `color`), the // demo's chip array must enumerate the full union declared in // `eidos/components/{kebab}/types.ts`. Truncated arrays (e.g. // `field` shipped 'outline'|'ghost' while ControlVariant has 3) // silently lie about the surface. const typesPath = join(EIDOS_DIR, kebab, 'types.ts'); const typesSrc = tryRead(typesPath); if (typesSrc) { for (const prop of ['size', 'variant', 'color'] as const) { const declared = extractTypeUnion(typesSrc, prop, kebab); if (!declared || declared.size === 0) continue; const exposed = extractDemoChipSet(src, prop); if (!exposed) { out.push( fail( 'D-7.4', 'warn', `'${prop}' type exposes ${declared.size} values but no chip array found in demo` ) ); continue; } const missing = [...declared].filter((v) => !exposed.has(v)); const extra = [...exposed].filter((v) => !declared.has(v)); if (missing.length === 0 && extra.length === 0) { out.push(pass('D-7.4', 'error', `${prop}: ${declared.size} values`)); } else { const parts: string[] = []; if (missing.length) parts.push(`missing ${missing.join(', ')}`); if (extra.length) parts.push(`extra ${extra.join(', ')}`); out.push(fail('D-7.4', 'error', `'${prop}' chip drift — ${parts.join(' · ')}`)); } } } return out; } // Parse `export type {Name}{Prop} = …` and return the set of string-literal // values in the union. Supports both direct unions ('a' | 'b' | 'c') and // `Extract`. Returns null if the type isn't found, // or the type references an unresolved alias we can't follow (deliberate // fail-safe: don't emit a false alarm). function extractTypeUnion( typesSrc: string, prop: 'size' | 'variant' | 'color', kebab: string ): Set | null { // Match `export type {PascalKebab}{Prop} = …` exactly — no intermediate // PascalCase tokens, so we don't pick up sub-part types like // `FormActionColor` when auditing the root form demo. The root prop type // follows the canonical naming convention `{Name}{Prop}` (CheckboxColor, // ComboboxSize, FieldVariant, …). const cap = prop.charAt(0).toUpperCase() + prop.slice(1); const pascal = kebab .split('-') .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) .join(''); const re = new RegExp( `export\\s+type\\s+${pascal}${cap}\\s*=\\s*([^;\\n]+(?:\\n[^;\\n]+)*);`, 'm' ); const m = typesSrc.match(re); if (!m) return null; const body = m[1]; // Pull every quoted literal from the body. This handles both // `'a' | 'b' | 'c'` // and // `Extract` // equally. If the body has no string literals at all (e.g. it just // aliases another type — `ControlVariant`), we can't resolve the // transitive union here without a TS pass; return null so the audit // stays silent. const literals = [...body.matchAll(/'([a-z][a-z0-9-]*)'/g)].map((x) => x[1]); if (literals.length === 0) { // Resolve a single-alias chain like `= ControlVariant` / // `= SelectionVariant` / `= ChipVariant` / etc. via the canonical // vocab defined in `src/uix/eidos/lib/types.ts`. const alias = body.trim(); const resolved = SHARED_VARIANT_VOCAB.get(alias); return resolved ? new Set(resolved) : null; } return new Set(literals); } // Canonical shared unions from `src/uix/eidos/lib/types.ts`. Keep in sync // with that file — duplicated here so the audit doesn't import Svelte types. const SHARED_VARIANT_VOCAB = new Map([ ['ControlVariant', ['surface', 'outline', 'ghost']], ['SelectionVariant', ['solid', 'outline', 'ghost']], ['ChipVariant', ['soft', 'solid', 'outline', 'ghost']], ['MarkerVariant', ['solid', 'soft', 'outline']], // `segmented` joined the tabs archetype in EIDOS_VARIANTS (lib/types.ts) — // keep this mirror in sync with that const. ['TabsVariant', ['line', 'surface', 'pills', 'segmented']], // NOTE: canonical ColorRole (lib/config-types.ts) also includes `tertiary`, // but it is RESERVED (no component consumes it) — component types expose // the 8-value subset below. Revisit when tertiary gains a consumer. ['ColorRole', ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss']], ['AffirmativeColorRole', ['primary', 'secondary', 'neutral', 'affirm']], ['ProgressiveColorRole', ['primary', 'secondary', 'neutral', 'affirm', 'fulfill']], ['EditableColorRole', ['primary', 'secondary', 'neutral', 'affirm', 'risk', 'threat']] ]); // Find the chip array that the demo binds to the given prop. Two shapes: // {prop} … {#each [...] as …} // const {prop}s: SomeType[] = [...] function extractDemoChipSet( demoSrc: string, prop: 'size' | 'variant' | 'color' ): Set | null { // Shape A: const declaration `const sizes/variants/colors: T[] = ['a', 'b']`. const constRe = new RegExp(`const\\s+${prop}s(?:\\s*:\\s*[^=]+)?\\s*=\\s*\\[([^\\]]+)\\]`, 'm'); const cm = demoSrc.match(constRe); if (cm) { const literals = [...cm[1].matchAll(/'([a-z][a-z0-9-]*)'/g)].map((x) => x[1]); if (literals.length) return new Set(literals); } // Shape B: inline `{#each ['a', 'b', 'c'] as …}` after a control label. // Look for the control label line, then scan the next ~25 lines. const labelRe = new RegExp(`data-uix-control-label[^>]*>\\s*${prop}(?:\\s|<)`, 'm'); const labelIdx = demoSrc.search(labelRe); if (labelIdx === -1) return null; const slice = demoSrc.slice(labelIdx, labelIdx + 800); const arrRe = /\{#each\s*\[([^\]]+)\]\s*as\s+\w+/; const am = slice.match(arrRe); if (!am) return null; const literals = [...am[1].matchAll(/'([a-z][a-z0-9-]*)'/g)].map((x) => x[1]); if (!literals.length) return null; return new Set(literals); } // S9 embark guard (checkpoint verdict, 2026-07-07): the F-1.x pre-flight // dossier is BLOCKING (error) only for components embarked AFTER the // checkpoint. The catalog snapshot in component-audit.grandfather.json is // grandfathered to warn — its dossiers get written in the fix phase (P7) // without painting the whole catalog red meanwhile. const GRANDFATHERED: Set = (() => { const src = tryRead(join(__dirname, 'component-audit.grandfather.json')); if (!src) return new Set(); try { const parsed = JSON.parse(src) as { components?: string[] }; return new Set(parsed.components ?? []); } catch { return new Set(); } })(); 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 const preflightSev = GRANDFATHERED.has(kebab) ? 'warn' : 'error'; // F-1.1 Baseline if (/##\s*Baseline\b/m.test(src)) out.push(pass('F-1.1', preflightSev)); else out.push(fail('F-1.1', preflightSev, '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', preflightSev, `${refCount} references`)); else out.push( fail('F-1.2', preflightSev, `Only ${refCount} references in ## Comparativa (need ≥3)`) ); } else { out.push(fail('F-1.2', preflightSev, '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', preflightSev, `${dispositions} disposition markers`)); else out.push( fail( 'F-1.4', preflightSev, '## Gaps lacks disposition markers (implementar/diferir/descartar)' ) ); } else { out.push(fail('F-1.4', preflightSev, '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', preflightSev)); } else { out.push(fail('F-1.5', preflightSev, '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 it declares events or keyboard keys — or if its // PARTS are interactive by contract (archetype / role / element evidence). // The old events||keys test was circular: "interactive with 0 events" // (A-3.1) could never fire for a component whose only interactivity signal // was its parts — pin-input (textbox Input, 0 events, 0 keys) audited as // passive (checkpoint C8, 2026-07-07). const eventCount = countEventBlocks(morfoSrc); const keyCount = [...morfoSrc.matchAll(KEYBOARD_KEY_RE)].length; const interactiveParts = /archetype:\s*['"](?:trigger|field-trigger|input|item)['"]/.test(morfoSrc) || /\brole:\s*['"](?:button|textbox|searchbox|combobox|listbox|option|slider|spinbutton|switch|checkbox|radio|menuitem|menuitemcheckbox|menuitemradio|tab|gridcell)['"]/.test( morfoSrc ) || /defaultElement:\s*['"](?:button|input|select|textarea)['"]/.test(morfoSrc); const interactive = eventCount > 0 || keyCount > 0 || interactiveParts; 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) { // Demo rules downgraded to WARN (user decision, 2026-07-10): the demos // will be re-homed in a NEW demo frame for the whole system, so drift // against the CURRENT v1/v2 page template is not fix-work. The D-* // data stays informational; the rules get rewritten against the new // frame when it lands. report.checks.push( ...checkDemo(kebab, report).map((check) => check.severity === 'error' ? { ...check, severity: 'warn' as const } : check ) ); } return report; } // ─── Verdict ──────────────────────────────────────────────────────────────── function verdict(r: ComponentReport): 'PASS' | 'NEEDS-WORK' | 'BROKEN' { // D-* (demo) rules are verdict-NEUTRAL (user decision, 2026-07-10): the // demos will be re-homed in a new demo frame, so current-template drift // stays visible as data but never paints the component matrix. const counted = r.checks.filter((c) => !c.passed && !c.rule.startsWith('D-')); const errors = counted.filter((c) => c.severity === 'error').length; const warns = counted.filter((c) => 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 \`docs/guides/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( `- **WIP tracks excluded** (unfinished, audited on demand via \`--only\`): ${[...WIP_TRACKS].join(', ')}` ); 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();