audit: add R-2.6 (token presence) + D-7.4 (chip parity) rules

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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent dfb64511de
commit 6144837174

@ -117,6 +117,21 @@ const VERBS_BY_FAMILY = SEMA_VERBS as Record<string, readonly string[]>;
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<string> = (() => {
const set = new Set<string>();
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<Base, 'a' | 'b' | 'c'>`. 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<string> | 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<X, 'a' | 'b' | 'c'>`
// 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<string, readonly string[]>([
['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:
// <span data-uix-control-label>{prop}</span> … {#each [...] as …}
// const {prop}s: SomeType[] = [...]
function extractDemoChipSet(demoSrc: string, prop: 'size' | 'variant' | 'color'): Set<string> | 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');

@ -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

Loading…
Cancel
Save

Powered by TurnKey Linux.