From 61448371747b8c40587ca926cfcaca894366c8ec Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 21 May 2026 12:42:28 +0200 Subject: [PATCH] audit: add R-2.6 (token presence) + D-7.4 (chip parity) rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-2.6: at script start, parse generated/base.css for declared --color-* tokens. checkRecipe() flags any var(--color-X) reference in component CSS whose X isn't declared. Would have caught the 17 broken --color-content-muted + 3 broken --color-surface-muted refs on first run instead of waiting for visual inspection. D-7.4: per-component, parse types.ts for {PascalName}{Prop} (Size, Variant, Color) exact-match unions. Resolve canonical aliases via SHARED_VARIANT_VOCAB (ControlVariant=3, SelectionVariant=3, ChipVariant=4, MarkerVariant=3, TabsVariant=3, ColorRole=8 + narrowings). Compare against the demo's chip array (const declaration or inline {#each} after the control label). Drift reported as "missing X, Y" / "extra Z". Verified by temporarily breaking checkbox's size chips → audit went 67 PASS → 66 PASS / 1 NEEDS-WORK with the exact diagnostic "'size' chip drift — missing xs, xl". Reverted; 67/67 PASS. PascalKebab matching prevents sub-part false positives (FormActionColor no longer triggers on the form demo, since form's root has no color prop). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/component-audit.ts | 179 +++++++++++++++++++++++++++++++++++++ src/uix/PENDIENTES.md | 4 +- 2 files changed, 181 insertions(+), 2 deletions(-) diff --git a/scripts/component-audit.ts b/scripts/component-audit.ts index ebcb27d4a..eeecf2cbe 100644 --- a/scripts/component-audit.ts +++ b/scripts/component-audit.ts @@ -117,6 +117,21 @@ 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; +})(); + // ─── Component discovery ──────────────────────────────────────────────────── function listComponents(): { kebab: string; morfoPath: string }[] { @@ -668,6 +683,26 @@ function checkRecipe(kebab: string, info: ComponentReport): CheckResult[] { 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). + if (DECLARED_COLOR_TOKENS.size > 0) { + const undefRefs: string[] = []; + for (const m of css.matchAll(/var\(--color-([a-z0-9-]+)\)/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']) { @@ -743,9 +778,153 @@ function checkDemo(kebab: string, info: ComponentReport): CheckResult[] { 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']], + ['TabsVariant', ['line', 'surface', 'pills']], + ['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); +} + function checkReadme(kebab: string, info: ComponentReport): CheckResult[] { const out: CheckResult[] = []; const path = join(EIDOS_DIR, kebab, 'README.md'); diff --git a/src/uix/PENDIENTES.md b/src/uix/PENDIENTES.md index 868eb27c9..3b11e3aa5 100644 --- a/src/uix/PENDIENTES.md +++ b/src/uix/PENDIENTES.md @@ -55,8 +55,8 @@ remedia. No se hace barrido proactivo de nada que ya esté PASS. | Audit A-3.6 falla con eventos `open`/`close` válidos de `emerge` | **diferir** — el regex pide `{verb}-X` pero muchos verbs son legítimos solos. Refinar regex para aceptar verbs canónicos. | | Audit no verifica que el demo cierre el popover al hacer click-outside (cuando aplica) | **diferir** — verificación visual queda al browser-walk. | | Translations check: añadir warning cuando un componente declara `texts.label` pero su catálogo no tiene `label` | **implementar** — pequeño, evita drift. | -| **Audit C2 nuevo (token referenced/declared)** — detectar `var(--color-X)` referenciado sin estar declarado en `generated/base.css` | **implementar** — el bug `--color-content-muted` / `--color-surface-muted` se filtró sin detección. Es un check trivial con `Set` y `regex`. | -| **Audit D7 nuevo (chip parity)** — verificar que el array de chips de cada control (size, variant, color) enumera el tipo completo declarado en `types.ts` | **implementar** — el caso `field`/`toolbar` (2 chips de 3 posibles) se quedó silente. | +| **Audit R-2.6** — `var(--color-X)` referenciado sin estar declarado en `generated/base.css` | **hecho** 2026-05-21 (`scripts/component-audit.ts`). Set declarado se calcula al inicio desde `generated/base.css`; cada componente CSS se chequea contra él. | +| **Audit D-7.4** — el array de chips de cada control (size, variant, color) enumera el tipo completo declarado en `types.ts` | **hecho** 2026-05-21 (`scripts/component-audit.ts`). Parsea `{PascalName}{Prop}` exacto, resuelve alias canónicos (`ControlVariant`, `SelectionVariant`, `ChipVariant`, `MarkerVariant`, `TabsVariant`, `ColorRole`+narrowings) vía `SHARED_VARIANT_VOCAB`. | ## Doc debt