From eba7b4522dae6db32037c32bec0029cbc41434f1 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 16 May 2026 23:36:29 +0200 Subject: [PATCH] Guard persisted Eidos config shapes --- continue.md | 6 +- src/uix/eidos/active-eidos-config.test.ts | 22 ++++++ src/uix/eidos/lib/config.ts | 81 +++++++++++++++++++---- 3 files changed, 95 insertions(+), 14 deletions(-) diff --git a/continue.md b/continue.md index 79db8bf29..a47d0fd24 100644 --- a/continue.md +++ b/continue.md @@ -42,10 +42,12 @@ Actualizacion Eidos ecosystem 2026-05-16: canonicos y pasos `1..12`. - Valores CSS del `EidosConfig` comparten la misma defensa que los overrides runtime: no pueden contener `;`, `{` ni `}`. +- Documentos persistidos con grupos opcionales malformados (`primitives.*`, + `themes.*`, `recipes.*`) fallan temprano como `EidosConfigValidationError`. - Validado: - - `npx vitest run src/uix/eidos/active-eidos-config.test.ts` -> 34 tests OK. + - `npx vitest run src/uix/eidos/active-eidos-config.test.ts` -> 35 tests OK. - `npx vitest run src/uix/eidos/active-eidos.test.ts` -> 19 tests OK. - - `npx vitest run src/uix/eidos` -> 7 archivos, 74 tests OK. + - `npx vitest run src/uix/eidos` -> 7 archivos, 75 tests OK. - `npm run check` -> 0 errores, 0 warnings. Actualizacion permutation runner 2026-05-16: diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index 93d6d1e35..7ecb3a394 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -338,6 +338,28 @@ describe('ActiveEidos config', () => { ) }) + it('reports malformed optional config groups before deep validation', () => { + const malformed = structuredClone(createThemeBaseEidos().toDocument()) + + ;(malformed.options.primitives as unknown as Record).typography = 'bad' + ;(malformed.options.themes!['base-light'] as unknown as Record).color = 'bad' + ;(malformed.options.recipes as unknown as Record).tooltip = 'bad' + + try { + createEidosFromDocument(malformed) + throw new Error('expected malformed optional groups to fail') + } catch (error) { + expect(error).toBeInstanceOf(EidosConfigValidationError) + expect((error as EidosConfigValidationError).issues.map((issue) => issue.path)).toEqual( + expect.arrayContaining([ + 'primitives.typography', + 'themes.base-light.color', + 'recipes.tooltip' + ]) + ) + } + }) + it('ships typography families separately from typography styles', () => { const typography = createThemeBaseEidos().snapshot().primitives.typography! diff --git a/src/uix/eidos/lib/config.ts b/src/uix/eidos/lib/config.ts index d50f34bbd..200b0e6ad 100644 --- a/src/uix/eidos/lib/config.ts +++ b/src/uix/eidos/lib/config.ts @@ -175,6 +175,27 @@ function validateEidosConfigShape( message: 'Eidos config must define a primitives object' }) ok = false + } else { + const primitiveKeys = [ + 'color', + 'size', + 'space', + 'controlHeight', + 'radius', + 'border', + 'focusRing', + 'layout', + 'density', + 'typography', + 'motion', + 'icon', + 'opacity', + 'zIndex' + ] as const + + for (const key of primitiveKeys) { + ok = validateOptionalPlainRecord(input.primitives[key], `primitives.${key}`, issues) && ok + } } const semantics = input.semantics @@ -189,25 +210,61 @@ function validateEidosConfigShape( ok = false } - if (input.themes !== undefined && !isPlainRecord(input.themes)) { - issues.push({ - path: 'themes', - message: 'Eidos config themes must be a plain object' - }) - ok = false + if (input.themes !== undefined) { + if (!isPlainRecord(input.themes)) { + issues.push({ + path: 'themes', + message: 'Eidos config themes must be a plain object' + }) + ok = false + } else { + for (const [themeId, theme] of Object.entries(input.themes)) { + if (!isPlainRecord(theme)) { + issues.push({ + path: `themes.${themeId}`, + message: 'Eidos config theme entries must be plain objects' + }) + ok = false + continue + } + ok = validateOptionalPlainRecord(theme.color, `themes.${themeId}.color`, issues) && ok + ok = validateOptionalPlainRecord(theme.shadow, `themes.${themeId}.shadow`, issues) && ok + } + } } - if (input.recipes !== undefined && !isPlainRecord(input.recipes)) { - issues.push({ - path: 'recipes', - message: 'Eidos config recipes must be a plain object' - }) - ok = false + if (input.recipes !== undefined) { + if (!isPlainRecord(input.recipes)) { + issues.push({ + path: 'recipes', + message: 'Eidos config recipes must be a plain object' + }) + ok = false + } else { + for (const [component, tokens] of Object.entries(input.recipes)) { + ok = validateOptionalPlainRecord(tokens, `recipes.${component}`, issues) && ok + } + } } return ok } +function validateOptionalPlainRecord( + value: unknown, + path: string, + issues: EidosValidationIssue[] +): boolean { + if (value === undefined) return true + if (isPlainRecord(value)) return true + + issues.push({ + path, + message: 'Eidos config entries must be plain objects' + }) + return false +} + export function listEidosColorScales(options: EidosConfig): readonly string[] { return Object.keys(options.primitives.color?.scales ?? {}) }