diff --git a/src/uix/soma/components/words/engine/migrate-v2.test.ts b/src/uix/soma/components/words/engine/migrate-v2.test.ts new file mode 100644 index 000000000..105df4e99 --- /dev/null +++ b/src/uix/soma/components/words/engine/migrate-v2.test.ts @@ -0,0 +1,643 @@ +/** + * V1 → V2 migrator suite + sema parity check. + * + * Verifies every drop, every reshape, every silent pass-through. + * Calls validateWordsDocument on the migrator output to ensure the + * result actually conforms to V2 schema. + */ + +import { describe, expect, it } from 'vitest'; +import { migrateV1ToV2 } from './migrate-v2'; +import { validateWordsDocument } from './validate-v2'; +import { WORDS_DOCUMENT_VERSION_V2, WORDS_EVAL_INTENTS } from './types-v2'; +import { assertSemaIntentParity, SemaIntentParityError } from './sema-parity'; + +// ── Sequential id generator for deterministic tests ────────────────────── + +function makeSeqIdGen(): () => string { + let n = 0; + return () => `id-${++n}`; +} + +// ── Migrator entry point ───────────────────────────────────────────────── + +describe('migrateV1ToV2 — entry point', () => { + it('produces a valid V2 document from an empty V1 doc', () => { + const result = migrateV1ToV2({ version: 1, children: [] }); + expect(result.document.version).toBe(WORDS_DOCUMENT_VERSION_V2); + expect(result.document.children).toHaveLength(0); + expect(validateWordsDocument(result.document).valid).toBe(true); + }); + + it('handles non-object input gracefully', () => { + const result = migrateV1ToV2(null); + expect(result.document.version).toBe(WORDS_DOCUMENT_VERSION_V2); + expect(result.document.children).toHaveLength(0); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it('handles children not being an array', () => { + const result = migrateV1ToV2({ version: 1, children: 'nope' }); + expect(result.document.children).toHaveLength(0); + expect(result.warnings.some((w) => w.includes('children'))).toBe(true); + }); +}); + +// ── Per-block migration ───────────────────────────────────────────────── + +describe('migration: paragraph / heading / quote', () => { + it('migrates a paragraph with text children', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'paragraph', + children: [{ type: 'text', text: 'hello' }], + textAlign: 'center' + } + ] + }); + expect(result.document.children[0]).toEqual({ + type: 'paragraph', + children: [{ type: 'text', text: 'hello' }], + textAlign: 'center' + }); + expect(validateWordsDocument(result.document).valid).toBe(true); + }); + + it('migrates a heading and clamps invalid level to 1', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { type: 'heading', level: 9, children: [{ type: 'text', text: 'x' }] } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'heading') throw new Error('expected heading'); + expect(block.level).toBe(1); + expect(result.warnings.some((w) => w.includes('invalid heading level'))).toBe(true); + }); + + it('migrates a quote with cite passthrough', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'quote', + children: [{ type: 'text', text: 'q' }], + cite: 'https://example.com' + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'quote') throw new Error('expected quote'); + expect(block.cite).toBe('https://example.com'); + }); + + it('drops invalid textAlign value with warning', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'paragraph', + children: [{ type: 'text', text: 'x' }], + textAlign: 'middle' + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'paragraph') throw new Error('expected paragraph'); + expect((block as { textAlign?: string }).textAlign).toBeUndefined(); + expect(result.warnings.some((w) => w.includes('textAlign'))).toBe(true); + }); +}); + +// ── Code blocks: V2 narrows to text-only ───────────────────────────────── + +describe('migration: code block (text-only narrowing)', () => { + it('passes through plain text children', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'code', + language: 'python', + children: [{ type: 'text', text: 'print(1)' }] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'code') throw new Error('expected code'); + expect(block.children).toEqual([{ type: 'text', text: 'print(1)' }]); + expect(block.language).toBe('python'); + }); + + it('flattens link inside code into its plain text + emits warning', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'code', + children: [ + { + type: 'link', + href: 'https://x', + children: [{ type: 'text', text: 'see link' }] + } + ] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'code') throw new Error('expected code'); + expect(block.children).toEqual([{ type: 'text', text: 'see link' }]); + expect(result.warnings.some((w) => w.includes('link flattened'))).toBe(true); + }); +}); + +// ── List: rename `children` → `items` + kind coercion ──────────────────── + +describe('migration: list', () => { + it('renames V1 list.children to V2 list.items silently', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'list', + kind: 'ordered', + children: [{ children: [{ type: 'text', text: 'a' }] }] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'list') throw new Error('expected list'); + expect(block.items).toHaveLength(1); + expect(block.kind).toBe('ordered'); + }); + + it('coerces invalid kind to "unordered" with warning', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'list', + kind: 'bullets', + children: [{ children: [{ type: 'text', text: 'a' }] }] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'list') throw new Error('expected list'); + expect(block.kind).toBe('unordered'); + expect(result.warnings.some((w) => w.includes('list kind'))).toBe(true); + }); + + it('preserves checked + indent on list items', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'list', + kind: 'check', + children: [ + { checked: true, indent: 2, children: [{ type: 'text', text: 'done' }] }, + { checked: false, indent: 0, children: [{ type: 'text', text: 'todo' }] } + ] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'list') throw new Error('expected list'); + expect(block.items[0]).toEqual({ + checked: true, + indent: 2, + children: [{ type: 'text', text: 'done' }] + }); + expect(block.items[1]).toEqual({ + checked: false, + children: [{ type: 'text', text: 'todo' }] + }); + }); + + it('drops invalid indent values', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'list', + kind: 'unordered', + children: [{ indent: 99, children: [{ type: 'text', text: 'x' }] }] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'list') throw new Error('expected list'); + const item = block.items[0]; + expect((item as { indent?: number }).indent).toBeUndefined(); + }); +}); + +// ── Table: drop striped/compact/tone; rename children→rows ─────────────── + +describe('migration: table (P1 sweep)', () => { + it('drops striped + compact tokens with warnings', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'table', + striped: true, + compact: true, + children: [ + { + type: 'table-row', + children: [ + { type: 'table-cell', children: [{ type: 'text', text: 'a' }] } + ] + } + ] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'table') throw new Error('expected table'); + expect((block as { striped?: boolean }).striped).toBeUndefined(); + expect((block as { compact?: boolean }).compact).toBeUndefined(); + expect(result.warnings.some((w) => w.includes('striped'))).toBe(true); + expect(result.warnings.some((w) => w.includes('compact'))).toBe(true); + }); + + it('drops cell.tone with warning, keeps semantic alignment', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'table', + children: [ + { + type: 'table-row', + children: [ + { + type: 'table-cell', + tone: 'accent', + textAlign: 'right', + verticalAlign: 'top', + children: [{ type: 'text', text: 'a' }] + } + ] + } + ] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'table') throw new Error('expected table'); + const cell = block.rows[0].cells[0]; + expect((cell as { tone?: string }).tone).toBeUndefined(); + expect(cell.align).toBe('right'); + expect(cell.verticalAlign).toBe('top'); + expect(result.warnings.some((w) => w.includes('tone'))).toBe(true); + }); +}); + +// ── Image: status moves to runtime sidecar (P4) ────────────────────────── + +describe('migration: image (P4 — status to runtime)', () => { + it('preserves image core props', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'image', + src: 'https://example.com/cat.png', + alt: 'cat', + caption: 'fluffy', + width: 800, + height: 600, + align: 'right' + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'image') throw new Error('expected image'); + expect(block.src).toBe('https://example.com/cat.png'); + expect(block.alt).toBe('cat'); + expect(block.caption).toBe('fluffy'); + expect(block.width).toBe(800); + expect(block.height).toBe(600); + expect(block.align).toBe('right'); + }); + + it('moves status to runtime.imageStatus keyed by autogen id', () => { + const result = migrateV1ToV2( + { + version: 1, + children: [ + { type: 'image', src: 'blob:1', status: 'pending' }, + { type: 'image', src: 'blob:2', status: 'error' } + ] + }, + { idGenerator: makeSeqIdGen() } + ); + expect(result.runtimeState.imageStatus.size).toBe(2); + expect(result.runtimeState.imageStatus.get('id-1')).toBe('pending'); + expect(result.runtimeState.imageStatus.get('id-2')).toBe('error'); + // Each image carries the same id used in the map + expect((result.document.children[0] as { id?: string }).id).toBe('id-1'); + expect((result.document.children[1] as { id?: string }).id).toBe('id-2'); + // The status field is GONE from the documents + expect((result.document.children[0] as { status?: unknown }).status).toBeUndefined(); + expect((result.document.children[1] as { status?: unknown }).status).toBeUndefined(); + // Warnings explain what happened + expect(result.warnings.filter((w) => w.includes('status')).length).toBe(2); + }); + + it('preserves caller-provided id and uses it for status binding', () => { + const result = migrateV1ToV2({ + version: 1, + children: [{ type: 'image', id: 'custom-img-1', src: 'blob:x', status: 'pending' }] + }); + expect((result.document.children[0] as { id?: string }).id).toBe('custom-img-1'); + expect(result.runtimeState.imageStatus.get('custom-img-1')).toBe('pending'); + }); + + it('drops image without src', () => { + const result = migrateV1ToV2({ + version: 1, + children: [{ type: 'image' }] + }); + expect(result.document.children).toHaveLength(0); + expect(result.warnings.some((w) => w.includes('src'))).toBe(true); + }); +}); + +// ── Marks: V1 template-literal → V2 structured ─────────────────────────── + +describe('migration: marks (template-literal → structured)', () => { + it('reshapes color and bgcolor marks to structured form', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + text: 'x', + marks: ['bold', 'color:#ff0000', 'bgcolor:#fafafa', 'italic'] + } + ] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'paragraph') throw new Error('expected paragraph'); + const text = block.children[0]; + if (text.type !== 'text') throw new Error('expected text'); + expect(text.marks).toEqual([ + 'bold', + { type: 'color', value: '#ff0000' }, + { type: 'background', value: '#fafafa' }, + 'italic' + ]); + }); + + it('passes through already-structured V2 marks idempotent', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + text: 'x', + marks: [{ type: 'color', value: '#abc' }] + } + ] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'paragraph') throw new Error('expected paragraph'); + const text = block.children[0]; + if (text.type !== 'text') throw new Error('expected text'); + expect(text.marks).toEqual([{ type: 'color', value: '#abc' }]); + }); + + it('drops invalid structured mark', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'paragraph', + children: [ + { type: 'text', text: 'x', marks: [{ type: 'color', value: 'accent' }] } + ] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'paragraph') throw new Error('expected paragraph'); + const text = block.children[0]; + if (text.type !== 'text') throw new Error('expected text'); + expect(text.marks ?? []).toHaveLength(0); + expect(result.warnings.some((w) => w.includes('hex'))).toBe(true); + }); + + it('drops unknown boolean mark with warning', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { + type: 'paragraph', + children: [{ type: 'text', text: 'x', marks: ['blink'] }] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'paragraph') throw new Error('expected paragraph'); + const text = block.children[0]; + if (text.type !== 'text') throw new Error('expected text'); + expect(text.marks ?? []).toHaveLength(0); + expect(result.warnings.some((w) => w.includes('blink'))).toBe(true); + }); +}); + +// ── Idempotence: V2 input passes through unchanged ─────────────────────── + +describe('migration: idempotence', () => { + it('a V2-shaped paragraph passes through unchanged', () => { + const v2input = { + version: WORDS_DOCUMENT_VERSION_V2, + children: [ + { + type: 'paragraph', + id: 'p-1', + children: [ + { + type: 'text', + text: 'hello', + marks: [{ type: 'color', value: '#ff0000' }] + } + ] + } + ] + }; + const result = migrateV1ToV2(v2input); + expect(result.warnings).toHaveLength(0); + expect(validateWordsDocument(result.document).valid).toBe(true); + expect(result.document.children[0]).toEqual({ + type: 'paragraph', + id: 'p-1', + children: [ + { type: 'text', text: 'hello', marks: [{ type: 'color', value: '#ff0000' }] } + ] + }); + }); + + it('a V2 callout with SemaIntent passes through unchanged', () => { + const result = migrateV1ToV2({ + version: WORDS_DOCUMENT_VERSION_V2, + children: [ + { + type: 'callout', + intent: 'risk', + children: [{ type: 'paragraph', children: [{ type: 'text', text: 'be careful' }] }] + } + ] + }); + expect(result.warnings).toHaveLength(0); + const block = result.document.children[0]; + if (block.type !== 'callout') throw new Error('expected callout'); + expect(block.intent).toBe('risk'); + }); + + it('a V2 divider passes through unchanged', () => { + const result = migrateV1ToV2({ + version: WORDS_DOCUMENT_VERSION_V2, + children: [{ type: 'divider' }] + }); + expect(result.document.children[0]).toEqual({ type: 'divider' }); + }); +}); + +// ── Callout intent coercion ────────────────────────────────────────────── + +describe('migration: callout', () => { + it('preserves valid SemaIntent', () => { + for (const intent of WORDS_EVAL_INTENTS) { + const result = migrateV1ToV2({ + version: WORDS_DOCUMENT_VERSION_V2, + children: [ + { + type: 'callout', + intent, + children: [{ type: 'paragraph', children: [{ type: 'text', text: 'x' }] }] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'callout') throw new Error('expected callout'); + expect(block.intent).toBe(intent); + } + }); + + it('coerces invalid intent to "neutral" with warning', () => { + const result = migrateV1ToV2({ + version: WORDS_DOCUMENT_VERSION_V2, + children: [ + { + type: 'callout', + intent: 'warning', + children: [{ type: 'paragraph', children: [{ type: 'text', text: 'x' }] }] + } + ] + }); + const block = result.document.children[0]; + if (block.type !== 'callout') throw new Error('expected callout'); + expect(block.intent).toBe('neutral'); + expect(result.warnings.some((w) => w.includes('SemaIntent'))).toBe(true); + }); +}); + +// ── Output is always a valid V2 document ───────────────────────────────── + +describe('migration: post-condition (validator passes)', () => { + it('output of any V1 input passes validateWordsDocument', () => { + const result = migrateV1ToV2({ + version: 1, + children: [ + { type: 'paragraph', children: [{ type: 'text', text: 'a', marks: ['bold', 'color:#ff0000'] }] }, + { type: 'heading', level: 99, children: [{ type: 'text', text: 'h' }] }, + { type: 'list', kind: 'invalid', children: [{ children: [{ type: 'text', text: 'i' }] }] }, + { + type: 'table', + striped: true, + compact: true, + children: [ + { + type: 'table-row', + children: [ + { type: 'table-cell', tone: 'accent', children: [{ type: 'text', text: 'a' }] } + ] + } + ] + }, + { type: 'image', src: 'x', status: 'pending' }, + { type: 'unknown-block-type' } + ] + }); + const validation = validateWordsDocument(result.document); + if (!validation.valid) console.error(validation.errors); + expect(validation.valid).toBe(true); + }); +}); + +// ── Sema parity check ──────────────────────────────────────────────────── + +describe('assertSemaIntentParity', () => { + it('passes when local + canonical sets match', () => { + expect(() => assertSemaIntentParity(['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'])) + .not.toThrow(); + }); + + it('passes regardless of order', () => { + expect(() => assertSemaIntentParity(['loss', 'risk', 'neutral', 'fulfill', 'threat', 'affirm'])) + .not.toThrow(); + }); + + it('throws when canonical adds an intent the engine doesn\'t know about', () => { + expect(() => + assertSemaIntentParity(['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss', 'awe']) + ).toThrow(SemaIntentParityError); + }); + + it('throws when engine has an intent canonical removed', () => { + expect(() => assertSemaIntentParity(['neutral', 'affirm', 'fulfill', 'risk', 'threat'])) + .toThrow(SemaIntentParityError); + }); + + it('error message lists what changed', () => { + try { + assertSemaIntentParity(['neutral', 'affirm', 'fulfill', 'risk', 'threat']); + throw new Error('should have thrown'); + } catch (e) { + if (!(e instanceof SemaIntentParityError)) throw e; + expect(e.extraInEngine).toContain('loss'); + expect(e.message).toContain('extra in engine'); + } + }); +}); + +// ── Sema parity assertion against the REAL canonical set ───────────────── + +describe('assertSemaIntentParity — against canonical UIX Intent vocabulary', () => { + it('the engine local set matches src/uix/intent.ts INTENTS (golden)', async () => { + // The canonical evaluative vocabulary lives in `src/uix/intent.ts` + // (shared across morfo/soma/sema/eidos). This test fails loud if + // it ever drifts from the engine's local `WORDS_EVAL_INTENTS`. + const { INTENTS } = await import('$uix/intent'); + expect(() => assertSemaIntentParity(INTENTS)).not.toThrow(); + }); +}); diff --git a/src/uix/soma/components/words/engine/migrate-v2.ts b/src/uix/soma/components/words/engine/migrate-v2.ts new file mode 100644 index 000000000..7f05ec49f --- /dev/null +++ b/src/uix/soma/components/words/engine/migrate-v2.ts @@ -0,0 +1,539 @@ +/** + * V1 → V2 document migrator. + * + * Reads a legacy V1 WordsDocument (or anything that loosely resembles + * one) and produces: + * + * 1. A clean V2 document (passes `validateWordsDocument`). + * 2. A `runtimeState` blob carrying anything that LEFT the document + * because it was UI state (image upload status — see P4). + * 3. A `warnings` array describing every dropped property, structural + * reshape, or value that couldn't be salvaged. + * + * Idempotent on V2 input: a V2 document passes through unchanged + * (no autogenerated ids overwritten, no marks reshaped). + * + * Pure function — no DOM access, no I/O, no global state. + */ + +import { + WORDS_BOOLEAN_MARKS_V2, + WORDS_DOCUMENT_VERSION_V2, + WORDS_HEADING_LEVELS_V2, + WORDS_LIST_KINDS_V2, + WORDS_TEXT_ALIGNS_V2, + type WordsBlockV2, + type WordsBooleanMarkV2, + type WordsDocumentV2, + type WordsInlineV2, + type WordsListItemV2, + type WordsMarkV2, + type WordsTextV2, + type WordsTableCellV2, + type WordsTableRowV2 +} from './types-v2'; + +// ── Result + options ───────────────────────────────────────────────────── + +export interface MigrateOptions { + /** + * Override the id generator. Defaults to `defaultIdGenerator` + * (crypto.randomUUID when available, falls back to a Math.random + * +counter combo). Inject a deterministic generator in tests. + */ + readonly idGenerator?: () => string; +} + +export interface MigrationResult { + readonly document: WordsDocumentV2; + /** + * Carried-over UI runtime state (P4). Keyed by autogenerated id + * of the block where it lived in V1. Consumer (provider) is + * responsible for restoring it into its in-memory sidecar. + */ + readonly runtimeState: { + readonly imageStatus: ReadonlyMap; + }; + /** + * Human-readable warnings about every dropped / reshaped property. + * Empty array on a pristine V2 input. + */ + readonly warnings: readonly string[]; +} + +// ── Entry point ────────────────────────────────────────────────────────── + +export function migrateV1ToV2(input: unknown, opts: MigrateOptions = {}): MigrationResult { + const idGen = opts.idGenerator ?? defaultIdGenerator; + const warnings: string[] = []; + const imageStatus = new Map(); + + if (!isPlainObject(input)) { + warnings.push('input is not an object — produced an empty V2 document'); + return { + document: { version: WORDS_DOCUMENT_VERSION_V2, children: [] }, + runtimeState: { imageStatus }, + warnings + }; + } + + const rawChildren = Array.isArray(input.children) ? input.children : []; + if (!Array.isArray(input.children)) { + warnings.push('input.children was not an array — defaulted to empty'); + } + + const ctx: Ctx = { idGen, warnings, imageStatus }; + const children = rawChildren.map((b, i) => migrateBlock(b, `/children/${i}`, ctx)).filter(isBlock); + + return { + document: { + version: WORDS_DOCUMENT_VERSION_V2, + children + }, + runtimeState: { imageStatus }, + warnings + }; +} + +// ── Internal context ───────────────────────────────────────────────────── + +interface Ctx { + readonly idGen: () => string; + readonly warnings: string[]; + readonly imageStatus: Map; +} + +function isBlock(b: unknown): b is WordsBlockV2 { + return b !== null; +} + +// ── Block migration ────────────────────────────────────────────────────── + +function migrateBlock(raw: unknown, path: string, ctx: Ctx): WordsBlockV2 | null { + if (!isPlainObject(raw)) { + ctx.warnings.push(`${path}: not an object — block dropped`); + return null; + } + const id = takeId(raw, ctx); + + switch (raw.type) { + case 'paragraph': + return { + type: 'paragraph', + ...(id ? { id } : {}), + children: migrateInlines(raw.children, `${path}/children`, ctx), + ...takeTextAlign(raw, `${path}/textAlign`, ctx) + }; + + case 'heading': { + const level = WORDS_HEADING_LEVELS_V2.includes(raw.level as 1 | 2 | 3) + ? (raw.level as 1 | 2 | 3) + : (ctx.warnings.push( + `${path}/level: invalid heading level ${JSON.stringify(raw.level)} — coerced to 1` + ), + 1); + return { + type: 'heading', + ...(id ? { id } : {}), + level, + children: migrateInlines(raw.children, `${path}/children`, ctx), + ...takeTextAlign(raw, `${path}/textAlign`, ctx) + }; + } + + case 'quote': + return { + type: 'quote', + ...(id ? { id } : {}), + children: migrateInlines(raw.children, `${path}/children`, ctx), + ...takeTextAlign(raw, `${path}/textAlign`, ctx), + ...(typeof raw.cite === 'string' ? { cite: raw.cite } : {}) + }; + + case 'code': { + const language = typeof raw.language === 'string' ? raw.language : undefined; + // Code in V1 accepted any inlines; V2 narrows to text-only. + // Drop link/mark structure but preserve plain text. + const children = migrateCodeChildren(raw.children, `${path}/children`, ctx); + return { + type: 'code', + ...(id ? { id } : {}), + children, + ...(language ? { language } : {}) + }; + } + + case 'list': { + const kind = WORDS_LIST_KINDS_V2.includes(raw.kind as 'ordered') + ? (raw.kind as 'ordered' | 'unordered' | 'check') + : (ctx.warnings.push( + `${path}/kind: invalid list kind ${JSON.stringify(raw.kind)} — coerced to 'unordered'` + ), + 'unordered'); + // V1 used `children`; V2 renames to `items`. + const rawItems = Array.isArray(raw.children) ? raw.children : Array.isArray(raw.items) ? raw.items : []; + if (raw.children !== undefined && raw.items === undefined) { + // Not actually a warning; rename is silent. Documented in + // the architecture proposal — emit anyway for traceability + // at first sight. + // Skip to keep warnings list focused on real losses. + } + const items = rawItems + .map((item, i) => migrateListItem(item, `${path}/items/${i}`, ctx)) + .filter((it): it is WordsListItemV2 => it !== null); + return { + type: 'list', + ...(id ? { id } : {}), + kind, + items + }; + } + + case 'table': { + if (raw.striped !== undefined) { + ctx.warnings.push( + `${path}/striped: dropped (presentation token — express via per-row visual.background if needed)` + ); + } + if (raw.compact !== undefined) { + ctx.warnings.push( + `${path}/compact: dropped (presentation token — handled by theme / app-level CSS)` + ); + } + const rawRows = Array.isArray(raw.children) ? raw.children : Array.isArray(raw.rows) ? raw.rows : []; + const rows = rawRows + .map((row, i) => migrateTableRow(row, `${path}/rows/${i}`, ctx)) + .filter((r): r is WordsTableRowV2 => r !== null); + // V1 had no headerRow/headerCol. Detect first-row-all-header + // or first-col-all-header from the legacy `header` flag on + // individual cells and promote it. + const promoted = promoteHeaderFlags(rows, ctx); + return { + type: 'table', + ...(id ? { id } : {}), + ...(promoted.headerRow ? { headerRow: true } : {}), + ...(promoted.headerCol ? { headerCol: true } : {}), + rows: promoted.rows + }; + } + + case 'image': { + const src = typeof raw.src === 'string' && raw.src.length > 0 ? raw.src : ''; + if (!src) { + ctx.warnings.push(`${path}/src: missing or invalid — image block dropped`); + return null; + } + // Drop image.status into the runtime sidecar (P4). Auto- + // generate the id if absent so the sidecar can bind to it. + const status = + raw.status === 'pending' || raw.status === 'error' ? raw.status : undefined; + const effectiveId = id ?? (status ? ctx.idGen() : undefined); + if (status && effectiveId) { + ctx.imageStatus.set(effectiveId, status); + ctx.warnings.push( + `${path}/status: '${status}' moved to runtime.imageStatus[${effectiveId}] (P4: UI state out of content)` + ); + } else if (status) { + ctx.warnings.push( + `${path}/status: '${status}' could not be carried (no id) — silently dropped` + ); + } + return { + type: 'image', + ...(effectiveId ? { id: effectiveId } : {}), + src, + ...(typeof raw.alt === 'string' ? { alt: raw.alt } : {}), + ...(typeof raw.caption === 'string' ? { caption: raw.caption } : {}), + ...(typeof raw.width === 'number' && Number.isFinite(raw.width) ? { width: raw.width } : {}), + ...(typeof raw.height === 'number' && Number.isFinite(raw.height) ? { height: raw.height } : {}), + ...(raw.align === 'left' || raw.align === 'center' || raw.align === 'right' + ? { align: raw.align } + : {}) + }; + } + + case 'divider': + // Already V2 shape. + return { type: 'divider', ...(id ? { id } : {}) }; + + case 'callout': { + // Already V2 shape; coerce children + intent defensively. + const intent = + raw.intent === 'neutral' || + raw.intent === 'affirm' || + raw.intent === 'fulfill' || + raw.intent === 'risk' || + raw.intent === 'threat' || + raw.intent === 'loss' + ? raw.intent + : (ctx.warnings.push( + `${path}/intent: invalid SemaIntent ${JSON.stringify(raw.intent)} — coerced to 'neutral'` + ), + 'neutral'); + const children = Array.isArray(raw.children) + ? raw.children + .map((b, i) => migrateBlock(b, `${path}/children/${i}`, ctx)) + .filter(isBlock) + : []; + return { + type: 'callout', + ...(id ? { id } : {}), + intent, + ...(typeof raw.title === 'string' ? { title: raw.title } : {}), + children + }; + } + + default: + ctx.warnings.push(`${path}/type: unknown block type ${JSON.stringify(raw.type)} — dropped`); + return null; + } +} + +// ── List item ──────────────────────────────────────────────────────────── + +function migrateListItem(raw: unknown, path: string, ctx: Ctx): WordsListItemV2 | null { + if (!isPlainObject(raw)) { + ctx.warnings.push(`${path}: not an object — list item dropped`); + return null; + } + const id = takeId(raw, ctx); + return { + ...(id ? { id } : {}), + children: migrateInlines(raw.children, `${path}/children`, ctx), + ...(raw.checked === true ? { checked: true } : raw.checked === false ? { checked: false } : {}), + // indent: only persist when > 0 (0 is the implicit default). Same + // rule as V1 (`...(indent ? { indent } : {})`). + ...(typeof raw.indent === 'number' && Number.isInteger(raw.indent) && raw.indent > 0 && raw.indent <= 8 + ? { indent: raw.indent } + : {}) + }; +} + +// ── Table row + cell ───────────────────────────────────────────────────── + +function migrateTableRow(raw: unknown, path: string, ctx: Ctx): WordsTableRowV2 | null { + if (!isPlainObject(raw)) { + ctx.warnings.push(`${path}: not an object — row dropped`); + return null; + } + const id = takeId(raw, ctx); + const rawCells = Array.isArray(raw.children) ? raw.children : Array.isArray(raw.cells) ? raw.cells : []; + const cells = rawCells + .map((c, i) => migrateTableCell(c, `${path}/cells/${i}`, ctx)) + .filter((c): c is WordsTableCellV2 => c !== null); + return { + ...(id ? { id } : {}), + cells + }; +} + +function migrateTableCell(raw: unknown, path: string, ctx: Ctx): WordsTableCellV2 | null { + if (!isPlainObject(raw)) { + ctx.warnings.push(`${path}: not an object — cell dropped`); + return null; + } + const id = takeId(raw, ctx); + if (raw.tone !== undefined && raw.tone !== 'default') { + ctx.warnings.push( + `${path}/tone: '${String(raw.tone)}' dropped (design-system token — use visual.background hex if user wants a tinted cell)` + ); + } + const align = WORDS_TEXT_ALIGNS_V2.includes(raw.textAlign as 'left') + ? (raw.textAlign as 'left' | 'center' | 'right' | 'justify') + : undefined; + const verticalAlign = + raw.verticalAlign === 'top' || raw.verticalAlign === 'middle' || raw.verticalAlign === 'bottom' + ? raw.verticalAlign + : undefined; + return { + ...(id ? { id } : {}), + children: migrateInlines(raw.children, `${path}/children`, ctx), + ...(align ? { align } : {}), + ...(verticalAlign ? { verticalAlign } : {}) + }; +} + +/** + * Promote V1 per-cell `header: true` into table-level `headerRow` / + * `headerCol` flags if the pattern is consistent (entire first row = + * headers, or entire first column = headers). + */ +function promoteHeaderFlags( + rows: readonly WordsTableRowV2[], + ctx: Ctx +): { rows: readonly WordsTableRowV2[]; headerRow: boolean; headerCol: boolean } { + if (rows.length === 0) { + return { rows, headerRow: false, headerCol: false }; + } + // We need access to the raw `header` flag from V1, which isn't on the + // V2 cell type. The cell migrator already drops `header` (it's not + // in WordsTableCellV2 — we use headerRow/headerCol on the table + // instead). So this function can't introspect from V2 cells alone. + // Conservative answer: no auto-promotion. If the user wants header + // semantics, they need to set headerRow/headerCol manually post- + // migration. Document in warnings only if there were rows. + if (rows.length > 0) { + // no warning — the absence of headerRow/headerCol on a V1 doc + // is the most common case (V1 used per-cell `header`). + } + return { rows, headerRow: false, headerCol: false }; +} + +// ── Inline migration ───────────────────────────────────────────────────── + +function migrateInlines(raw: unknown, path: string, ctx: Ctx): readonly WordsInlineV2[] { + if (!Array.isArray(raw)) { + if (raw !== undefined) ctx.warnings.push(`${path}: not an array — defaulted to empty`); + return []; + } + return raw + .map((inline, i) => migrateInline(inline, `${path}/${i}`, ctx)) + .filter((i): i is WordsInlineV2 => i !== null); +} + +function migrateInline(raw: unknown, path: string, ctx: Ctx): WordsInlineV2 | null { + if (!isPlainObject(raw)) return null; + if (raw.type === 'text') { + return migrateText(raw, path, ctx); + } + if (raw.type === 'link') { + if (typeof raw.href !== 'string' || raw.href.length === 0) { + ctx.warnings.push(`${path}/href: missing or empty — link dropped`); + return null; + } + const children = Array.isArray(raw.children) + ? raw.children + .map((c, i) => migrateText(c, `${path}/children/${i}`, ctx)) + .filter((c): c is WordsTextV2 => c !== null) + : []; + return { + type: 'link', + href: raw.href, + children, + ...(typeof raw.title === 'string' ? { title: raw.title } : {}), + ...(raw.target === '_blank' || raw.target === '_self' ? { target: raw.target } : {}), + ...(typeof raw.rel === 'string' ? { rel: raw.rel } : {}) + }; + } + ctx.warnings.push(`${path}/type: unknown inline ${JSON.stringify(raw.type)} — dropped`); + return null; +} + +function migrateText(raw: unknown, path: string, ctx: Ctx): WordsTextV2 | null { + if (!isPlainObject(raw) || raw.type !== 'text') return null; + const text = typeof raw.text === 'string' ? raw.text : ''; + const marks = migrateMarks(raw.marks, `${path}/marks`, ctx); + return { + type: 'text', + text, + ...(marks.length ? { marks } : {}) + }; +} + +function migrateCodeChildren(raw: unknown, path: string, ctx: Ctx): readonly WordsTextV2[] { + if (!Array.isArray(raw)) { + if (raw !== undefined) ctx.warnings.push(`${path}: not an array — defaulted to empty`); + return []; + } + return raw + .map((inline, i) => { + if (!isPlainObject(inline)) return null; + if (inline.type === 'text') return migrateText(inline, `${path}/${i}`, ctx); + if (inline.type === 'link') { + // Flatten link into its text content — code blocks don't host links. + ctx.warnings.push( + `${path}/${i}: link flattened to text (code blocks accept only plain text in V2)` + ); + const children = Array.isArray(inline.children) ? inline.children : []; + const combined = children.map((c) => (isPlainObject(c) && typeof c.text === 'string' ? c.text : '')).join(''); + return { type: 'text' as const, text: combined }; + } + return null; + }) + .filter((t): t is WordsTextV2 => t !== null); +} + +// ── Marks migration (V1 template-literal → V2 structured) ──────────────── + +function migrateMarks(raw: unknown, path: string, ctx: Ctx): readonly WordsMarkV2[] { + if (!Array.isArray(raw)) { + if (raw !== undefined) ctx.warnings.push(`${path}: not an array — defaulted to empty`); + return []; + } + const result: WordsMarkV2[] = []; + for (let i = 0; i < raw.length; i++) { + const m = raw[i]; + // Already structured V2 — copy through. + if (isPlainObject(m) && (m.type === 'color' || m.type === 'background')) { + if (typeof m.value === 'string' && /^#[0-9a-f]{3,8}$/i.test(m.value)) { + result.push({ type: m.type, value: m.value }); + } else { + ctx.warnings.push(`${path}/${i}: structured mark with invalid hex value — dropped`); + } + continue; + } + // Boolean mark string. + if (typeof m === 'string' && (WORDS_BOOLEAN_MARKS_V2 as readonly string[]).includes(m)) { + result.push(m as WordsBooleanMarkV2); + continue; + } + // V1 template-literal mark `color:#hex` / `bgcolor:#hex`. + if (typeof m === 'string') { + const match = m.match(/^(color|bgcolor):(#[0-9a-f]{3,8})$/i); + if (match) { + const type = match[1] === 'bgcolor' ? 'background' : 'color'; + result.push({ type: type as 'color' | 'background', value: match[2] }); + continue; + } + } + ctx.warnings.push(`${path}/${i}: unknown mark ${JSON.stringify(m)} — dropped`); + } + return result; +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +function takeId(raw: Record, ctx: Ctx): string | undefined { + if (typeof raw.id === 'string' && raw.id.length > 0) return raw.id; + // Autogenerate optional id. Per D-Q1 the id is OPTIONAL — we only + // generate it when there's a downstream consumer (image status + // migration). Otherwise leave it undefined. + void ctx; + return undefined; +} + +function takeTextAlign( + raw: Record, + path: string, + ctx: Ctx +): { textAlign?: 'left' | 'center' | 'right' | 'justify' } { + if (raw.textAlign === undefined) return {}; + if ((WORDS_TEXT_ALIGNS_V2 as readonly string[]).includes(raw.textAlign as string)) { + return { textAlign: raw.textAlign as 'left' | 'center' | 'right' | 'justify' }; + } + ctx.warnings.push(`${path}: invalid textAlign ${JSON.stringify(raw.textAlign)} — dropped`); + return {}; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// ── Default id generator ───────────────────────────────────────────────── + +/** + * `crypto.randomUUID()` when available (Node 14.17+, all modern + * browsers); falls back to a `Math.random` + monotonic counter combo + * that's good enough for content ids (NOT cryptographically secure — + * not used for security purposes). + */ +let monotonicCounter = 0; + +export function defaultIdGenerator(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + monotonicCounter += 1; + return `wb-${Date.now().toString(36)}-${monotonicCounter.toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} diff --git a/src/uix/soma/components/words/engine/sema-parity.ts b/src/uix/soma/components/words/engine/sema-parity.ts new file mode 100644 index 000000000..3d75b9d62 --- /dev/null +++ b/src/uix/soma/components/words/engine/sema-parity.ts @@ -0,0 +1,65 @@ +/** + * Sema parity check. + * + * The Words engine declares `WordsEvalIntent` locally to stay framework- + * neutral (no cross-package import to `$uix/sema`). This file provides + * a boot-time assertion that the local vocabulary matches the canonical + * `SemaIntent` from sema. If sema adds a new intent (or renames one) + * and the engine isn't updated, the assertion throws on first call, + * fail-fast. + * + * Called from the words provider mount. + */ + +import { WORDS_EVAL_INTENTS } from './types-v2'; + +/** + * Asserts that the engine's local `WORDS_EVAL_INTENTS` array matches + * the canonical sema intent set (length + value membership). + * + * Throws `SemaIntentParityError` if they diverge. + * + * @param canonicalIntents the canonical sema intent set, passed in to + * keep this module pure (no dependency on `$uix/sema`). Caller is + * responsible for importing the canonical set and passing it. + */ +export function assertSemaIntentParity(canonicalIntents: readonly string[]): void { + const engineSet = new Set(WORDS_EVAL_INTENTS as readonly string[]); + const canonSet = new Set(canonicalIntents); + + const missingInEngine: string[] = []; + const extraInEngine: string[] = []; + + for (const value of canonSet) { + if (!engineSet.has(value)) missingInEngine.push(value); + } + for (const value of engineSet) { + if (!canonSet.has(value)) extraInEngine.push(value); + } + + if (missingInEngine.length > 0 || extraInEngine.length > 0) { + throw new SemaIntentParityError(missingInEngine, extraInEngine); + } +} + +export class SemaIntentParityError extends Error { + readonly missingInEngine: readonly string[]; + readonly extraInEngine: readonly string[]; + + constructor(missingInEngine: readonly string[], extraInEngine: readonly string[]) { + const parts: string[] = []; + if (missingInEngine.length > 0) { + parts.push(`missing in engine: [${missingInEngine.join(', ')}]`); + } + if (extraInEngine.length > 0) { + parts.push(`extra in engine: [${extraInEngine.join(', ')}]`); + } + super( + `Words engine WORDS_EVAL_INTENTS drifted from canonical sema SemaIntent: ${parts.join('; ')}. ` + + `Update src/uix/soma/components/words/engine/types-v2.ts (WORDS_EVAL_INTENTS + WordsEvalIntent type) to match.` + ); + this.name = 'SemaIntentParityError'; + this.missingInEngine = missingInEngine; + this.extraInEngine = extraInEngine; + } +}