feat(words): R2 — V1→V2 migrator + sema parity check + 35 tests

R2 closes the engine refactor non-breaking phase. The V2 model from
R1 is now reachable from any V1 document via a pure migrator, and
the engine's local SemaIntent copy stays in lockstep with the
canonical $uix/intent.INTENTS via a runtime assertion.

engine/migrate-v2.ts (~370 lines):
- Pure function migrateV1ToV2(input, opts?) → { document, runtimeState,
  warnings }.
- Per-block migration paths for paragraph / heading / quote / code /
  list / table / image / divider / callout. Each rejects invalid
  values defensively (e.g. invalid heading level coerced to 1 with
  warning).
- P4 enforcement on image: drops `image.status` from the content,
  moves it to `runtimeState.imageStatus: Map<blockId, status>` keyed
  by an autogen id. Caller (provider) restores into in-memory
  sidecar on load.
- P1 sweep on table: drops `striped` / `compact` (presentation
  tokens) with warnings. Drops `cell.tone` (design-system token)
  with warning. Renames list.children → list.items silently.
- Marks reshape: V1 template-literal `'color:#hex'` /
  `'bgcolor:#hex'` → V2 structured `{type:'color'|'background',
  value:'#hex'}`. Already-structured marks pass through unchanged.
- Code blocks narrow to text-only: flattens any link inside code
  into its text content with warning.
- Idempotent on V2 input: a V2 document round-trips with zero
  warnings and exact-equal output (validates with
  validateWordsDocument).
- Default id generator uses crypto.randomUUID() when available, with
  Math.random + monotonic counter fallback. Tests inject a
  deterministic sequential generator.

engine/sema-parity.ts (~60 lines):
- assertSemaIntentParity(canonicalIntents) throws SemaIntentParityError
  if the engine's local WORDS_EVAL_INTENTS drifts from the canonical
  list. Error names both the missing and extra values + points to
  the file to update.
- Caller passes canonical list (avoids cross-package import in this
  module). Provider mount calls it with $uix/intent.INTENTS.

engine/migrate-v2.test.ts (35 tests):
- Entry-point shape handling (null / non-object / non-array children).
- Per-block migration (paragraph, heading with level clamp, quote
  with cite, code with text-only narrowing + link flattening, list
  with kind coercion and indent-0 omission, table with token drops,
  image with src validation).
- P4 enforcement: image.status → runtimeState.imageStatus keyed by
  autogen id; respects caller-provided id.
- Marks reshape: template-literal → structured; structured
  pass-through; invalid hex rejected; unknown boolean dropped.
- Idempotence on V2 input (paragraph, callout, divider).
- Callout intent: all 6 canonical SemaIntent values accepted;
  invalid (e.g. 'warning') coerced to 'neutral' with warning.
- Post-condition: migrator output ALWAYS passes
  validateWordsDocument on any V1 input.
- Sema parity: 5 tests including the real golden cross-check
  against $uix/intent.INTENTS.

Verification: 170/170 engine tests pass (85 V1 + 50 V2 validator
+ 35 V2 migrator/parity). `npm run check`: 0 errors.

Next: R3 — visual sidecar implementation. Render emits inline
styles from block.visual.*; POLISH-1b (image radius/shadow/border
controls) becomes implementable under this model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent d18b32dd93
commit 6c70699ff7

@ -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();
});
});

@ -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<string, 'pending' | 'error'>;
};
/**
* 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<string, 'pending' | 'error'>();
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<string, 'pending' | 'error'>;
}
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<string, unknown>, 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<string, unknown>,
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<string, unknown> {
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)}`;
}

@ -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;
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.