` because the roster
- * leaves it out;
- * - `generateBlockList` sections the html tier under its own count instead
- * of reading it as curated blocks;
- * - `generateDts` emits the tags into `JSX.IntrinsicElements`, which is the
- * authoring surface a `kind:'html'` page type-checks against.
- */
-
-import { describe, expect, it } from 'vitest';
-import { compile, generateBlockList, generateDts, manifestFromConfigs, type RegistryConfigLike } from '../index.js';
-
-const configs: RegistryConfigLike[] = [
- { type: 'flex', namespace: 'ui', isContainer: true, inputs: [{ name: 'gap', type: 'number' }, { name: 'children', type: 'slot' }] },
- { type: 'my-widget', namespace: 'x', tier: 'public', inputs: [{ name: 'label', type: 'string' }] },
- { type: 'studio-admin', namespace: 'app-shell', tier: 'internal', inputs: [] },
- { type: 'p', namespace: 'ui', tier: 'html', inputs: [{ name: 'className', type: 'string' }, { name: 'children', type: 'slot' }] },
- { type: 'a', namespace: 'ui', tier: 'html', inputs: [{ name: 'className', type: 'string' }, { name: 'href', type: 'string' }, { name: 'children', type: 'slot' }] },
- { type: 'img', namespace: 'ui', tier: 'html', inputs: [{ name: 'src', type: 'string' }, { name: 'alt', type: 'string' }] },
- { type: 'main', namespace: 'ui', tier: 'html', isContainer: true, inputs: [{ name: 'className', type: 'string' }, { name: 'children', type: 'slot' }] },
-];
-
-const manifest = manifestFromConfigs(configs);
-/** What a consumer reads back off disk — `JSON.stringify` drops every `undefined`. */
-const published = JSON.parse(JSON.stringify(manifest)) as typeof manifest;
-
-describe('manifestFromConfigs carries the html-tier stamp, and only that stamp (objectui#10735)', () => {
- it('writes `tier: html` on a stamped config', () => {
- for (const tag of ['p', 'a', 'img', 'main']) {
- expect(published.components[tag].tier, `${tag} lost its stamp`).toBe('html');
- }
- });
-
- it('publishes NO `tier` key for a curated, an opted-in or an internal config — byte-identical to before', () => {
- for (const tag of ['flex', 'my-widget', 'studio-admin']) {
- expect(Object.prototype.hasOwnProperty.call(published.components[tag], 'tier'), `${tag} grew a tier key`).toBe(false);
- }
- });
-
- it('keeps every other field exactly as the registration declared it', () => {
- expect(published.components.a.inputs.map((i) => i.name)).toEqual(['className', 'href', 'children']);
- expect(published.components.main.isContainer).toBe(true);
- expect(published.components.img.inputs.map((i) => i.name)).toEqual(['src', 'alt']);
- });
-
- it('`publicOnly` keeps the curated tier alone — the html tier is not `public`', () => {
- expect(Object.keys(manifestFromConfigs(configs, { publicOnly: true }).components)).toEqual(['my-widget']);
- });
-});
-
-describe('compile reads the manifest as a whitelist — the stamp changes nothing for that reader', () => {
- it('accepts an html-tier tag with its declared inputs and child slot', () => {
- const r = compile('
Hello docs

', published);
- expect(r.diagnostics.filter((d) => d.severity === 'error')).toEqual([]);
- expect(r.ok).toBe(true);
- expect(r.tree?.type).toBe('main');
- });
-
- it('refuses `
` — the roster leaves it out, so it is `forbidden-tag` like any unknown tag', () => {
- const r = compile('
hi
', published);
- expect(r.ok).toBe(false);
- expect(r.diagnostics).toEqual(
- expect.arrayContaining([expect.objectContaining({ severity: 'error', code: 'forbidden-tag', tag: 'div' })]),
- );
- // The lit control: the same source with `p` in place of `div` is clean.
- expect(compile('
hi
', published).ok).toBe(true);
- });
-
- it('warns `not-a-container` on a void tag authored with children — the slot is declared per tag, honestly', () => {
- const r = compile('
stray', published);
- expect(r.diagnostics).toEqual(
- expect.arrayContaining([expect.objectContaining({ severity: 'warning', code: 'not-a-container', tag: 'img' })]),
- );
- });
-});
-
-describe('generateBlockList sections the html tier under its own count (objectui#10735)', () => {
- it('counts curated blocks in the title and lists the html tier separately', () => {
- const list = generateBlockList(published);
- // Curated title counts `flex`, `my-widget`, `studio-admin` — not the four html-tier rows.
- expect(list).toContain('# SDUI public blocks (3)');
- expect(list).toContain('## html tier intrinsic elements (4)');
- // Rows land in the right section: the html rows come AFTER the section heading.
- const heading = list.indexOf('## html tier intrinsic elements');
- for (const tag of ['p', 'a', 'img', 'main']) {
- expect(list.indexOf(`| \`${tag}\` |`), `${tag} row is missing or above the html section`).toBeGreaterThan(heading);
- }
- for (const tag of ['flex', 'my-widget']) {
- expect(list.indexOf(`| \`${tag}\` |`), `${tag} row landed in the html section`).toBeLessThan(heading);
- }
- });
-
- it('emits no html section for a manifest without html-tier entries — the curated list is unchanged', () => {
- const curatedOnly = manifestFromConfigs(configs.filter((c) => c.tier !== 'html'));
- const list = generateBlockList(curatedOnly);
- expect(list).toContain('# SDUI public blocks (3)');
- expect(list).not.toContain('html tier intrinsic elements');
- });
-});
-
-describe('generateDts publishes the html tier into JSX.IntrinsicElements (objectui#10735)', () => {
- it('emits an interface and an intrinsic entry per html-tier tag, beside the curated ones', () => {
- const dts = generateDts(published);
- expect(dts).toContain('"p": PProps;');
- expect(dts).toContain('"a": AProps;');
- // `a` declares `className`, a base attribute, so it `Omit`s it (objectui#11075).
- expect(dts).toContain('export interface AProps extends Omit
{\n className?: string;\n href?: string;\n}');
- expect(dts).toContain('"flex": FlexProps;');
- });
-});
diff --git a/ui/packages/sdui-parser/src/__tests__/inert-expression-6598.test.ts b/ui/packages/sdui-parser/src/__tests__/inert-expression-6598.test.ts
deleted file mode 100644
index fe81482267f..00000000000
--- a/ui/packages/sdui-parser/src/__tests__/inert-expression-6598.test.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * objectui#6598 — the html tier's silent-vanish hole for braced values this
- * tier cannot materialize.
- *
- * `interpretBrace` materializes strict JSON plus the JS literal subset
- * (objectui#6614 Q1-A, ruled 2026-08-28); a GENUINE EXPRESSION still becomes the
- * deferred `{ $expr }` marker, and NOTHING downstream evaluates that marker
- * (this tier parses, never executes — ADR-0080; a repo-wide grep finds zero
- * `$expr` consumers outside this package). Such a value reaches the renderer as
- * an opaque object, every defensive non-array read degrades it to "not
- * declared", and the author's binding is eaten in silence. That is ADR-0078's
- * prohibited parsed-but-silently-inert state, reported from production as
- * objectui#6598 (moved from objectstack#12649).
- *
- * These cases pin the correction: a `$expr` value on a DECLARED input draws the
- * warning-severity `inert-expression` diagnostic, message carrying the fix.
- * Severity stays WARNING deliberately (the objectui#5709 precedent for inert
- * authored keys); ⛔ escalation to error is objectui#6614 **Q2**, which lands at
- * the SAVE GATE once the framework wires the registry manifest into
- * `validate-jsx-pages` — not here, and not at render.
- *
- * ⭐ WHAT MOVED IN #6614 Q1-A, AND WHY IT IS NOT AN ACCIDENT. This file
- * originally pinned `columns={['name','amount']}`, `columns={[{field:"name"}]}`
- * and `options={{pageSize: 25}}` as WARNING cases, and said in so many words
- * that widening the literal grammar "should move these pins consciously, not by
- * accident". Q1-A widened it, so those three spellings now MATERIALIZE and are
- * correct — the whole point of the ruling. They moved to
- * `literal-subset-6614.test.ts`, which pins their values; each was replaced here
- * by a genuine expression, so this file still pins the same FACT (an inert
- * braced value is never silent) on the same side of the new boundary.
- *
- * The fixture manifest mirrors the LIVE `list-view` registration's relevant
- * inputs (packages/plugin-list/src/index.tsx) but is deliberately synthetic —
- * tier.test.ts's `object-table` fixture standing in for the live registration
- * is how the issue got mis-anchored in the first place. The live-path
- * (registry → SchemaRenderer → grid handoff) evidence lives in the issue
- * report, not here: this file pins the compile half.
- */
-import { describe, expect, it } from 'vitest';
-import { compile } from '../index.js';
-import type { Manifest } from '../types.js';
-
-const manifest: Manifest = {
- components: {
- 'list-view': {
- type: 'list-view',
- namespace: 'plugin-list',
- inputs: [
- { name: 'objectName', type: 'string', required: true },
- { name: 'columns', type: 'array' },
- { name: 'options', type: 'object' },
- ],
- },
- },
-};
-
-describe('inert-expression: a braced EXPRESSION on a declared input warns instead of vanishing', () => {
- it('a method call — the shape #6598 could not materialize — warns and stays as $expr', () => {
- const r = compile(
- ` r.name)} />`,
- manifest,
- );
- expect(r.diagnostics).toEqual([
- expect.objectContaining({
- severity: 'warning',
- code: 'inert-expression',
- tag: 'list-view',
- message: expect.stringContaining('"columns"'),
- }),
- ]);
- // The marker itself is unchanged — the tree still carries the deferred
- // value; only the silence is gone. The message names what IS accepted.
- expect(r.tree?.columns).toEqual({ $expr: 'rows.map((r) => r.name)' });
- expect(r.diagnostics[0].message).toMatch(/LITERALS only/);
- // Warning, not error: the page still compiles (the objectui#5709 posture).
- expect(r.ok).toBe(true);
- });
-
- it('a bare identifier draws the same warning', () => {
- const r = compile(``, manifest);
- expect(r.diagnostics).toEqual([
- expect.objectContaining({ severity: 'warning', code: 'inert-expression' }),
- ]);
- });
-
- it('an $expr on an object-typed input is covered too', () => {
- const r = compile(``, manifest);
- expect(r.diagnostics).toEqual([
- expect.objectContaining({ severity: 'warning', code: 'inert-expression', tag: 'list-view' }),
- ]);
- });
-
- it('strict-JSON spellings stay diagnostic-free — the warning cannot fire on a working page', () => {
- for (const source of [
- ``,
- ``,
- ``,
- ``,
- ]) {
- const r = compile(source, manifest);
- expect(r.diagnostics).toEqual([]);
- expect(r.ok).toBe(true);
- }
- });
-
- it('an $expr on an UNKNOWN prop keeps drawing unknown-prop, not a double report', () => {
- const r = compile(``, manifest);
- expect(r.diagnostics).toEqual([
- expect.objectContaining({ severity: 'warning', code: 'unknown-prop' }),
- ]);
- });
-});
diff --git a/ui/packages/sdui-parser/src/__tests__/input-type-union.test.ts b/ui/packages/sdui-parser/src/__tests__/input-type-union.test.ts
deleted file mode 100644
index b8c56c26d0d..00000000000
--- a/ui/packages/sdui-parser/src/__tests__/input-type-union.test.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-/**
- * ObjectUI — `ManifestInput.type` as a union of arms (objectui#3832)
- *
- * The mechanism half of the card; the user-visible half (the five measured
- * specimens, through the manifest the console really builds) lives in
- * `apps/console/src/__tests__/component-input-union-specimens.test.ts`.
- *
- * Three facts are pinned here, and they are different facts:
- *
- * 1. ANY-ARM — a value passes when any declared arm accepts it, and is still
- * reported when none does. The second half is the one that can rot silently:
- * a `switch (input.type)` handed an array falls into its default branch and
- * returns null, so a broken implementation looks exactly like a clean value.
- * 2. BACKWARD COMPATIBILITY — a single-kind declaration behaves and serializes
- * byte-identically to before. This is what makes the change an extension of
- * `sdui.manifest.json` rather than a new version of it.
- * 3. THE CONSUMERS — the serializer canonicalizes (collapse one arm, drop an
- * off-vocabulary arm, dedupe) and the JSX codegen emits a TS union. A
- * declaration the gate honours but the `.d.ts` contradicts would just move
- * the author's false error one layer over.
- */
-import { describe, expect, it } from 'vitest';
-import {
- canonicalizeInputType,
- compile,
- generateDts,
- inputTypeArms,
- manifestFromConfigs,
- validateTree,
-} from '../index.js';
-import type { Manifest, SchemaElement } from '../types.js';
-
-const one = (inputs: Parameters[0][number]['inputs']): Manifest =>
- manifestFromConfigs([{ type: 'probe', namespace: 'ui', inputs }]);
-
-const codes = (manifest: Manifest, node: Record): string[] =>
- validateTree({ type: 'probe', ...node } as SchemaElement, manifest).diagnostics.map(
- (d) => d.code,
- );
-
-const messages = (manifest: Manifest, node: Record): string[] =>
- validateTree({ type: 'probe', ...node } as SchemaElement, manifest).diagnostics.map(
- (d) => d.message,
- );
-
-describe('any declared arm accepts the value', () => {
- const manifest = one([{ name: 'title', type: ['string', 'object'] }]);
-
- it('accepts either arm', () => {
- expect(codes(manifest, { title: 'Account' })).toEqual([]);
- expect(codes(manifest, { title: { en: 'Account', 'zh-CN': '客户' } })).toEqual([]);
- });
-
- it('still reports a value that matches NO arm — the union is not an opt-out', () => {
- // The control for fact 1. Every "accepts" assertion above would also pass if
- // the coarse check had stopped running altogether.
- expect(codes(manifest, { title: 42 })).toContain('type-mismatch');
- expect(codes(manifest, { title: true })).toContain('type-mismatch');
- expect(codes(manifest, { title: ['Account'] })).toContain('type-mismatch');
- });
-
- it('names every arm in the message, once, in declaration order', () => {
- expect(messages(manifest, { title: 42 })).toEqual([
- ' prop "title" expected a string or an object',
- ]);
- });
-
- it('reports one diagnostic per prop, not one per failed arm', () => {
- expect(codes(manifest, { title: 42 })).toHaveLength(1);
- });
-
- it('reports at the STRICTEST arm severity when an enum arm is present', () => {
- // An enum's closed list is the one fact this layer is certain about, so
- // adding a second arm beside it must not turn its verdict into a dismissible
- // warning. The code becomes `type-mismatch` (the fact is "fits none of the
- // arms") while the message still carries the allowed values.
- const withEnum = one([{ name: 'size', type: ['number', 'enum'], enum: ['sm', 'lg'] }]);
- expect(codes(withEnum, { size: 12 })).toEqual([]);
- expect(codes(withEnum, { size: 'lg' })).toEqual([]);
-
- const bad = validateTree(
- { type: 'probe', size: 'enormous' } as SchemaElement,
- withEnum,
- ).diagnostics;
- expect(bad).toHaveLength(1);
- expect(bad[0]).toMatchObject({ severity: 'error', code: 'type-mismatch' });
- expect(bad[0].message).toContain('one of ["sm","lg"]');
- });
-
- it('a `slot` arm keeps accepting everything', () => {
- // Preserves the pre-union `default: return null` branch: a slot describes a
- // child position, not a value, and those inputs never drew a diagnostic.
- const slotty = one([{ name: 'footer', type: 'slot' }]);
- expect(codes(slotty, { footer: 'anything' })).toEqual([]);
- expect(codes(slotty, { footer: { deeply: ['nested'] } })).toEqual([]);
- });
-});
-
-describe('a single-kind declaration is unchanged (backward compatibility)', () => {
- it('produces the same messages it always did', () => {
- const manifest = one([
- { name: 's', type: 'string' },
- { name: 'n', type: 'number' },
- { name: 'b', type: 'boolean' },
- { name: 'a', type: 'array' },
- { name: 'o', type: 'object' },
- { name: 'c', type: 'color' },
- ]);
- expect(messages(manifest, { s: 1 })).toEqual([' prop "s" expected a string']);
- expect(messages(manifest, { n: 'x' })).toEqual([' prop "n" expected a number']);
- expect(messages(manifest, { b: 'x' })).toEqual([' prop "b" expected a boolean']);
- expect(messages(manifest, { a: {} })).toEqual([' prop "a" expected an array']);
- expect(messages(manifest, { o: [] })).toEqual([' prop "o" expected an object']);
- // `color` is a string with a narrower control — its message was, and stays,
- // the string one.
- expect(messages(manifest, { c: 1 })).toEqual([' prop "c" expected a string']);
- });
-
- it('keeps `invalid-enum` at error severity for a lone enum arm', () => {
- // The one code that is NOT `type-mismatch`, and the severity a silent
- // fallback would hide behind (objectui#3985). A lone enum arm must keep both.
- const manifest = one([{ name: 'mode', type: 'enum', enum: ['a', 'b'] }]);
- const d = validateTree({ type: 'probe', mode: 'c' } as SchemaElement, manifest).diagnostics;
- expect(d).toHaveLength(1);
- expect(d[0]).toMatchObject({ severity: 'error', code: 'invalid-enum' });
- expect(d[0].message).toBe(' prop "mode"="c" is not one of ["a","b"]');
- });
-
- it('serializes to the bare string, so an existing manifest is byte-identical', () => {
- const before = one([{ name: 'title', type: 'string' }]);
- expect(before.components.probe.inputs[0].type).toBe('string');
- expect(JSON.stringify(before)).toContain('"type":"string"');
- });
-});
-
-describe('canonicalizeInputType — what reaches the published manifest', () => {
- it('collapses a one-element array to the bare kind', () => {
- // So a union declaration that happens to have one arm cannot fork the
- // published shape into two spellings of one meaning.
- expect(canonicalizeInputType(['string'])).toBe('string');
- expect(one([{ name: 'x', type: ['string'] }]).components.probe.inputs[0].type).toBe('string');
- });
-
- it('de-duplicates repeated arms', () => {
- expect(canonicalizeInputType(['string', 'string'])).toBe('string');
- expect(canonicalizeInputType(['string', 'object', 'string'])).toEqual(['string', 'object']);
- });
-
- it('preserves arm order for a real union', () => {
- expect(canonicalizeInputType(['object', 'string'])).toEqual(['object', 'string']);
- });
-
- it('coerces a lone off-vocabulary kind to `string`, as it always did', () => {
- expect(canonicalizeInputType('i18nString')).toBe('string');
- expect(canonicalizeInputType(undefined)).toBe('string');
- });
-
- it('DROPS an off-vocabulary arm from a union instead of coercing it', () => {
- // Coercing would publish a `'string'` arm the author never declared — a
- // widening invented by the serializer. The surviving arms carry the
- // declaration, so the unknown one is dropped.
- expect(canonicalizeInputType(['object', 'i18nString'])).toBe('object');
- expect(canonicalizeInputType(['number', 'nope', 'boolean'])).toEqual(['number', 'boolean']);
- // …and if that empties the array, the lone-unknown fallback applies, because
- // a manifest input must always carry a valid kind.
- expect(canonicalizeInputType(['nope', 'alsoNope'])).toBe('string');
- expect(canonicalizeInputType([])).toBe('string');
- });
-
- it('is what the gate reads — a dropped arm is not silently honoured', () => {
- const manifest = one([{ name: 'title', type: ['object', 'i18nString'] as never }]);
- expect(codes(manifest, { title: { en: 'x' } })).toEqual([]);
- expect(codes(manifest, { title: 'x' })).toContain('type-mismatch');
- });
-});
-
-describe('inputTypeArms', () => {
- it('normalizes both declaration forms', () => {
- expect(inputTypeArms('string')).toEqual(['string']);
- expect(inputTypeArms(['string', 'number'])).toEqual(['string', 'number']);
- expect(inputTypeArms(undefined)).toEqual([]);
- });
-});
-
-describe('generateDts emits the union it validates', () => {
- it('emits a TypeScript union for a multi-arm input', () => {
- const dts = generateDts(
- one([
- { name: 'title', type: ['string', 'object'], required: true },
- { name: 'defaultValue', type: ['string', 'number'] },
- ]),
- );
- expect(dts).toContain('title: string | Record;');
- expect(dts).toContain('defaultValue?: string | number;');
- });
-
- it('de-duplicates arms that collapse to the same TS type', () => {
- const dts = generateDts(one([{ name: 'tint', type: ['string', 'color'] }]));
- expect(dts).toContain('tint?: string;');
- expect(dts).not.toContain('string | string');
- });
-
- it('emits an enum arm as its literal union, beside the other arms', () => {
- const dts = generateDts(one([{ name: 'size', type: ['enum', 'number'], enum: ['sm', 'lg'] }]));
- expect(dts).toContain('size?: "sm" | "lg" | number;');
- });
-
- it('omits an input whose only arm is a slot, and keeps one that has a value arm', () => {
- const dts = generateDts(
- one([
- { name: 'footer', type: 'slot' },
- { name: 'caption', type: ['slot', 'string'] },
- ]),
- );
- expect(dts).not.toContain('footer');
- // The old test was `i.type !== 'slot'`, which this union passes; the type is
- // taken from the value arms only, so the slot arm contributes nothing.
- expect(dts).toContain('caption?: string;');
- });
-});
-
-describe('the union survives the whole compile pipeline', () => {
- it('compiles a JSX page that writes either arm', () => {
- const manifest = one([{ name: 'title', type: ['string', 'object'] }]);
- expect(compile(``, manifest).diagnostics).toEqual([]);
- expect(
- compile(``, manifest).diagnostics,
- ).toEqual([]);
- expect(compile(``, manifest).diagnostics.map((d) => d.code)).toContain(
- 'type-mismatch',
- );
- });
-});
diff --git a/ui/packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts b/ui/packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts
deleted file mode 100644
index ccce437b05c..00000000000
--- a/ui/packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * ObjectUI — what this tier says about `quickAdd` on `` once the
- * key is RETIRED (objectui#8285, ruling B of decision batch #91, 2026-09-08)
- *
- * ## What changed
- *
- * Until objectui#8285 closed, `quickAdd` was a key `@objectstack/spec`
- * published on `object-kanban` and the board could not honour, so this package
- * carried an interim `inert-quick-add` warning in place of the `unknown-prop`
- * the prop walk would otherwise emit: "has no prop quickAdd" was FALSE against
- * the published contract. The ruling scheduled that interim for deletion the
- * day the spec's own refusal landed ("the spec refusal by name replaces that
- * diagnostic once it lands").
- *
- * It landed: `@objectstack/spec` 17.5.0 tombstones the key, both faces of
- * `@object-ui/types`' `ObjectKanbanSchema` refuse it by name, and
- * `ObjectKanban` no longer forwards it. So the interim module and its four
- * exports are gone, and the walk's own answer is back.
- *
- * ## Why a bare `unknown-prop` is the accurate answer now
- *
- * "`` has no prop quickAdd" is TRUE on every face after the
- * retirement, and on this tier it is also the whole remedy: a constrained-JSX
- * page cannot write the `onQuickAdd` function the control needs, so the only
- * edit open to its author is to delete the key. That is the difference from the
- * retired `body` dialect next door (`./body-dialect-6771.test.ts`), which keeps
- * a replacement diagnostic because it has a replacement SPELLING to name
- * (`children`); `quickAdd` has none on this tier.
- *
- * The live-registry half of this reading — the manifest a real page compiles
- * against — is pinned in
- * `packages/plugin-kanban/src/__tests__/quickAddRetiredNotForwarded-8285.test.tsx`.
- */
-import { describe, expect, it } from 'vitest';
-import * as sduiParser from '../index.js';
-import { compile, manifestFromConfigs, validateTree } from '../index.js';
-import type { Diagnostic, Manifest } from '../types.js';
-
-/** The `object-kanban` registration's shape: `quickAdd` is not among its inputs. */
-const manifest: Manifest = manifestFromConfigs([
- {
- type: 'object-kanban',
- namespace: 'plugin-kanban',
- inputs: [
- { name: 'objectName', type: 'string', required: true },
- { name: 'groupBy', type: 'string' },
- ],
- },
-]);
-
-const diagnose = (node: Record): Diagnostic[] =>
- validateTree(node as never, manifest).diagnostics;
-
-/** The retired interim's code, spelled here because the constant left the barrel. */
-const RETIRED_CODE = 'inert-quick-add';
-
-describe('objectui#8285 — `quickAdd` on draws the walk\'s own `unknown-prop`', () => {
- it.each([[true], [false], [{ $expr: 'rows.length > 0' }]])(
- 'value %p — exactly one diagnostic, an `unknown-prop` warning naming the key',
- (value) => {
- const diagnostics = diagnose({ type: 'object-kanban', objectName: 'task', quickAdd: value });
- expect(diagnostics.map((d) => [d.severity, d.code, d.tag])).toEqual([
- ['warning', 'unknown-prop', 'object-kanban'],
- ]);
- expect(diagnostics[0].message).toContain('"quickAdd"');
- },
- );
-
- it('the retired interim code is emitted for no value of the key', () => {
- // `true` is the row that drew it before; `false` and the braced marker are
- // the two it deliberately skipped. None of them may draw it now.
- for (const value of [true, false, { $expr: 'x' }]) {
- const codes = diagnose({ type: 'object-kanban', objectName: 'task', quickAdd: value }).map((d) => d.code);
- expect(codes).not.toContain(RETIRED_CODE);
- }
- });
-
- it('through the whole pipeline: source text compiles, with the warning and nothing harder', () => {
- const result = compile('', manifest);
- expect(result.diagnostics.map((d) => d.code)).toEqual(['unknown-prop']);
- // A warning, as the interim was: the save gate's pass/fail does not move.
- expect(result.ok).toBe(true);
- // Non-vacuity for that `true`: the same pipeline does turn `ok` false.
- expect(compile('', manifest).ok).toBe(false);
- });
-
- it('the barrel no longer publishes the interim\'s four exports', () => {
- const exported = Object.keys(sduiParser);
- for (const name of ['checkKanbanQuickAdd', 'INERT_QUICK_ADD', 'QUICK_ADD_HOST_TYPES', 'QUICK_ADD_KEY']) {
- expect(exported).not.toContain(name);
- }
- // Control on the same instrument: the sibling retired-key check is still
- // exported, so the four absences are a reading of this barrel.
- expect(exported).toContain('checkRetiredBodyDialect');
- });
-});
diff --git a/ui/packages/sdui-parser/src/__tests__/literal-subset-6614.test.ts b/ui/packages/sdui-parser/src/__tests__/literal-subset-6614.test.ts
deleted file mode 100644
index 433099b43cd..00000000000
--- a/ui/packages/sdui-parser/src/__tests__/literal-subset-6614.test.ts
+++ /dev/null
@@ -1,281 +0,0 @@
-/**
- * objectui#6614 Q1-A — `interpretBrace` materializes the JS LITERAL SUBSET.
- *
- * Maintainer ruling, 2026-08-28 (live director session, batch #5 item 1),
- * verbatim: 「6614 也同意」 ⇒ Q1-A · Q2-A · Q3-A, adopting the card's
- * recommendation whole. This file pins Q1-A and ONLY Q1-A.
- *
- * WHY THE RULING EXISTS. The html tier is the untrusted-safe DATA tier — parsed,
- * never executed (ADR-0080) — and it is the only safe carrier for runtime AI or
- * tenant authoring. Before this change it accepted strict JSON inside braces
- * while calling itself JSX, so `columns={['name','amount']}` — the spelling
- * every JSX author and every AI author writes — compiled to the deferred
- * `{ $expr }` marker that nothing downstream evaluates, and the author's whole
- * data binding vanished at render (objectui#6598, reported from production
- * after eight spellings were tried). That is a trap, not a contract. The ruling
- * closes it by materializing the literal subset, and it keeps genuine
- * expressions loudly refused.
- *
- * ⭐ WHY THE REFUSAL PINS BELOW MATTER AS MUCH AS THE POSITIVE ONES. A suite
- * that only asserted the newly-legal spellings would pass just as well against
- * a parser that started accepting EVERYTHING — including code — which is the
- * one failure this widening must never ship. So every positive pin here has a
- * refusal pin naming the first thing on the far side of the boundary.
- *
- * THE BOUNDARY, stated once. Exactly two widenings over JSON:
- * 1. single-quoted strings (value position AND key position);
- * 2. unquoted identifier object keys.
- * Everything else JSON refuses is still refused: trailing commas, comments,
- * holes, spreads, `undefined`/`NaN`/`Infinity`, `+1`/`.5`/`1.`/`0x1f`, template
- * literals, identifiers, member access, calls, and every operator. The subset
- * contains no identifier lookup and no operator, so there is nothing in it to
- * execute — the widening moves habitual spellings onto the materialized side,
- * it does not move the data/code boundary.
- *
- * ⛔ NOT IN SCOPE HERE, by the same ruling: Q2 (escalating `inert-expression`
- * from warning to error — that lands at the SAVE GATE once the framework wires
- * the registry manifest into `validate-jsx-pages`) and Q3 (base-prop `$expr`
- * inertness, sequenced deliberately AFTER this change so no warning is added
- * for spellings this change is about to legalise).
- */
-import { describe, expect, it } from 'vitest';
-import { compile, interpretBrace, parseJsx } from '../index.js';
-import type { Manifest } from '../types.js';
-
-const manifest: Manifest = {
- components: {
- 'list-view': {
- type: 'list-view',
- namespace: 'plugin-list',
- inputs: [
- { name: 'objectName', type: 'string', required: true },
- { name: 'columns', type: 'array' },
- { name: 'options', type: 'object' },
- { name: 'title', type: 'string' },
- { name: 'pageSize', type: 'number' },
- { name: 'enabled', type: 'boolean' },
- ],
- },
- },
-};
-
-/** The braced attribute value, as it reaches the renderer. */
-const propOf = (braced: string, prop = 'columns'): unknown =>
- parseJsx(``).tree?.[prop];
-
-describe('#6614 Q1-A — the two ruled widenings materialize', () => {
- it("the production spelling: a single-quoted array reaches the renderer as a real array", () => {
- // objectui#6598's page, byte-for-byte. This is the assertion the whole card
- // exists for: before the ruling this was `{ $expr: "['name','amount']" }`.
- expect(propOf(`['name','amount']`)).toEqual(['name', 'amount']);
- expect(interpretBrace(`['name','amount']`)).toEqual(['name', 'amount']);
- });
-
- it('a single-quoted string in value position materializes to the string itself', () => {
- expect(propOf(`'Accounts'`, 'title')).toBe('Accounts');
- // No quotes survive into the value — the point is normalization, not a
- // literal copy of the source text.
- expect(interpretBrace(`'Accounts'`)).toBe('Accounts');
- });
-
- it('unquoted identifier object keys materialize', () => {
- expect(propOf(`{pageSize: 25}`, 'options')).toEqual({ pageSize: 25 });
- expect(propOf(`[{field:"name"},{field:"amount"}]`)).toEqual([
- { field: 'name' },
- { field: 'amount' },
- ]);
- // `$` and `_` are identifier characters, and a digit is legal after the first.
- expect(interpretBrace(`{_a: 1, $b: 2, c3: 3}`)).toEqual({ _a: 1, $b: 2, c3: 3 });
- });
-
- it('a single-quoted KEY is covered — it is widening #1 in key position', () => {
- expect(interpretBrace(`{'pageSize': 25}`)).toEqual({ pageSize: 25 });
- });
-
- it('the two widenings compose, and nest', () => {
- expect(interpretBrace(`{cols: ['name','amount'], page: {size: 25, deep: ['x']}}`)).toEqual({
- cols: ['name', 'amount'],
- page: { size: 25, deep: ['x'] },
- });
- });
-
- it('escapes inside a single-quoted string follow JSON, plus `\\u0027`', () => {
- expect(interpretBrace(`'a\\'b'`)).toBe("a'b");
- expect(interpretBrace(`'tab\\there'`)).toBe('tab\there');
- expect(interpretBrace(`'\\u0041'`)).toBe('A');
- // A double quote needs no escape inside single quotes, and vice versa.
- expect(interpretBrace(`'say "hi"'`)).toBe('say "hi"');
- });
-
- it('the materialized spellings draw NO diagnostic — the page is simply correct now', () => {
- for (const source of [
- ``,
- ``,
- ``,
- ``,
- ``,
- ]) {
- const r = compile(source, manifest);
- expect(r.diagnostics, source).toEqual([]);
- expect(r.ok, source).toBe(true);
- }
- });
-
- it('a materialized value is type-checked like any other — the widening does not skip checkType', () => {
- // Single quotes get it PAST the marker; they do not get it past the
- // manifest. A string where an array is declared is still a mismatch.
- const r = compile(``, manifest);
- expect(r.diagnostics).toEqual([
- expect.objectContaining({ code: 'type-mismatch', tag: 'list-view' }),
- ]);
- });
-});
-
-describe('#6614 Q1-A — the far side of the boundary is still refused', () => {
- /** Every one of these must stay the deferred marker, byte-for-byte. */
- const REFUSED: Array<[label: string, braced: string]> = [
- ['a bare identifier', `columns`],
- ['member access', `ctx.user.name`],
- ['a function call', `getColumns()`],
- ['a method call on a literal', `['a'].concat(b)`],
- ['the card-quoted expression', `rows.map((r) => r.name)`],
- ['an arrow function', `() => 1`],
- ['arithmetic', `1 + 2`],
- ['a literal that merely STARTS the expression', `['a'] + x`],
- ['a ternary', `a ? b : c`],
- ['logical short-circuit', `flag && ['a']`],
- ['a template literal', '`col-${n}`'],
- ['an array spread', `[...cols]`],
- ['an object spread', `{...base, pageSize: 25}`],
- ['a trailing comma in an array', `['a',]`],
- ['a trailing comma in an object', `{a: 1,}`],
- ['an array hole', `[,1]`],
- ['a block comment', `/* cols */ ['a']`],
- ['a line comment', `['a'] // cols`],
- ['undefined', `undefined`],
- ['NaN', `NaN`],
- ['Infinity', `Infinity`],
- ['a leading plus', `+1`],
- ['a bare fractional point', `.5`],
- ['a trailing decimal point', `1.`],
- ['a hex number', `0x1f`],
- ['an octal-ish number', `010`],
- ['a non-JSON escape', `'\\x41'`],
- ['an unterminated string', `['a`],
- ['a computed key', `{[k]: 1}`],
- ['a keyword glued to an identifier', `nullish`],
- ['an empty brace', ``],
- ];
-
- it.each(REFUSED)('%s stays the deferred `{ $expr }` marker', (_label, braced) => {
- expect(interpretBrace(braced)).toEqual({ $expr: braced.trim() });
- });
-
- it('a refused value still draws the warning-severity `inert-expression` diagnostic', () => {
- const r = compile(
- ` r.name)} />`,
- manifest,
- );
- expect(r.diagnostics).toEqual([
- expect.objectContaining({
- severity: 'warning',
- code: 'inert-expression',
- tag: 'list-view',
- message: expect.stringContaining('"columns"'),
- }),
- ]);
- expect(r.tree?.columns).toEqual({ $expr: 'rows.map((r) => r.name)' });
- // ⛔ Q2 is NOT this change: the render-side severity stays `warning`, and
- // the page still compiles. Escalation belongs at the save gate.
- expect(r.ok).toBe(true);
- });
-
- it('the diagnostic no longer advises a spelling that now works', () => {
- const r = compile(``, manifest);
- const message = r.diagnostics[0].message;
- // The pre-#6614 message said "write it as JSON (double-quoted strings and
- // keys)" and named `columns={['name','amount']}` as the WRONG form. That
- // spelling is now correct, so the old advice would send an author to edit
- // working source. Pin that it is gone.
- expect(message).not.toMatch(/double-quoted/);
- expect(message).toContain('LITERALS only');
- });
-});
-
-describe('#6614 Q1-A — strict JSON is byte-identical to before', () => {
- /**
- * The invariance is structural, not incidental: `interpretBrace` still calls
- * `JSON.parse` FIRST and untouched, so an input JSON accepts never reaches the
- * new reader at all. This pins the property that structure guarantees.
- */
- const STRICT_JSON = [
- `["name","amount"]`,
- `[{"field":"name","label":"Full Name"}]`,
- `{"pageSize":25}`,
- `{}`,
- `[]`,
- `"plain string"`,
- `25`,
- `-0`,
- `1e3`,
- `-1.5e-3`,
- `true`,
- `false`,
- `null`,
- `{"nested":{"deep":[1,2,{"x":null}]}}`,
- `"escaped \\" quote"`,
- `"\\u00e9"`,
- `{"__proto__":1}`,
- `{"a":1,"a":2}`,
- ];
-
- it.each(STRICT_JSON)('%s parses exactly as JSON.parse does', (source) => {
- expect(interpretBrace(source)).toEqual(JSON.parse(source));
- });
-
- it('-0 keeps its sign, as JSON.parse gives it', () => {
- expect(Object.is(interpretBrace(`-0`), -0)).toBe(true);
- });
-
- it('a strict-JSON page compiles with the same zero diagnostics it always did', () => {
- for (const source of [
- ``,
- ``,
- ``,
- ``,
- ]) {
- const r = compile(source, manifest);
- expect(r.diagnostics, source).toEqual([]);
- expect(r.ok, source).toBe(true);
- }
- });
-});
-
-describe('#6614 Q1-A — the widening opens no execution or pollution lever', () => {
- it('an authored `__proto__` key becomes an OWN property, never the prototype', () => {
- // ⚠️ This is the one place where matching JS-literal semantics would be
- // WRONG. A real JS object literal `{__proto__: {...}}` invokes the prototype
- // setter; this tier exists to make untrusted source safe to parse, so the
- // unquoted-key widening must follow `JSON.parse` (own data property) and not
- // JS. A plain `out[key] = value` in the reader would fail this test.
- const value = interpretBrace(`{__proto__: {polluted: true}}`) as Record;
- expect(Object.prototype.hasOwnProperty.call(value, '__proto__')).toBe(true);
- expect(Object.getPrototypeOf(value)).toBe(Object.prototype);
- expect(({} as Record).polluted).toBeUndefined();
- });
-
- it('a key named like a prototype member does not read through to it', () => {
- const value = interpretBrace(`{constructor: 'x', toString: 'y'}`) as Record;
- expect(value.constructor).toBe('x');
- expect(value.toString).toBe('y');
- });
-
- it('duplicate keys take the last value, as both JSON and a JS literal do', () => {
- expect(interpretBrace(`{a: 1, a: 2}`)).toEqual({ a: 2 });
- });
-
- it('event handlers and raw-HTML injection stay forbidden regardless of the value grammar', () => {
- const r = parseJsx(``);
- expect(r.diagnostics.map((d) => d.code)).toEqual(['forbidden-attr', 'forbidden-attr']);
- });
-});
diff --git a/ui/packages/sdui-parser/src/__tests__/member-kind-8067.test.ts b/ui/packages/sdui-parser/src/__tests__/member-kind-8067.test.ts
deleted file mode 100644
index 6471d36923f..00000000000
--- a/ui/packages/sdui-parser/src/__tests__/member-kind-8067.test.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-/**
- * ObjectUI — `ManifestInput.of`, the coarse MEMBER kind (objectui#8067)
- *
- * The mechanism half of the card. The contract half — every declared `of` in
- * the repository compared against what `ComponentPropsMap[type]` accepts at the
- * member position — lives in
- * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`.
- *
- * WHY THE KEY EXISTS. `type: 'array'` said a value was a list and stopped
- * there, so a member that drifted from the contract was invisible to every
- * layer that reads a declaration. `page:header.actions` is the measured cost:
- * spec `z.array(z.string())` ("Action IDs"), a renderer reading the members as
- * `ActionDef` OBJECTS, and the repo-wide parity gate green for the whole life of
- * the drift because both sides had the key and neither could say what was
- * inside it.
- *
- * WHAT IS PINNED HERE, and they are different facts:
- *
- * 1. THE READER EXISTS — a member no declared arm accepts is REPORTED. This is
- * the fact objectui#5905 is the cautionary precedent for: five
- * `ComponentInput` keys were declared and read by nothing, and every gate
- * stayed green. A `of` that nothing reads is that defect with a new name.
- * 2. BACKWARD COMPATIBILITY — an input that declares no `of` is validated,
- * serialized and typed byte-identically to before the key existed. That is
- * what makes this an extension of `sdui.manifest.json` rather than a new
- * version of it.
- * 3. THE COARSE CEILING HOLDS ONE LEVEL DOWN — `of` names a KIND, never a
- * domain and never a member's KEYS, so `of: 'object'` clears every object
- * whatever it contains. The maintainer ruling of 2026-08-17 quoted on
- * `ComponentInput.type` ("SPEC IS THE SOLE JUDGE OF VALUES") is untouched.
- * 4. THE OTHER CONSUMERS — the serializer canonicalizes `of` exactly as it
- * canonicalizes `type`, and the JSX codegen narrows the emitted element
- * type from it. A member kind the validator honours but the `.d.ts`
- * contradicts would just move an author's false error one layer over.
- */
-import { describe, expect, it } from 'vitest';
-import { generateDts, manifestFromConfigs, validateTree } from '../index.js';
-import type { Manifest, SchemaElement } from '../types.js';
-
-const one = (inputs: Parameters[0][number]['inputs']): Manifest =>
- manifestFromConfigs([{ type: 'probe', namespace: 'ui', inputs }]);
-
-const diags = (manifest: Manifest, node: Record) =>
- validateTree({ type: 'probe', ...node } as SchemaElement, manifest).diagnostics;
-
-const codes = (manifest: Manifest, node: Record): string[] =>
- diags(manifest, node).map((d) => d.code);
-
-describe('a declared member kind is READ — the objectui#5905 standard', () => {
- const manifest = one([{ name: 'actions', type: 'array', of: 'string' }]);
-
- it('clears an array whose members are all of the declared kind', () => {
- expect(codes(manifest, { actions: ['clone', 'convert'] })).toEqual([]);
- });
-
- it('reports a member of the wrong kind — the drift this key exists to catch', () => {
- // Verbatim the `page:header.actions` drift: spec says action IDs, the value
- // carries `ActionDef` objects. Before `of` this node was clean.
- expect(codes(manifest, { actions: [{ name: 'clone' }] })).toEqual(['member-type-mismatch']);
- });
-
- it('names every offending position in ONE diagnostic, not one per member', () => {
- const [diagnostic] = diags(manifest, { actions: ['clone', 42, {}, 'convert'] });
- expect(diagnostic.code).toBe('member-type-mismatch');
- expect(diagnostic.severity).toBe('warning');
- expect(diagnostic.message).toBe(
- ' prop "actions" expected every member to be a string — [1], [2] are not',
- );
- });
-
- it('an empty container conforms — there is no member to refuse', () => {
- expect(codes(manifest, { actions: [] })).toEqual([]);
- });
-
- it('reads the ARRAY form of `of` the same way it reads the array form of `type`', () => {
- // The rot this pins: a reader that forgot `Array.isArray` would fall through
- // and report NOTHING, which looks exactly like a clean value.
- const union = one([{ name: 'items', type: 'array', of: ['string', 'object'] }]);
- expect(codes(union, { items: ['a', { b: 1 }] })).toEqual([]);
- expect(codes(union, { items: [42] })).toEqual(['member-type-mismatch']);
- });
-
- it('judges an OBJECT container by its VALUES — the map half of the key', () => {
- const map = one([{ name: 'labels', type: 'object', of: 'string' }]);
- expect(codes(map, { labels: { en: 'Account', 'zh-CN': '客户' } })).toEqual([]);
- const [diagnostic] = diags(map, { labels: { en: 'Account', count: 3 } });
- expect(diagnostic.message).toBe(
- ' prop "labels" expected every member to be a string — [count] is not',
- );
- });
-});
-
-describe('the container verdict comes first', () => {
- const manifest = one([{ name: 'actions', type: 'array', of: 'string' }]);
-
- it('a wrong CONTAINER draws one diagnostic, not two', () => {
- // One mistake, one report. A member walk over a value that is not even the
- // declared container would name positions of a shape the author never wrote.
- expect(codes(manifest, { actions: 'clone' })).toEqual(['type-mismatch']);
- });
-
- it('a value that satisfied a NON-container arm of a union is not member-judged', () => {
- const union = one([{ name: 'actions', type: ['string', 'array'], of: 'string' }]);
- expect(codes(union, { actions: 'clone' })).toEqual([]);
- expect(codes(union, { actions: [42] })).toEqual(['member-type-mismatch']);
- });
-});
-
-describe('the coarse ceiling holds one level down', () => {
- it('`of: \'object\'` clears every object, whatever keys it carries', () => {
- // `of` is a KIND, never a member's KEYS. Which keys an element must have is
- // spec's question and a per-block pin's, exactly as the value DOMAIN of a
- // `number` arm is (`ComponentInput.type`, maintainer ruling 2026-08-17).
- const manifest = one([{ name: 'sections', type: 'array', of: 'object' }]);
- expect(codes(manifest, { sections: [{ anything: 'at all' }, {}] })).toEqual([]);
- expect(codes(manifest, { sections: ['sales_info'] })).toEqual(['member-type-mismatch']);
- });
-
- it('an `enum` member arm raises its severity to error, as it does one level up', () => {
- const manifest = one([
- { name: 'types', type: 'array', of: 'enum', enum: ['comment', 'task'] },
- ]);
- expect(codes(manifest, { types: ['comment', 'task'] })).toEqual([]);
- const [diagnostic] = diags(manifest, { types: ['comment', 'email'] });
- expect(diagnostic.code).toBe('member-type-mismatch');
- expect(diagnostic.severity).toBe('error');
- });
-});
-
-describe('an input that declares no member kind is unchanged', () => {
- // The backward-compatibility half. Every input published before this key
- // existed says exactly this, so anything that moves here moves for all of
- // them.
- const manifest = one([{ name: 'actions', type: 'array' }]);
-
- it('draws no member diagnostic on any member', () => {
- expect(codes(manifest, { actions: [42, {}, 'clone', null] })).toEqual([]);
- });
-
- it('publishes no `of` at all — the serialized entry is byte-identical', () => {
- expect(JSON.stringify(manifest.components.probe.inputs[0])).toBe(
- '{"name":"actions","type":"array"}',
- );
- });
-
- it('emits the unnarrowed element type', () => {
- expect(generateDts(manifest)).toContain('actions?: unknown[];');
- });
-});
-
-describe('the serializer canonicalizes `of` exactly as it canonicalizes `type`', () => {
- it('collapses a one-element array to the bare kind', () => {
- expect(one([{ name: 'a', type: 'array', of: ['string'] }]).components.probe.inputs[0].of).toBe(
- 'string',
- );
- });
-
- it('drops an off-vocabulary arm rather than inventing `string` for it', () => {
- expect(
- one([{ name: 'a', type: 'array', of: ['string', 'nonsense'] }]).components.probe.inputs[0].of,
- ).toBe('string');
- });
-
- it('dedupes a repeated arm', () => {
- expect(
- one([{ name: 'a', type: 'array', of: ['string', 'object', 'string'] }]).components.probe
- .inputs[0].of,
- ).toEqual(['string', 'object']);
- });
-
- it('does NOT invent a member kind for an undeclared `of`', () => {
- // `canonicalizeInputType`'s no-arms fallback is `'string'`, so routing an
- // undefined through it would make every array in every manifest claim
- // string members it was never told it had.
- expect(one([{ name: 'a', type: 'array' }]).components.probe.inputs[0].of).toBeUndefined();
- });
-});
-
-describe('the JSX authoring surface narrows with it', () => {
- it('types the elements rather than emitting `unknown[]`', () => {
- const dts = generateDts(one([{ name: 'actions', type: 'array', of: 'string' }]));
- expect(dts).toContain('actions?: string[];');
- expect(dts).not.toContain('actions?: unknown[];');
- });
-
- it('types an object map\'s values', () => {
- expect(generateDts(one([{ name: 'labels', type: 'object', of: 'string' }]))).toContain(
- 'labels?: Record;',
- );
- });
-
- it('emits a parenthesised union for a multi-arm member declaration', () => {
- expect(generateDts(one([{ name: 'items', type: 'array', of: ['string', 'object'] }]))).toContain(
- 'items?: (string | Record)[];',
- );
- });
-
- it('a `slot`-only member declaration types no member — never a silent `string`', () => {
- expect(generateDts(one([{ name: 'items', type: 'array', of: 'slot' }]))).toContain(
- 'items?: unknown[];',
- );
- });
-});
diff --git a/ui/packages/sdui-parser/src/__tests__/node-slot-walk-11170.test.ts b/ui/packages/sdui-parser/src/__tests__/node-slot-walk-11170.test.ts
deleted file mode 100644
index 8e71e8f11f2..00000000000
--- a/ui/packages/sdui-parser/src/__tests__/node-slot-walk-11170.test.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * ObjectUI
- * Copyright (c) 2024-present ObjectStack Inc.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- */
-
-/**
- * `validateTree` descends the node slots a component's manifest entry carries
- * (objectui#11170), and `manifestFromConfigs` projects them from the one
- * declaration it is handed through `slotsFor`.
- *
- * The subject under every slot is a tag the manifest does not know, so what
- * each case measures is REACH: `unknown-component` on the same node, drawn or
- * not drawn, decided by whether its position is a declared slot. The
- * declaration used here is a stand-in with the real grammar — this package
- * imports nothing at runtime — and the components-side census for the same
- * card holds this walk and `@object-ui/types`' `nodeSlotValues` to one answer
- * over the live declaration.
- */
-import { describe, expect, it } from 'vitest';
-import { manifestFromConfigs, validateTree } from '../index.js';
-import type { Diagnostic, SchemaElement } from '../types.js';
-
-const SLOTS: Record = {
- dialog: [{ path: 'trigger' }, { path: 'content' }, { path: 'footer' }],
- tabs: [{ path: 'items[].content' }],
- carousel: [{ path: 'items[]' }],
- 'report-viewer': [{ path: 'report.sections[].content' }],
- 'page:card': [{ path: 'footer' }, { path: 'body', retired: true }],
-};
-const slotsFor = (type: string) => SLOTS[type] ?? [];
-
-const CONFIGS = [
- { type: 'dialog', namespace: 'ui', inputs: [{ name: 'trigger', type: 'slot' }, { name: 'content', type: 'slot' }, { name: 'footer', type: 'slot' }] },
- { type: 'tabs', namespace: 'ui', inputs: [{ name: 'items', type: 'array' }] },
- { type: 'carousel', namespace: 'ui', inputs: [{ name: 'items', type: 'array' }] },
- { type: 'report-viewer', namespace: 'plugin-report', inputs: [{ name: 'report', type: 'code' }] },
- { type: 'page:card', namespace: 'page', inputs: [{ name: 'children', type: 'slot' }, { name: 'footer', type: 'slot' }] },
- { type: 'text', namespace: 'ui', inputs: [{ name: 'content', type: 'string' }] },
-] as unknown as Parameters[0];
-
-const withSlots = manifestFromConfigs(CONFIGS, { slotsFor });
-const withoutSlots = manifestFromConfigs(CONFIGS);
-
-const UNKNOWN = { type: 'nope-11170' };
-const codes = (node: unknown, manifest = withSlots): string[] =>
- validateTree(node as SchemaElement, manifest).diagnostics.map((d: Diagnostic) => d.code);
-const unknownCount = (node: unknown, manifest = withSlots) =>
- codes(node, manifest).filter((c) => c === 'unknown-component').length;
-
-describe('manifestFromConfigs projects the declaration it is handed (objectui#11170)', () => {
- it('carries each entry’s non-retired slot paths, and no key at all without the option', () => {
- expect(withSlots.components['dialog']!.slots).toEqual(['trigger', 'content', 'footer']);
- expect(withSlots.components['tabs']!.slots).toEqual(['items[].content']);
- // The retired `body` is left out of the authoring tier's projection.
- expect(withSlots.components['page:card']!.slots).toEqual(['footer']);
- // An entry with no slots publishes none (`undefined`, which `JSON.stringify`
- // drops — the same contract `tier` and `of` keep), so the serialised entry
- // is byte-identical to one built before the key existed.
- expect(withSlots.components['text']!.slots).toBeUndefined();
- expect(JSON.stringify(withSlots.components['text'])).toBe(JSON.stringify(withoutSlots.components['text']));
- expect(JSON.stringify(withoutSlots)).not.toContain('"slots"');
- });
-});
-
-describe('validateTree reaches slot-held nodes exactly where the entry declares a slot', () => {
- it('a direct slot, one node or a list', () => {
- expect(unknownCount({ type: 'dialog', content: UNKNOWN })).toBe(1);
- expect(unknownCount({ type: 'dialog', content: [{ type: 'text' }, UNKNOWN], footer: UNKNOWN })).toBe(2);
- // The same node under an undeclared key is not reached.
- expect(unknownCount({ type: 'dialog', header: UNKNOWN })).toBe(0);
- });
-
- it('a panel list and a trailing `[]`', () => {
- expect(unknownCount({ type: 'tabs', items: [{ value: 'a', content: UNKNOWN }, { value: 'b', content: [UNKNOWN, UNKNOWN] }] })).toBe(3);
- expect(unknownCount({ type: 'carousel', items: [UNKNOWN, [UNKNOWN]] })).toBe(2);
- expect(unknownCount({ type: 'report-viewer', report: { sections: [{ content: UNKNOWN }] } })).toBe(1);
- });
-
- it('nested: a slot under a child under a slot', () => {
- const doc = { type: 'dialog', content: { type: 'tabs', items: [{ value: 'a', content: UNKNOWN }] } };
- expect(unknownCount(doc)).toBe(1);
- });
-
- it('a retired position is not walked by this tier; its refusal by name stands (objectui#6771)', () => {
- const diagnostics = validateTree({ type: 'page:card', body: [UNKNOWN] } as unknown as SchemaElement, withSlots).diagnostics;
- expect(diagnostics.filter((d) => d.code === 'unknown-component')).toEqual([]);
- expect(diagnostics.map((d) => d.code)).toContain('unknown-prop');
- expect(diagnostics.find((d) => d.code === 'unknown-prop')?.message).toContain('retired by objectui#6771');
- });
-
- it('a manifest without slots walks `children` alone — the pre-11170 reach, unchanged', () => {
- expect(unknownCount({ type: 'dialog', content: UNKNOWN }, withoutSlots)).toBe(0);
- expect(unknownCount({ type: 'dialog', children: [UNKNOWN] }, withoutSlots)).toBe(1);
- });
-
- it('an unknown component’s own slots are not consulted: one diagnostic, on it', () => {
- expect(codes({ type: 'nope-outer', content: UNKNOWN })).toEqual(['unknown-component']);
- });
-
- it('a string in a slot is legal and draws nothing', () => {
- expect(codes({ type: 'dialog', trigger: 'Open', content: ['plain', { type: 'text' }] })).toEqual([]);
- });
-});
diff --git a/ui/packages/sdui-parser/src/__tests__/provenance.test.ts b/ui/packages/sdui-parser/src/__tests__/provenance.test.ts
deleted file mode 100644
index d0908c3e4e7..00000000000
--- a/ui/packages/sdui-parser/src/__tests__/provenance.test.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-/**
- * html-tier provenance marking (objectui#4000).
- *
- * The parser stamps every element it emits, so a renderer can tell an html-tier
- * node from a JSON-authored one and scope authoring advice to the surface the
- * advice is actually about. What is pinned here is the marker's four load-
- * bearing properties, each of which has a distinct failure mode:
- *
- * - it is SET on what the parser produces, at every depth;
- * - it SURVIVES an object spread — `SchemaRenderer` hands renderers a shallow
- * copy, so a marker that did not survive would restore the original bug in
- * the render path only, silently;
- * - it is INVISIBLE to JSON — so it cannot be persisted, and therefore cannot
- * be replayed by an authored document that wants the exemption;
- * - it is NOT set on JSON reached through a braced attribute — that JSON was
- * hand-written, so the JSON surface's advice does apply to it.
- */
-
-import { describe, expect, it } from 'vitest';
-import { parseJsx } from '../parse.js';
-import { HTML_TIER_NODE, isHtmlTierNode, markHtmlTierNode } from '../provenance.js';
-import type { SchemaElement } from '../types.js';
-
-describe('html-tier provenance (#4000)', () => {
- it('marks every element the parser emits, at every depth', () => {
- const { tree } = parseJsx('deep
');
- const root = tree as SchemaElement;
- expect(isHtmlTierNode(root)).toBe(true);
-
- const child = root.children![0] as SchemaElement;
- expect(isHtmlTierNode(child)).toBe(true);
- const grandchild = child.children![0] as SchemaElement;
- expect(isHtmlTierNode(grandchild)).toBe(true);
- });
-
- it('survives the shallow copy a renderer receives', () => {
- // `SchemaRenderer` evaluates expressions against `{ ...schema }` and passes
- // the COPY down. Object spread carries own enumerable symbol keys and drops
- // non-enumerable ones — so this assertion, not the definition site, is what
- // actually holds the marker's descriptor in place.
- const { tree } = parseJsx('');
- const copy = { ...(tree as SchemaElement) };
- expect(isHtmlTierNode(copy)).toBe(true);
-
- // …and through a second hop, which is what nesting plus scoped styling does.
- expect(isHtmlTierNode({ ...copy, className: 'a scoped' })).toBe(true);
- });
-
- it('is invisible to JSON, to Object.keys and to for...in', () => {
- const { tree } = parseJsx('');
- const root = tree as SchemaElement;
-
- expect(JSON.parse(JSON.stringify(root))).toEqual({ type: 'div', className: 'a' });
- // ⚠️ Key ORDER, not key SET: `type` moved from first to last when the
- // discriminator collision was closed (objectui#7235) and the node became
- // `{ ...props, type: tag }`. The set, the values and the round-trip are
- // unchanged — this line is the one visible consequence of that reversal,
- // and it is here rather than adjusted away because an order pin that
- // silently follows the implementation pins nothing.
- expect(Object.keys(root)).toEqual(['className', 'type']);
- const seen: string[] = [];
- for (const k in root) seen.push(k);
- expect(seen).toEqual(['className', 'type']);
-
- // The round-trip is the anti-forgery pin: a persisted tree comes back
- // unmarked, so a saved (or AI-generated) document cannot carry the
- // exemption back in with it.
- expect(isHtmlTierNode(JSON.parse(JSON.stringify(root)))).toBe(false);
- });
-
- it('survives the reversed spread — the marking is applied AFTER every key', () => {
- // objectui#7235 reversed `markHtmlTierNode({ type: tag, ...props })` into
- // `markHtmlTierNode({ ...props, type: tag })`. The wrapper is objectui-only
- // (objectstack's copy of this parser builds a bare object), so the reversal
- // is the one edit in that port where the stamp could have been dropped —
- // e.g. by moving the call inside the literal, or by spreading a marked
- // object into a fresh unmarked one. Pinned at every depth and for the
- // shapes the reversal actually moves: props present, props absent, and a
- // container whose children were attached after the stamp.
- const { tree } = parseJsx('');
- const root = tree as SchemaElement;
-
- expect(isHtmlTierNode(root)).toBe(true);
- expect(root.type).toBe('flex');
- // `children` is assigned after `markHtmlTierNode` returns; the stamp is on
- // the same object, so it must still read true once they are attached.
- expect(root.children).toHaveLength(2);
- for (const child of root.children as SchemaElement[]) {
- expect(isHtmlTierNode(child)).toBe(true);
- }
- // The propless element — `{ ...{}, type: tag }` — is the degenerate case
- // of the reversal and has its own way of going wrong.
- expect(isHtmlTierNode((root.children as SchemaElement[])[1])).toBe(true);
- });
-
- it('does not mark JSON smuggled in through a braced attribute', () => {
- // The parser produces the element; the object inside the braces is
- // hand-written JSON that merely rode along. Marking it would hand the
- // exemption to authored metadata — the precise over-reach this mechanism
- // exists to avoid.
- const { tree } = parseJsx('');
- const root = tree as SchemaElement;
- expect(isHtmlTierNode(root)).toBe(true);
-
- const smuggled = (root.body as SchemaElement[])[0];
- expect(smuggled.type).toBe('div');
- expect(isHtmlTierNode(smuggled)).toBe(false);
- });
-
- it('reads false for anything unmarked, and never throws on non-objects', () => {
- expect(isHtmlTierNode({ type: 'div' })).toBe(false);
- expect(isHtmlTierNode(null)).toBe(false);
- expect(isHtmlTierNode(undefined)).toBe(false);
- expect(isHtmlTierNode('div')).toBe(false);
- expect(isHtmlTierNode(7)).toBe(false);
- // A string-keyed lookalike is not provenance — there is no fallback read.
- expect(isHtmlTierNode({ type: 'div', _provenance: 'html' })).toBe(false);
- });
-
- it('keys the marker in the global registry, so duplicate instances agree', () => {
- // This package ships ESM and CJS and is consumed from several packages. Two
- // module instances holding two local symbols would not compare equal, and
- // the failure mode of that mismatch is the original bug returning with no
- // error anywhere. A registry key cannot drift.
- expect(HTML_TIER_NODE).toBe(Symbol.for('@object-ui/sdui-parser.html-tier-node'));
- expect(isHtmlTierNode(markHtmlTierNode({ type: 'div' }))).toBe(true);
- });
-});
diff --git a/ui/packages/sdui-parser/src/__tests__/tier.test.ts b/ui/packages/sdui-parser/src/__tests__/tier.test.ts
deleted file mode 100644
index 70b524dbf47..00000000000
--- a/ui/packages/sdui-parser/src/__tests__/tier.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { assertFullyLoaded, generateBlockList, generateDts, manifestFromConfigs } from '../index.js';
-
-const configs = [
- { type: 'object-table', namespace: 'plugin-grid', tier: 'public' as const, inputs: [
- { name: 'object', type: 'string', required: true, binding: 'object' as const }, { name: 'pageSize', type: 'number' } ] },
- { type: 'flex', namespace: 'ui', tier: 'public' as const, isContainer: true, inputs: [{ name: 'gap', type: 'number' }] },
- { type: 'studio-metadata-admin', namespace: 'app-shell', tier: 'internal' as const, inputs: [] },
- { type: 'debug-overlay', namespace: 'app-shell', inputs: [] },
-];
-
-describe('M4: capability vs contract (tier)', () => {
- it('publicOnly keeps only the curated tier', () => {
- expect(Object.keys(manifestFromConfigs(configs, { publicOnly: true }).components).sort())
- .toEqual(['flex', 'object-table']);
- });
- it('unfiltered keeps the full capability', () => {
- expect(Object.keys(manifestFromConfigs(configs).components)).toHaveLength(4);
- });
- it('generates the block list 清单 with bindings', () => {
- const list = generateBlockList(manifestFromConfigs(configs, { publicOnly: true }));
- expect(list).toContain('# SDUI public blocks (2)');
- expect(list).toContain('object:object');
- });
- it('codegen stays consistent with the public manifest', () => {
- expect(generateDts(manifestFromConfigs(configs, { publicOnly: true })))
- .toContain('"object-table": ObjectTableProps;');
- });
-});
-
-describe('assertFullyLoaded — generators must not emit lazy stubs (objectui#2953)', () => {
- it('passes when every config is a loaded registration', () => {
- expect(() => assertFullyLoaded(configs)).not.toThrow();
- });
-
- it('names every stub, so the fix is "import these plugins"', () => {
- const withStubs = [
- ...configs,
- { type: 'object-kanban', namespace: 'plugin-kanban', lazy: true },
- { type: 'object-map', namespace: 'plugin-map', lazy: true },
- ];
- // The silent alternative is what this exists to prevent: both blocks reach
- // the manifest with `inputs: []`, which reads as "takes no props" and turns
- // every prop an author writes into an unknown-prop diagnostic.
- expect(manifestFromConfigs(withStubs).components['object-kanban'].inputs).toEqual([]);
- expect(() => assertFullyLoaded(withStubs)).toThrow(/object-kanban, object-map/);
- expect(() => assertFullyLoaded(withStubs)).toThrow(/2 not-yet-loaded lazy block/);
- });
-});
diff --git a/ui/packages/sdui-parser/src/__tests__/type-attribute-collision-7235.test.ts b/ui/packages/sdui-parser/src/__tests__/type-attribute-collision-7235.test.ts
deleted file mode 100644
index 2a0c99aaec9..00000000000
--- a/ui/packages/sdui-parser/src/__tests__/type-attribute-collision-7235.test.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-/**
- * html tier: an authored `type=` attribute collides with the envelope's own
- * discriminator, and is REFUSED at parse time (objectui#7235 — the port of
- * objectstack#13957, maintainer ruling 2026-09-01, ADR-0080 amendment).
- *
- * The two outcomes this replaces are pinned side by side on purpose, because
- * they are the reason the refusal is at parse rather than at the warning layer:
- * one of them produced NO diagnostic at all, and the other produced a loud one
- * pointing somewhere else. A test that only asserted "an error is raised" would
- * pass on the second case before this change.
- *
- * ⚠️ This copy is the RENDERER's (and the console's live edit preview); the
- * save gate runs objectstack's. Until this landed the two differed in accepted
- * grammar, so an author got no diagnostic while typing and a refusal on save.
- */
-import { describe, expect, it } from 'vitest';
-import { compile, manifestFromConfigs } from '../index.js';
-import { parseJsx } from '../parse.js';
-import { isHtmlTierNode } from '../provenance.js';
-import type { SchemaElement } from '../types.js';
-
-const manifest = manifestFromConfigs([
- { type: 'flex', namespace: 'ui', isContainer: true, inputs: [
- { name: 'direction', type: 'enum', enum: ['row', 'col'] },
- { name: 'gap', type: 'number' },
- { name: 'children', type: 'slot' },
- ] },
- { type: 'grid', namespace: 'ui', isContainer: true, inputs: [{ name: 'columns', type: 'number' }, { name: 'children', type: 'slot' }] },
- { type: 'object-chart', namespace: 'plugin-charts', isContainer: false, inputs: [
- { name: 'objectName', type: 'string', binding: 'object' },
- ] },
-]);
-
-/** The refusal, as the author sees it: one diagnostic naming BOTH names. */
-const refusals = (source: string) =>
- compile(source, manifest).diagnostics.filter((d) => d.code === 'forbidden-attr');
-
-describe('an authored `type` attribute is refused (the discriminator collision)', () => {
- it('refuses when the value names ANOTHER REGISTERED type — the previously SILENT case', () => {
- const r = compile('', manifest);
-
- // Before this change: `grid` resolved in the manifest, every check passed,
- // and the page rendered a grid where the author wrote a flex — zero
- // diagnostics of any severity.
- expect(r.ok).toBe(false);
- expect(r.diagnostics).toContainEqual(
- expect.objectContaining({ severity: 'error', code: 'forbidden-attr', tag: 'flex' }),
- );
-
- // ONE diagnostic, naming BOTH the tag and the attribute (the ruled shape).
- const [only, ...rest] = refusals('');
- expect(rest).toEqual([]);
- expect(only.message).toContain('"type"');
- expect(only.message).toContain('');
-
- // …AND the tree still builds as the tag the author wrote. Asserting only
- // the diagnostic would leave the other half of this defect in place: the
- // silent redirect is the node carrying `grid` as its discriminator, and a
- // loud diagnostic beside a redirected node is still a redirected node.
- expect(r.tree?.type).toBe('flex');
- expect(r.tree).not.toHaveProperty('type', 'grid');
- });
-
- it('refuses when the value names NOTHING registered — the previously MISDIRECTED case', () => {
- // `` is the shape a react-tier author carries
- // across: on that tier `type` is the chart family. Before this change the
- // only diagnostic was `unknown-component` naming `"bar"`, which reads as a
- // missing plugin rather than as an attribute that should not be there.
- const source = '';
- const r = compile(source, manifest);
-
- expect(r.ok).toBe(false);
- expect(refusals(source)).toHaveLength(1);
- expect(r.diagnostics.map((d) => d.code)).not.toContain('unknown-component');
- // The diagnostic names the ATTRIBUTE, never the value the author gave it.
- expect(refusals(source)[0].message).toContain('"type"');
- expect(refusals(source)[0].message).not.toContain('"bar"');
- expect(r.tree?.type).toBe('object-chart');
- });
-
- it('refuses a BARE `type` attribute too — the check is on the name, not the value', () => {
- expect(refusals('')).toHaveLength(1);
- expect(refusals('')).toHaveLength(1);
- });
-
- it('refuses it on a NESTED element, not only on the root', () => {
- const found = refusals('');
- expect(found).toHaveLength(1);
- expect(found[0].tag).toBe('grid');
- expect(found[0].message).toContain('');
- });
-
- it('leaves a clean element alone — no regression', () => {
- const r = compile('', manifest);
- expect(r.ok).toBe(true);
- expect(r.diagnostics).toEqual([]);
- expect(r.tree).toMatchObject({ type: 'flex', direction: 'row', gap: 4 });
- });
-
- it('a node with NO `type` attribute keeps the same keys, values and provenance', () => {
- // The regression guard, stated as what was actually measured. ⚠️ It is NOT
- // byte-identical to the pre-change output: reversing the spread moves
- // `type` from FIRST key to LAST, so `JSON.stringify` emits the same pairs
- // in a different order. The key SET, every value, the children and the
- // html-tier marker are unchanged, and that is the whole of the difference —
- // `provenance.test.ts` carries the order pin itself.
- const { tree, diagnostics } = parseJsx('');
- const root = tree as SchemaElement;
-
- expect(diagnostics).toEqual([]);
- expect(root).toMatchObject({
- type: 'flex',
- direction: 'col',
- gap: 8,
- wrap: true,
- children: [{ type: 'grid', columns: 3 }],
- });
- expect(Object.keys(root).sort()).toEqual(['children', 'direction', 'gap', 'type', 'wrap']);
- expect(isHtmlTierNode(root)).toBe(true);
- expect(isHtmlTierNode(root.children![0])).toBe(true);
- });
-});
-
-/**
- * ⚠️ Read this before trusting the block below as coverage of the spread order.
- *
- * The order fix is DEFENSE IN DEPTH, which means the refusal makes it
- * unobservable through the public API: measured by ablation on the committed
- * tree, restoring `{ type: tag, ...props }` while LEAVING the refusal in place
- * keeps every test in this file GREEN, because the refused attribute never
- * reaches `props` to be spread. What turns them red is removing the refusal.
- *
- * So these tests pin the discriminator's identity, not the statement that
- * produces it. That is not a gap to paper over with a stronger-sounding
- * assertion — it is the ruled relationship between the two halves («拒绝使覆盖
- * 不可达,顺序修复是防御纵深»), and it is stated here so the next author does
- * not read a green suite as proof the order is load-bearing on its own.
- */
-describe('spread order — defense in depth behind the refusal', () => {
- it('keeps the TAG as the discriminator even when a `type` attribute was authored', () => {
- // Parser-level, with no manifest: the refusal is a diagnostic, and the tree
- // is still built. What must never happen is the tree carrying the AUTHOR's
- // value as its discriminator — that is the silent redirect itself.
- const { tree, diagnostics } = parseJsx('');
-
- expect(tree?.type).toBe('flex');
- expect(diagnostics.map((d) => d.code)).toContain('forbidden-attr');
- });
-
- it('and does not let the refused value land under its own name either', () => {
- const { tree } = parseJsx('');
- expect(Object.values(tree ?? {})).not.toContain('grid');
- // Nor under the `__forbidden_` sentinel the OTHER refusals park in
- // `props` — that sentinel draws an `unknown-prop` naming a key nobody
- // wrote, which is the second diagnostic the ruled shape refuses to emit.
- expect(Object.keys(tree ?? {})).not.toContain('__forbidden_type');
- });
-
- it('every other prop still survives the reordered spread', () => {
- const { tree } = parseJsx('');
- expect(tree).toMatchObject({
- type: 'flex',
- direction: 'col',
- gap: 8,
- wrap: true,
- children: [{ type: 'grid', columns: 3 }],
- });
- });
-});
diff --git a/ui/packages/sdui-parser/src/body-dialect.ts b/ui/packages/sdui-parser/src/body-dialect.ts
deleted file mode 100644
index 62347e28f1c..00000000000
--- a/ui/packages/sdui-parser/src/body-dialect.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * ObjectUI — the retired `body` child-list dialect (objectui#6771)
- *
- * `body` was a declared twin of `children` on `BaseSchema`, and for a long
- * while it was the ONLY child-list key a dozen registrations read. This tier
- * never knew it: `BASE_PROPS` lists `children` and not `body`, so the one
- * spelling that resolved drew `unknown-prop` — the same warning a typo draws,
- * on the tier built to accept AI-authored pages, where the diagnostic IS the
- * contract. The maintainer ruled the retirement (2026-09-01, option B): one
- * concept, one spelling, and the spelling is `children`.
- *
- * This module is the tier's half of that ruling. It REPLACES the diagnostic the
- * prop walk would otherwise emit for `body`, rather than adding to it — two
- * diagnostics for one mistake is the shape `checkMemberTypes` already refuses
- * (objectui#8067).
- *
- * WHY A REPLACEMENT AND NOT A BARE `unknown-prop`. After the retirement
- * "`` has no prop body" is TRUE — but it is the answer that sent authors
- * the wrong way in the first place. Every `body` node in this repository's own
- * docs, examples and READMEs was migrated in the same change, and an author
- * carrying the old spelling in from anywhere else needs the replacement NAMED,
- * not merely the key refused. The two codes below are the ruling's own words:
- * the tier teaches `children` only, and a `body` child list under a
- * non-container draws the same `not-a-container` the `children` spelling draws.
- *
- * SCOPE — asked INSIDE the `!input` branch, because its claim is about a key
- * nobody declares, so a
- * component that publishes its own `body` input keeps its declared type check
- * untouched. Re-derived against the registry rather than recalled: exactly one
- * registration in the tree declares an input named `body`, and it is
- * `record:alert` (`plugin-detail`, `register('alert', …, { namespace: 'record' })`),
- * whose `body` takes an inline translation map and is not this spelling.
- * Control on the same sweep: `name: 'children'` fires seven times.
- */
-
-import type { Diagnostic, ParsedSchemaNode } from './types.js';
-
-/** The child-list spelling objectui#6771 retired. `children` replaced it. */
-export const RETIRED_CHILD_LIST_KEY = 'body';
-
-const isPlainObject = (v: unknown): v is Record =>
- typeof v === 'object' && v !== null && !Array.isArray(v);
-
-/**
- * Is this value shaped like the child list the dialect used to carry?
- *
- * A list of nodes, or the bare single node `FlatContent` and `renderChildren`
- * have always accepted. A scalar is not — `body="hi"` is a dead key and nothing
- * more, so it does not get the containment sentence, which would name a shape
- * its author did not write.
- */
-const isChildList = (value: unknown): value is ParsedSchemaNode | ParsedSchemaNode[] =>
- Array.isArray(value) || isPlainObject(value);
-
-/**
- * The diagnostic for an authored `body`, or `null` when this module has nothing
- * to say about the key.
- *
- * @param tag the node's component type, for the message and the `tag` field
- * @param key the authored prop name
- * @param value its authored value
- * @param acceptsChildren whether the component declares the `children` slot
- * input — `acceptsChildren(comp)` in `./validate.ts`,
- * the ONE containment predicate this tier has. It used
- * to be `comp.isContainer`; objectui#9910 moved both
- * sites together so the retired spelling and the live
- * one can never disagree about who takes a list.
- */
-export function checkRetiredBodyDialect(
- tag: string,
- key: string,
- value: unknown,
- acceptsChildren: boolean,
-): Diagnostic | null {
- if (key !== RETIRED_CHILD_LIST_KEY) return null;
-
- if (isChildList(value) && !acceptsChildren) {
- // The ruling, verbatim: "a `body` child list under a non-container draws
- // the same `not-a-container` the `children` spelling draws". Same code, so
- // a consumer keying on the code cannot tell the two spellings apart — which
- // is the single-spelling rule stated in the diagnostic stream. The sentence
- // carries the extra fact the `children` path has no need of.
- return {
- severity: 'warning',
- code: 'not-a-container',
- message:
- `<${tag}> does not accept children — and "body" is the retired spelling of ` +
- `"children" (objectui#6771), so this child list has no door here at all`,
- tag,
- };
- }
-
- return {
- severity: 'warning',
- code: 'unknown-prop',
- message:
- `<${tag}> has no prop "body" — the child-list key is "children" ` +
- `("body" was retired by objectui#6771)`,
- tag,
- };
-}
diff --git a/ui/packages/sdui-parser/src/codegen.ts b/ui/packages/sdui-parser/src/codegen.ts
deleted file mode 100644
index f6727b7c9f8..00000000000
--- a/ui/packages/sdui-parser/src/codegen.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * ObjectUI — codegen the JSX type surface from the registry manifest (ADR-0080 §3)
- *
- * Emits a `.d.ts` that augments `JSX.IntrinsicElements` so a constrained
- * JSX-source page type-checks in `.tsx`: tag name === registry `type` key,
- * attributes === the component's manifest `inputs`. This is a TYPE-CHECKING
- * fiction — no real React intrinsic named `flex` exists; the interpreter
- * fulfills it via the registry at render time.
- */
-
-import type { Manifest, ManifestComponent, ManifestInput, ManifestInputType } from './types.js';
-import { inputTypeArms } from './input-type.js';
-import { SDUI_BASE_PROPS } from './validate.js';
-
-/**
- * `SduiBaseProps`, emitted from {@link SDUI_BASE_PROPS} — the list
- * `validateTree` reads — so the generated types and the validator cannot
- * disagree about a base prop (objectui#11044). ⛔ Never a second hand-kept
- * copy of the list here: that is how this interface came to refuse `bind` and
- * `hidden` after the validator accepted them.
- */
-const emitBaseProps = (): string =>
- `export interface SduiBaseProps {\n${SDUI_BASE_PROPS.filter((prop) => prop.tsType !== null)
- .map((prop) => ` ${prop.name}?: ${prop.tsType};`)
- .join('\n')}\n}`;
-
-/**
- * The attributes `SduiBaseProps` carries — every entry of the list but `type`,
- * whatever its scope. A declared input of the same name outranks each of them
- * in the generated types (objectui#11044, objectui#11075).
- */
-const BASE_ATTRIBUTES = new Set(SDUI_BASE_PROPS.filter((prop) => prop.tsType !== null).map((prop) => prop.name));
-
-export interface CodegenOptions {
- /** include a self-contained minimal JSX namespace so the d.ts type-checks
- * standalone (no React types needed). Default true. */
- standaloneJsx?: boolean;
-}
-
-export function generateDts(manifest: Manifest, options: CodegenOptions = {}): string {
- const { standaloneJsx = true } = options;
- const comps = Object.values(manifest.components).sort((a, b) => a.type.localeCompare(b.type));
-
- const interfaces = comps.map(emitInterface).join('\n\n');
- const intrinsics = comps
- .map((c) => ` ${JSON.stringify(c.type)}: ${propsName(c.type)};`)
- .join('\n');
-
- const baseElement = standaloneJsx
- ? `
- // minimal, so the surface type-checks without pulling React types
- type Element = unknown;
- interface ElementClass {}
- interface ElementAttributesProperty {}
- interface ElementChildrenAttribute { children: object; }`
- : '';
-
- return `// AUTO-GENERATED by @object-ui/sdui-parser — DO NOT EDIT.
-// Source of truth: ComponentRegistry inputs (ADR-0080 §3). Regenerate via codegen.
-/* eslint-disable */
-
-${emitBaseProps()}
-
-${interfaces}
-
-declare global {
- namespace JSX {
- interface IntrinsicElements {
-${intrinsics}
- }${baseElement}
- }
-}
-
-export {};
-`;
-}
-
-/**
- * A `'slot'` arm names a child position, not a prop value — `SduiBaseProps`
- * already types `children`, so a slot input contributes no attribute. An input
- * is therefore emitted when it has at least one NON-slot arm, and typed from
- * those arms only (objectui#3832): the old test was `i.type !== 'slot'`, which a
- * union like `['slot', 'string']` would have passed while `tsType` fell through
- * to the default and typed it `string` anyway.
- *
- * A base attribute the component declares is `Omit`ted from the base it
- * extends, whatever its scope: the declared input wins with its type. So a
- * declared type the base one does not admit is no TS2430 conflict — neither
- * `label?: string | Record` on a `'where-undeclared'` member
- * (objectui#11044) nor `record:alert`'s
- * `visible?: boolean | string | Record` on an `'every-node'`
- * one (objectui#11075) — and one the base admits (`className?: string`) types
- * the same either way.
- *
- * The scope is the VALIDATOR's distinction — `validateTree` skips an
- * `'every-node'` key before the declared-input lookup — and does not reach this
- * surface. ⛔ Every declared base attribute is `Omit`ted, not only the ones
- * whose types conflict: assignability is TypeScript's judgement, and a
- * conflicts-only rule here would re-derive it from type strings.
- *
- * A slot-only input emits no attribute, so a declared `children: 'slot'` is
- * not `Omit`ted and the base `children` stays.
- */
-function emitInterface(comp: ManifestComponent): string {
- const emitted = comp.inputs.filter((i) => valueArms(i).length > 0);
- const lines = emitted.map((i) => ` ${propLine(i)}`).join('\n');
- const yielded = emitted.filter((i) => BASE_ATTRIBUTES.has(i.name)).map((i) => JSON.stringify(i.name));
- const base = yielded.length > 0 ? `Omit` : 'SduiBaseProps';
- return `export interface ${propsName(comp.type)} extends ${base} {\n${lines}\n}`;
-}
-
-/** The arms that describe a VALUE (every arm except `'slot'`). */
-const valueArms = (input: ManifestInput): ManifestInputType[] =>
- inputTypeArms(input.type).filter((arm) => arm !== 'slot');
-
-function propLine(input: ManifestInput): string {
- const opt = input.required ? '' : '?';
- return `${quoteKeyIfNeeded(input.name)}${opt}: ${tsType(input)};`;
-}
-
-/**
- * The TypeScript type for one arm. Kinds that are a string with a narrower
- * authoring control (`color`, `date`, `code`, `file`) are `string` here, as
- * before — the JSX surface types the VALUE, and the control kind is the
- * designer's business.
- */
-function armTsType(arm: ManifestInputType, input: ManifestInput): string {
- switch (arm) {
- case 'number':
- return 'number';
- case 'boolean':
- return 'boolean';
- case 'array':
- return `${memberTsType(input)}[]`;
- case 'object':
- return `Record`;
- case 'enum': {
- const vals = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
- return vals.length ? vals.map((v) => JSON.stringify(v)).join(' | ') : 'string';
- }
- default:
- return 'string';
- }
-}
-
-/**
- * The TypeScript type of a container's MEMBERS, from `ManifestInput.of`
- * (objectui#8067).
- *
- * `unknown` when nothing is declared, which is what every input published
- * before `of` existed says — so an undeclared array still emits `unknown[]` and
- * an undeclared object still emits `Record`, byte for byte.
- * A declared member kind narrows it: `of: 'string'` on `page:header.actions`
- * turns `unknown[]` into `string[]`, and the JSX surface finally types the
- * action IDs the contract has always required.
- *
- * `'slot'` is dropped for the same reason {@link valueArms} drops it one level
- * up: it names a child position, not a value, so it contributes no member type.
- * If that leaves nothing, the result is `unknown` — a declaration that types no
- * member must not silently type them all as strings.
- */
-function memberTsType(input: ManifestInput): string {
- const arms = inputTypeArms(input.of).filter((arm) => arm !== 'slot');
- if (arms.length === 0) return 'unknown';
- const emitted = [...new Set(arms.map((arm) => armTsType(arm, { ...input, of: undefined })))];
- return emitted.length === 1 ? emitted[0] : `(${emitted.join(' | ')})`;
-}
-
-/**
- * A union declaration emits a TypeScript union, so the `.d.ts` an author
- * type-checks their page against accepts exactly the arms the manifest gate
- * accepts. Arms that collapse to the same TS type (`string` and `color`, say)
- * are de-duplicated rather than emitted as `string | string`.
- */
-function tsType(input: ManifestInput): string {
- const arms = valueArms(input);
- if (arms.length === 0) return 'string';
- const emitted = [...new Set(arms.map((arm) => armTsType(arm, input)))];
- return emitted.join(' | ');
-}
-
-const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
-const quoteKeyIfNeeded = (name: string): string => (IDENT.test(name) ? name : JSON.stringify(name));
-
-/** 'object-grid' -> 'ObjectGrid', 'record:details' -> 'RecordDetails' */
-export function propsName(type: string): string {
- const pascal = type
- .split(/[^A-Za-z0-9]+/)
- .filter(Boolean)
- .map((s) => s[0].toUpperCase() + s.slice(1))
- .join('');
- return `${pascal}Props`;
-}
-
-/**
- * Generate the human-facing PUBLIC block list (the curated "清单") from a
- * manifest — a Markdown table. Derived, never hand-maintained (ADR-0046).
- *
- * The html tier's intrinsic elements (`tier: 'html'`, objectui#10735) are
- * listed in a section of their own, under their own count: they are in the
- * manifest because a `kind:'html'` page may author them, not because they are
- * curated blocks, and a single table would read them as the latter.
- */
-export function generateBlockList(manifest: Manifest): string {
- const sorted = Object.values(manifest.components).sort((a, b) => a.type.localeCompare(b.type));
- const curated = sorted.filter((c) => c.tier !== 'html');
- const htmlTier = sorted.filter((c) => c.tier === 'html');
- const row = (c: ManifestComponent): string => {
- const req = c.inputs.filter((i) => i.required).map((i) => i.name);
- const binds = c.inputs.filter((i) => i.binding).map((i) => `${i.name}:${i.binding}`);
- return `| \`${c.type}\` | ${c.namespace ?? '—'} | ${c.isContainer ? '✓' : ''} | ${req.join(', ') || '—'} | ${binds.join(', ') || '—'} |`;
- };
- const header = ['| block | plugin | container | required props | bindings |', '|---|---|---|---|---|'];
- return [
- `# SDUI public blocks (${curated.length})`,
- '',
- '> Auto-generated from the registry `tier:\'public\'` set (ADR-0080). Do not edit by hand.',
- '',
- ...header,
- ...curated.map(row),
- ...(htmlTier.length
- ? [
- '',
- `## html tier intrinsic elements (${htmlTier.length})`,
- '',
- "> The intrinsic HTML tags a `kind:'html'` page may author (ADR-0081 §2), declared by the registry's `HTML_TIER_INTRINSICS` roster and marked `tier: 'html'` in the manifest. Not part of the curated vocabulary above.",
- '',
- ...header,
- ...htmlTier.map(row),
- ]
- : []),
- '',
- ].join('\n');
-}
diff --git a/ui/packages/sdui-parser/src/dashboard-widget-options.ts b/ui/packages/sdui-parser/src/dashboard-widget-options.ts
deleted file mode 100644
index 560084ed6da..00000000000
--- a/ui/packages/sdui-parser/src/dashboard-widget-options.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-/**
- * ObjectUI — unconsumed dashboard-widget `options` keys (`8d58f46b4`)
- *
- * `@objectstack/spec`'s `DashboardWidgetOptionsSchema` ends in `.passthrough()`
- * ("declared query keys + open renderer extras"), so ANY key parses, validates
- * and lints cleanly — including one no renderer reads. That is how a showcase
- * dashboard shipped `options: { invert: true }` on a gauge with a comment
- * saying what it was believed to do, and rendered the un-inverted measure with
- * no diagnostic anywhere (`8d58f46b4`). The 2026-08-23 maintainer ruling:
- * open extras stay open — they just stop being SILENT. A key that reaches no
- * renderer draws a WARNING naming the consumed set. Not an error: no gate
- * weakening and no new red gates were ruled.
- *
- * ## The census (measured on origin/main @ 8689166f6, spec 17.1.0)
- *
- * The spec REQUIRES `dataset` on every widget (`DashboardWidgetSchema` —
- * re-read for objectui#7293 against the PUBLISHED `@objectstack/spec@17.4.0`,
- * whose required keys are exactly `id` / `dataset` / `values`), and
- * both dashboard surfaces route a dataset-bound widget to `DatasetWidget`
- * (`DashboardRenderer.tsx` renders `` when `widget.dataset` is
- * set; `DashboardGridLayout.tsx` mirrors the fork, objectui#4614). On that —
- * the only spec-legal — path, the renderer-consumed `options` keys are exactly
- * the five the spec DECLARES, all read as direct `options.` accesses in
- * `packages/plugin-dashboard/src/DatasetWidget.tsx`:
- *
- * dateGranularity, sortBy, sortOrder, limit (query-affecting, framework#3588)
- * stageOrder (funnel/pyramid stage order)
- *
- * and nothing else. ⛔ Not `description`: it was the metric-card sub-caption
- * (objectui#4032 item 4, objectui#7293), read by `DatasetWidget.tsx`'s metric
- * branch and a resolver both dashboard surfaces called, and fed by the
- * server's `translateDashboard` overlay of the `widgets.{id}.subCaption`
- * translation. The spec never declared it. objectui#11389 (ruling C, which
- * reverses objectstack#5428 item 4) retires it at both ends: objectstack
- * first, in `@objectstack/spec` 17.7.0 (the overlay is gone and the
- * translation key is a tombstone), then the readers here. So an authored
- * `options.description` reaches no renderer and draws this warning like any
- * other unconsumed key. A widget keeps one authored description,
- * `widget.description`, the card-header subtitle, which is not an `options`
- * key and is not judged here.
- *
- * Notably NOT consumed on the path a widget really renders through:
- * `thresholds` and `format`. Both were widely believed to work; both draw this
- * warning, which is the point. They are NOT the same kind of claim, and since
- * the 2026-08-25 maintainer ruling (batch adjudication close-out, decision B1 —
- * a closure claim must be BOUNDED or DERIVABLE, and a claim written in two
- * places is single-sourced) they are written separately (objectui#6186):
- *
- * `thresholds` — zero read sites repo-wide. This paragraph is the CANONICAL
- * statement of that closure claim. `content/docs/plugins/plugin-dashboard.mdx`
- * used to assert it a second time in its own words; two copies of one
- * closure claim drift apart independently and neither knows when the other
- * stopped being true, so the page now POINTS here rather than restating it.
- * Single-sourcing is what makes this copy load-bearing, so it is DERIVED
- * rather than trusted:
- * `scripts/__tests__/unconsumed-widget-option-claim-6186.test.ts` re-scans
- * every JS/TS file git tracks for an access to a key of that name and fails
- * if one appears. Land a renderer that reads it and this module goes red in
- * the same run.
- * `format` — not read ON THE DATASET-BOUND PATH; the value is formatted with
- * the MEASURE's own metadata (`measureField(...).format`, from the dataset
- * definition), not from the widget's bag. Deliberately a BOUNDED claim and
- * not a repo-wide one: `format` is a live key in other vocabularies, so a
- * repo-wide scan would red on a TRUE claim — and an assertion that reds on
- * legitimate code gets deleted by the next person who hits it, which puts
- * the claim back where it started. Leg 2 of
- * `__tests__/dashboard-widget-options-census.test.ts` derives exactly this
- * bound: the DatasetWidget read set is the declared set, and neither
- * `format` nor `thresholds` is in it. That absence is asserted in its OWN
- * right rather than riding on an equality whose right-hand side can grow.
- *
- * ## Scope — where the warning deliberately does NOT fire
- *
- * - Widgets WITHOUT `dataset`: the legacy inline forms (`options.data`
- * arrays, `provider: 'object'` bags) consume a much larger, spread-shaped
- * key set (`{ ...options }` into `metric` / `object-metric` /
- * `data-table` / …), whose true reach is each child component's prop
- * surface. That form is spec-illegal today (`dataset` is required) and its
- * census would be the unmaintainable one; skipping it keeps every warning
- * this module emits a statement about the path the widget actually renders
- * through.
- * - Widgets in the legacy COMPONENT format (`widget.component`): `options`
- * is not part of that contract.
- * - Widgets carrying the spec's own escape hatch
- * `suppressWarnings: ['unconsumed-widget-option']` — the spec models
- * per-widget diagnostic suppression (`DashboardWidgetSchema`), so an
- * author with a genuine out-of-band consumer can say so in metadata.
- *
- * ## Maintenance — how this list stays true
- *
- * `__tests__/dashboard-widget-options-census.test.ts` re-runs the census on
- * every test run: it re-derives the five declared keys from the installed
- * `@objectstack/spec`, re-extracts the `options.` reads from
- * `DatasetWidget.tsx` source text (and fails loudly if that file gains a
- * consumption shape the extractor cannot see — a spread, a destructuring, a
- * computed access), re-checks that the retired sub-caption key has no read
- * site left in `plugin-dashboard`, and trips on any NEW
- * file in `packages/*\/src` or `apps/*\/src` that starts reading
- * `widget.options`. A renderer change that adds or removes a consumed key
- * fails that test until this list is updated — the cost of keeping this
- * warning honest is editing ONE array below plus re-reading the census notes.
- */
-import type { Diagnostic, SchemaElement } from './types.js';
-
-/** The diagnostic `code` — also the id `suppressWarnings` suppresses. */
-export const UNCONSUMED_WIDGET_OPTION = 'unconsumed-widget-option';
-
-/**
- * Component types that host a dashboard `widgets` array. Both resolve to the
- * surfaces measured by the census above (`DashboardRenderer`,
- * `DashboardGridLayout`), which share one dispatch (`widgetDispatch.ts`).
- */
-export const DASHBOARD_WIDGET_HOST_TYPES: ReadonlySet = new Set([
- 'dashboard',
- 'dashboard-grid',
-]);
-
-/**
- * The accepted set: every `options` key with a renderer read site on the
- * dataset-bound path, which is exactly the set the spec declares.
- * Alphabetical; the warning message prints it verbatim. Derivation and
- * evidence: file header.
- */
-export const CONSUMED_WIDGET_OPTION_KEYS: readonly string[] = [
- 'dateGranularity',
- 'limit',
- 'sortBy',
- 'sortOrder',
- 'stageOrder',
-];
-
-const CONSUMED = new Set(CONSUMED_WIDGET_OPTION_KEYS);
-
-const isPlainObject = (v: unknown): v is Record =>
- typeof v === 'object' && v !== null && !Array.isArray(v);
-
-/** The parser's deferred-expression marker — opaque here, never evaluated. */
-const isExpr = (v: unknown): boolean => isPlainObject(v) && '$expr' in v;
-
-/**
- * Diagnostics for `options` keys no renderer consumes, over one dashboard-host
- * node's `widgets` array. Pure and shallow by design: it never descends into
- * `children` (the caller's walk owns that) and answers `[]` for every shape
- * outside its census — see the scope notes in the file header.
- */
-export function checkDashboardWidgetOptions(node: SchemaElement): Diagnostic[] {
- if (!DASHBOARD_WIDGET_HOST_TYPES.has(node.type)) return [];
- const widgets = (node as Record).widgets;
- if (!Array.isArray(widgets)) return [];
-
- const diagnostics: Diagnostic[] = [];
- widgets.forEach((widget, index) => {
- if (!isPlainObject(widget) || isExpr(widget)) return;
- // Legacy component format: `options` is not part of that contract.
- if (widget.component !== undefined) return;
- // Only the dataset-bound (spec-legal) path is censused — see file header.
- if (widget.dataset === undefined || widget.dataset === null || widget.dataset === '') return;
- const options = widget.options;
- if (!isPlainObject(options) || isExpr(options)) return;
- if (
- Array.isArray(widget.suppressWarnings) &&
- widget.suppressWarnings.includes(UNCONSUMED_WIDGET_OPTION)
- ) {
- return;
- }
- const label = typeof widget.id === 'string' && widget.id !== '' ? widget.id : `#${index}`;
- const widgetType = typeof widget.type === 'string' && widget.type !== '' ? widget.type : 'widget';
- for (const key of Object.keys(options)) {
- if (CONSUMED.has(key)) continue;
- diagnostics.push({
- severity: 'warning',
- code: UNCONSUMED_WIDGET_OPTION,
- message:
- `<${node.type}> widget "${label}" (${widgetType}): options.${key} reaches no renderer — ` +
- `dashboard widget renderers read only: ${CONSUMED_WIDGET_OPTION_KEYS.join(', ')}`,
- tag: node.type,
- });
- }
- });
- return diagnostics;
-}
diff --git a/ui/packages/sdui-parser/src/index.ts b/ui/packages/sdui-parser/src/index.ts
deleted file mode 100644
index bb412fdc96c..00000000000
--- a/ui/packages/sdui-parser/src/index.ts
+++ /dev/null
@@ -1,258 +0,0 @@
-/**
- * @object-ui/sdui-parser — constrained JSX-source → SDUI SchemaNode tree (ADR-0080)
- *
- * Isomorphic, zero React. Run server-side as the authoritative save-time gate;
- * may also run client-side for live edit preview (re-validated on the server —
- * never the trust boundary). It PARSES; it never executes.
- */
-
-export * from './types.js';
-export { parseJsx, interpretBrace } from './parse.js';
-export { HTML_TIER_NODE, isHtmlTierNode, markHtmlTierNode } from './provenance.js';
-export { validateTree, acceptsChildren, CHILD_LIST_KEY, SDUI_BASE_PROPS } from './validate.js';
-export type { SduiBaseProp, SduiBasePropScope } from './validate.js';
-export {
- checkDashboardWidgetOptions,
- CONSUMED_WIDGET_OPTION_KEYS,
- DASHBOARD_WIDGET_HOST_TYPES,
- UNCONSUMED_WIDGET_OPTION,
-} from './dashboard-widget-options.js';
-export { generateDts, propsName, generateBlockList } from './codegen.js';
-export type { CodegenOptions } from './codegen.js';
-export { inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES } from './input-type.js';
-export { checkRetiredBodyDialect, RETIRED_CHILD_LIST_KEY } from './body-dialect.js';
-export { checkRetiredVisibilityDialect, RETIRED_VISIBILITY_DIALECTS } from './retired-visibility-dialects.js';
-
-import { parseJsx } from './parse.js';
-import { validateTree } from './validate.js';
-import { canonicalizeInputType } from './input-type.js';
-import type { Diagnostic, Manifest, SchemaElement, ManifestValidationResult } from './types.js';
-
-export interface CompileResult {
- tree: SchemaElement | null;
- diagnostics: Diagnostic[];
- requires: string[];
- bindings: ManifestValidationResult['bindings'];
- /** true when there are no error-severity diagnostics — the save gate's pass/fail */
- ok: boolean;
-}
-
-/**
- * The authoritative pipeline: parse (with the manifest's tags as the whitelist)
- * → validate against the manifest → derive `requires` + binding sites.
- */
-export function compile(source: string, manifest: Manifest): CompileResult {
- const allowedTags = new Set(Object.keys(manifest.components));
- const parsed = parseJsx(source, { allowedTags });
- const validated = validateTree(parsed.tree, manifest);
- const diagnostics = [...parsed.diagnostics, ...validated.diagnostics];
- return {
- tree: parsed.tree,
- diagnostics,
- requires: validated.requires,
- bindings: validated.bindings,
- ok: !diagnostics.some((d) => d.severity === 'error'),
- };
-}
-
-/* ------------------------------------------------------------------ *
- * Registry → manifest adapter. Structural input (no @object-ui/core
- * dependency) so the package stays pure and hoistable to framework.
- * Feed it `ComponentRegistry.getAllConfigs()` (optionally filtered to
- * the `tier:'public'` set).
- * ------------------------------------------------------------------ */
-
-/**
- * The shape of one `@object-ui/types` `NodeSlotDeclaration`, restated
- * structurally because this package imports nothing at runtime: a position in
- * that package's key-path grammar, and whether its spelling is retired.
- */
-export interface NodeSlotDeclarationLike {
- readonly path: string;
- readonly retired?: boolean;
-}
-
-export interface RegistryConfigLike {
- type: string;
- namespace?: string;
- /**
- * LAYOUT containment — the registration's `ComponentMeta.isContainer`,
- * carried through to the manifest for the consumers that read it there.
- * ⛔ It does not mean "accepts children" and `validateTree` does not read
- * it: whether a component takes an authored child list is declared as an
- * input named `children` (objectui#9910; `acceptsChildren` in `validate.ts`).
- */
- isContainer?: boolean;
- /**
- * ADR-0080 contract tier — only 'public' configs form the AI/contract
- * surface under `publicOnly`. `'html'` is the projection-only stamp
- * `ComponentRegistry.getPublicConfigs()` puts on the html tier's intrinsic
- * elements (objectui#10735); it is the one value {@link manifestFromConfigs}
- * carries into `ManifestComponent.tier`.
- */
- tier?: 'public' | 'internal' | 'html';
- label?: string;
- category?: string;
- inputs?: Array<{
- name: string;
- /**
- * One coarse kind, or the arms of a union (objectui#3832). Typed loosely
- * (`string`) on purpose — this interface is the STRUCTURAL boundary that
- * keeps this package free of a dependency on the registry, so an
- * off-vocabulary value has to be representable here and is normalized by
- * `canonicalizeInputType` on the way in.
- */
- type: string | string[];
- /**
- * The declared member kind(s) — array elements, or an object map's values
- * (objectui#8067). Typed as loosely as `type` above and for the same
- * reason: this interface is the STRUCTURAL boundary that keeps the package
- * free of a registry dependency, so an off-vocabulary value has to be
- * representable here and is normalized on the way in.
- */
- of?: string | string[];
- required?: boolean;
- enum?: Array;
- /**
- * The binding marker — exactly `'object'` (`9e37d9b39`).
- *
- * `'field'` was declared beside it from the first draft of ADR-0080 §6.3
- * and never written: every `binding:` literal in `packages/`, `apps/`
- * and `examples/` is `'object'` (7 of 7, measured 2026-09-06 and
- * re-measured at the retiring PR's merge-base), and nothing on either
- * side of the manifest resolved a field binding. Retired under ADR-0049
- * enforce-or-remove by the maintainer ruling of 2026-09-07 (decision
- * batch #69): a config that spells the arm is a `tsc` error here rather
- * than an entry the server would never resolve.
- *
- * The retirement then reached the two faces the ruling did not name
- * (objectui#8315): `ManifestInput.binding` and
- * `ManifestValidationResult.bindings[].kind` in `types.ts` read `'object'`
- * too, so the published package no longer narrows on one face and stays
- * wide on the other. The argument for keeping the manifest face wide —
- * producer → reader is a subset relation — is answered at
- * `ManifestInput.binding`, which is where a later reader lands.
- *
- * Only the framework writes the key: `InjectedComponentInput` in
- * `@object-ui/types`, spliced by `Registry.register`. `ComponentInput`
- * itself has no `binding` member, deliberately.
- */
- binding?: 'object';
- description?: string;
- }>;
- /**
- * True when the registry entry is a pending lazy stub — registered, but its
- * plugin module has not been imported, so it carries metadata and no
- * `inputs`. See {@link assertFullyLoaded}.
- */
- lazy?: boolean;
-}
-
-/**
- * Build-time guard for the manifest generators.
- *
- * A manifest is the contract's frozen form: the parser's tag whitelist and the
- * save gate's prop validator both read it. So it has to be generated from
- * FULLY LOADED registrations. A `lazy: true` entry means the generator never
- * imported that plugin, and the block would be written out with empty
- * `inputs` — indistinguishable from a block that legitimately takes no props,
- * which turns every prop an author writes on it into an `unknown-prop`
- * diagnostic. Wrong quietly, in the artifact everything downstream trusts.
- *
- * The generators keep their own eager import list, so this is what catches it
- * drifting behind the app's registration list (objectui#2953 was the same
- * class of bug one layer down).
- */
-export function assertFullyLoaded(configs: RegistryConfigLike[]): void {
- const stubs = configs.filter((c) => c.lazy).map((c) => c.type).sort();
- if (stubs.length === 0) return;
- throw new Error(
- `Manifest generation saw ${stubs.length} not-yet-loaded lazy block(s): ${stubs.join(', ')}.\n` +
- `A manifest must describe loaded registrations — these would be written out with no \`inputs\`, ` +
- `so every prop an author passes them would be reported as unknown.\n` +
- `Add an eager \`import\` of each block's plugin to the generator entry.`,
- );
-}
-
-/* The arm vocabulary and the two projections over it now live in
- * `input-type.ts` — `manifestFromConfigs` below, `validateTree` and the codegen
- * all read `ManifestInput.type` and must agree on how, since it holds one arm
- * or an array of them (objectui#3832). */
-
-/**
- * Project registry configs into the SDUI manifest.
- *
- * ⚠️ Every field here is copied from what the registration **declared**. In
- * particular `inputs` is `config.inputs` verbatim — this function does not, and
- * cannot, observe whether the renderer behind the block reads any of them.
- *
- * Worth stating because of what consumes the output. The framework's
- * `check:react-blocks-declaration-parity` diffs this against the spec's zod
- * schemas, and while that check was named `check:react-conformance` it was read
- * — by its own file header — as confirming the components "ACTUALLY implement"
- * the spec's props. It never could: both sides of that diff are declarations,
- * and this is the side this file produces. Four blocks published an `objectName`
- * no renderer read and sailed through it green (objectstack#4413; corrected in
- * objectstack#4472). Evidence about the render path has to come from the render
- * path — see `apps/console/src/__tests__/public-block-binding-reach.test.tsx`.
- *
- * `opts.slotsFor` is the one exception to "copied from the registration", and
- * it is a DECLARATION too (objectui#11170): hand it `nodeSlotsFor` from
- * `@object-ui/types` and every entry carries `slots`, the node-slot positions
- * that type's renderer reads besides `children`, so `validateTree` judges the
- * nodes under them. This package has no runtime dependency and so cannot
- * import the declaration itself; the producers that build a live manifest
- * (`getJsxManifest` in `@object-ui/components`' page renderer, the console's
- * manifest generator) pass it. A RETIRED position is left out here: this is
- * the authoring tier, and the spec's own authoring walks skip the tombstoned
- * spellings for the same reason. Without the option no `slots` key is written,
- * so every manifest built before the option existed serialises byte-identically.
- */
-export function manifestFromConfigs(
- configs: RegistryConfigLike[],
- opts: {
- only?: Set;
- publicOnly?: boolean;
- slotsFor?: (type: string) => readonly NodeSlotDeclarationLike[];
- } = {},
-): Manifest {
- const components: Manifest['components'] = {};
- for (const c of configs) {
- if (opts.only && !opts.only.has(c.type)) continue;
- if (opts.publicOnly && c.tier !== 'public') continue;
- const slots = opts.slotsFor
- ? opts.slotsFor(c.type).filter((s) => !s.retired).map((s) => s.path)
- : undefined;
- components[c.type] = {
- type: c.type,
- namespace: c.namespace,
- isContainer: c.isContainer,
- // `undefined` is dropped by `JSON.stringify`, so a manifest built with
- // no resolver — or an entry with no slots — serialises exactly as before
- // this key existed.
- slots: slots && slots.length > 0 ? slots : undefined,
- // The html tier's stamp, and ONLY that stamp (objectui#10735): a
- // registration's `'public'` / `'internal'` is registry mechanics the
- // manifest never carried, and `undefined` is dropped by `JSON.stringify`,
- // so every curated entry serialises exactly as before this key existed.
- tier: c.tier === 'html' ? 'html' : undefined,
- inputs: (c.inputs ?? []).map((i) => ({
- name: i.name,
- type: canonicalizeInputType(i.type),
- // Undefined stays undefined rather than going through
- // `canonicalizeInputType`, whose no-arms fallback is `'string'`: an
- // input that declares no member kind must publish NO `of`, or every
- // array in every manifest would suddenly claim string members it was
- // never told it had. `JSON.stringify` drops the undefined key, so the
- // published artifact is byte-identical for every input that does not
- // declare one (objectui#8067).
- of: i.of === undefined ? undefined : canonicalizeInputType(i.of),
- required: i.required,
- enum: i.enum,
- binding: i.binding,
- description: i.description,
- })),
- };
- }
- return { components };
-}
diff --git a/ui/packages/sdui-parser/src/input-type.ts b/ui/packages/sdui-parser/src/input-type.ts
deleted file mode 100644
index abd1c44e819..00000000000
--- a/ui/packages/sdui-parser/src/input-type.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * ObjectUI — the arms of a manifest input's coarse type (objectui#3832)
- *
- * `ManifestInput.type` carries ONE coarse kind, or an ARRAY of kinds when the
- * key's contract is a union. Every reader of that field needs the same two
- * decisions made the same way — how to see the arms, and which single form to
- * publish — so they live here once instead of at each call site. A reader that
- * forgets is not loud: `switch (input.type)` handed an array falls through to
- * the default branch and reports NOTHING, which looks exactly like a value that
- * validated cleanly.
- */
-
-import type { ManifestInput, ManifestInputType } from './types.js';
-
-/** The eleven coarse kinds, as a runtime set for guarding untyped input. */
-export const MANIFEST_INPUT_TYPES: ReadonlySet = new Set([
- 'string',
- 'number',
- 'boolean',
- 'enum',
- 'array',
- 'object',
- 'color',
- 'date',
- 'code',
- 'file',
- 'slot',
-]);
-
-/**
- * The declared arms of an input's coarse type, always as an array.
- *
- * Exported (not merely internal) so a third-party manifest consumer — a
- * designer panel, a codegen, a validator of its own — reads the arms through
- * the same accessor this package's own gate does, rather than re-deriving the
- * `Array.isArray` branch and getting it subtly wrong on the union form.
- */
-export function inputTypeArms(
- type: ManifestInput['type'] | undefined,
-): ManifestInputType[] {
- if (type === undefined) return [];
- return Array.isArray(type) ? type : [type];
-}
-
-/**
- * Project a DECLARED type (which may come from an untyped registry config) into
- * the canonical published form: a bare string for one arm, an array for a real
- * union.
- *
- * Two deliberate asymmetries between the single and array forms:
- *
- * - A single unrecognized kind still becomes `'string'`. That coercion predates
- * the union work and is kept exactly: with one arm there is no other
- * information to fall back on, and a manifest whose `type` is off-vocabulary
- * would make every consumer's switch silently inert.
- * - An unrecognized arm INSIDE an array is DROPPED, not coerced. Promoting it
- * to `'string'` would publish an arm the author never declared — a widening
- * invented by the serializer — and the surviving arms already carry the
- * declaration. If dropping empties the array, the single-arm fallback applies
- * so the output is always a valid manifest.
- *
- * Collapsing one arm to the bare string is what keeps this a backward-compatible
- * extension of `sdui.manifest.json`: every input declared today serializes to
- * the byte-identical entry it does now, and arrays appear only where a union was
- * really declared.
- */
-export function canonicalizeInputType(type: unknown): ManifestInputType | ManifestInputType[] {
- const arms = (Array.isArray(type) ? type : [type]).filter(
- (arm): arm is ManifestInputType => typeof arm === 'string' && MANIFEST_INPUT_TYPES.has(arm),
- );
- const distinct = [...new Set(arms)];
- if (distinct.length === 0) return 'string';
- if (distinct.length === 1) return distinct[0];
- return distinct;
-}
diff --git a/ui/packages/sdui-parser/src/parse.ts b/ui/packages/sdui-parser/src/parse.ts
deleted file mode 100644
index 0431c73da96..00000000000
--- a/ui/packages/sdui-parser/src/parse.ts
+++ /dev/null
@@ -1,605 +0,0 @@
-/**
- * ObjectUI — SDUI JSX-source parser (ADR-0080)
- *
- * A small recursive-descent parser for a CONSTRAINED JSX subset. It is
- * deliberately not a full JS/JSX parser: a bounded grammar is the point
- * (Markdoc model) — it shrinks the attack surface and the expressible-but-wrong
- * space. Output is an SDUI node tree (`ParsedSchemaNode`). Nothing is executed.
- *
- * Grammar (informal):
- * document := element (exactly one root)
- * element := openTag child-star closeTag, or a self-closing tag
- * attr := name '=' (string | braced), or a bare name meaning true
- * child := element | text | jsx-block-comment
- * tag := [A-Za-z][A-Za-z0-9:_-]star (matches registry keys)
- */
-
-import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, ParsedSchemaNode } from './types.js';
-import { markHtmlTierNode } from './provenance.js';
-
-/** Event handlers and raw-HTML injection are never allowed (parse ≠ execute). */
-const EVENT_ATTR = /^on[A-Z]/;
-const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);
-
-/**
- * The envelope's own discriminator, which on THIS tier the tag name sets.
- *
- * An authored `type=` attribute is a NAME COLLISION with it, and the parser
- * refuses it at parse time (maintainer ruling 2026-09-01, recorded as an
- * append-only amendment on ADR-0080 — 「响亮拒绝」, quoted verbatim there;
- * objectui#7235 ports it to this copy, objectstack#13957 landed the other).
- * One diagnostic naming BOTH the tag and the attribute replaces two bad
- * outcomes:
- *
- * - the value named another REGISTERED type (``) — the tree
- * carried `type:'grid'`, `validateTree` found `grid` in the manifest, every
- * check passed, and the page rendered a grid where the author wrote a flex.
- * ZERO diagnostics. On the one tier whose whole premise is that unreviewed
- * and AI-authored source is safe to accept.
- * - the value named NOTHING registered (``, the shape
- * a react-tier author carries across) — loud, but `unknown-component`
- * naming `"bar"` reads as a missing plugin, never as a bad prop.
- *
- * ⛔ NOT rescued as `specType` the way the react tier rescues it (objectui#2880):
- * that is consumer-side tolerance, and it would spread an alias concept to a
- * second tier. ⛔ NOT a warning grace period either — the same ruling declined
- * a staged rollout. ⛔ And NOT fixable at the warning layer: `type` is in
- * `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
- * member), so removing it there would make every legitimate node warn. The
- * refusal belongs here, at parse.
- *
- * ⚠️ The code is the EXISTING `forbidden-attr`, not a new one, and that is
- * load-bearing rather than lazy: objectstack's `scripts/check-sdui-lockstep.mjs`
- * holds ITS copy's diagnostic-code set equal to THIS one's at the pinned
- * revision, so a code minted on one side only IS the dialect split that gate
- * exists to catch (objectstack#12719). `forbidden-attr` already carries this
- * shape — an attribute this tier refuses, named beside its element — and both
- * copies stamp it.
- *
- * ⚠️ This copy is the RENDERER's (and the console's live edit preview); the
- * save gate runs objectstack's. Until this landed the two differed in accepted
- * grammar: a page objectstack refused still compiled silently while its author
- * was typing.
- */
-const DISCRIMINATOR_ATTR = 'type';
-
-export function parseJsx(source: string, options: ParseOptions = {}): ParseResult {
- return new Parser(source, options).parseDocument();
-}
-
-const isNameStart = (c: string) => /[A-Za-z]/.test(c);
-const isNameChar = (c: string) => /[A-Za-z0-9:_-]/.test(c);
-
-class Parser {
- private pos = 0;
- private readonly diagnostics: Diagnostic[] = [];
-
- constructor(private readonly src: string, private readonly opts: ParseOptions) {}
-
- parseDocument(): ParseResult {
- this.skipTrivia();
- if (this.peek() !== '<') {
- this.error('no-root', 'Expected a single root element');
- return { tree: null, diagnostics: this.diagnostics };
- }
- const tree = this.parseElement();
- this.skipTrivia();
- if (tree && this.pos < this.src.length) {
- this.error('multiple-roots', 'A page must have exactly one root element', this.pos);
- }
- return { tree, diagnostics: this.diagnostics };
- }
-
- private parseElement(): SchemaElement | null {
- const start = this.pos;
- if (!this.eat('<')) {
- this.error('expected-element', 'Expected "<"', start);
- return null;
- }
- const tag = this.readName();
- if (!tag) {
- this.error('bad-tag', 'Expected a tag name after "<"', start);
- return null;
- }
- if (this.opts.allowedTags && !this.opts.allowedTags.has(tag)) {
- this.error('forbidden-tag', `<${tag}> is not an allowed component`, start, tag);
- }
-
- const props: Record = {};
- for (;;) {
- this.skipWs();
- const c = this.peek();
- if (c === '' || c === '>' || c === '/') break;
- const attr = this.parseAttr(start, tag);
- if (!attr) break;
- // `drop` is set only for the refused discriminator attribute, and only so
- // that ONE diagnostic is what the author gets. The `__forbidden_`
- // sentinel the other refusals park in `props` reaches `validateTree`,
- // which knows no such prop and adds `unknown-prop` naming a key nobody
- // wrote — loud, and pointing at the wrong thing, which is the species of
- // diagnostic this whole change exists to remove. The existing sentinel
- // behaviour is left exactly as it was for the attributes that already had
- // it (`ref`, `key`, `dangerouslySetInnerHTML`, `on*`).
- if (!attr.drop) props[attr.name] = attr.value;
- }
-
- this.skipWs();
- let children: ParsedSchemaNode[] | undefined;
- if (this.eat('/')) {
- if (!this.eat('>')) this.error('bad-self-close', `Malformed self-closing <${tag}>`, this.pos, tag);
- } else if (this.eat('>')) {
- children = this.parseChildren(tag);
- } else {
- this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
- }
-
- // Stamp html-tier provenance at the point of production (objectui#4000).
- // Renderers whose type carries authoring advice aimed at the JSON surface
- // read this to tell "the author wrote a tag in OUR tier, and there is no
- // other spelling available to them" apart from "the author wrote this type
- // in JSON, where the advice applies". Symbol-keyed, so it is invisible to
- // JSON, to the DOM, and to anything an authored document could forge — see
- // provenance.ts. Values reached through a braced attribute are NOT marked:
- // that JSON was written by hand, and the JSON surface's advice does apply.
- // DEFENSE IN DEPTH (ruled together with the refusal above). `props` used to
- // be spread AFTER `type: tag`, so an authored `type` attribute overwrote the
- // discriminator the tag established and nothing downstream restored it —
- // `compile()` returns this tree as-is and `validateTree` then looks up
- // `manifest.components[node.type]`, i.e. the value the author wrote, not the
- // tag they wrote. The refusal makes that overwrite unreachable; the order
- // here makes it impossible. ⚠️ Reversing the order ALONE would have been a
- // regression of its own — the authored value would then be dropped in
- // silence, trading one silence for another. It is correct only BECAUSE the
- // attribute is refused loudly one function up.
- //
- // ⚠️ The `markHtmlTierNode` wrapper is objectui-only and survives the
- // reversal: it stamps the object the spread produced, so the marker is
- // applied after every key is in place regardless of their order. Pinned in
- // provenance.test.ts, because this is the one edit that could drop it.
- const node: SchemaElement = markHtmlTierNode({ ...props, type: tag });
- if (children && children.length) node.children = children;
- return node;
- }
-
- private parseAttr(elStart: number, tag: string): { name: string; value: unknown; drop?: boolean } | null {
- const name = this.readName();
- if (!name) {
- this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag);
- // skip one char to avoid an infinite loop on garbage
- this.pos++;
- return null;
- }
- this.skipWs();
- let value: unknown = true; // bare attribute => boolean true
- if (this.eat('=')) {
- this.skipWs();
- value = this.parseAttrValue(tag);
- }
- if (name === DISCRIMINATOR_ATTR) {
- // ONE diagnostic naming both the tag and the attribute — see
- // DISCRIMINATOR_ATTR above for why it replaces both prior outcomes.
- this.error(
- 'forbidden-attr',
- `Attribute "${DISCRIMINATOR_ATTR}" is not allowed on <${tag}> — on this tier the tag name IS the `
- + `component, so <${tag}> already means type "${tag}". Delete the attribute, or write the tag of the `
- + 'component you meant.',
- elStart,
- tag,
- );
- return { name, value: undefined, drop: true };
- }
- if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
- this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
- return { name: `__forbidden_${name}`, value: undefined };
- }
- return { name, value };
- }
-
- private parseAttrValue(tag: string): unknown {
- const c = this.peek();
- if (c === '"' || c === "'") return this.readString(c);
- if (c === '{') return interpretBrace(this.readBraced());
- this.error('bad-attr-value', `Expected an attribute value on <${tag}>`, this.pos, tag);
- return undefined;
- }
-
- private parseChildren(parentTag: string): ParsedSchemaNode[] {
- const children: ParsedSchemaNode[] = [];
- for (;;) {
- if (this.pos >= this.src.length) {
- this.error('unclosed-element', `Unclosed <${parentTag}>`, this.pos, parentTag);
- break;
- }
- // closing tag
- if (this.src.startsWith('', this.pos)) {
- this.pos += 2;
- this.skipWs();
- const close = this.readName();
- this.skipWs();
- this.eat('>');
- if (close !== parentTag) {
- this.error('mismatched-tag', `Expected ${parentTag}> but found ${close}>`, this.pos, parentTag);
- }
- break;
- }
- // JSX comment {/* ... */}
- if (this.src.startsWith('{/*', this.pos)) {
- const end = this.src.indexOf('*/}', this.pos);
- if (end === -1) {
- this.error('unclosed-comment', 'Unclosed comment', this.pos);
- this.pos = this.src.length;
- } else {
- this.pos = end + 3;
- }
- continue;
- }
- // nested element
- if (this.peek() === '<') {
- const el = this.parseElement();
- if (el) children.push(el);
- continue;
- }
- // expression child {expr} — out of grammar for v1: skip with a warning
- if (this.peek() === '{') {
- const start = this.pos;
- this.readBraced();
- this.error(
- 'expression-child',
- 'Inline {expression} children are not supported yet — bind via a component prop',
- start,
- );
- continue;
- }
- // text
- //
- // HTML collapses a whitespace run to ONE space; it does not delete it. A
- // bare `.trim()` here deleted the space that separates a text run from an
- // adjacent sibling element, so `A x page` compiled to
- // `A`/`page` and rendered as `Axpage` (objectui#5661). The rule: collapse
- // the run, then keep a single leading space only when a sibling precedes
- // the run, and a single trailing space only when a sibling element
- // follows it. At the parent's own start/end the edge space is still
- // dropped, so ` hi
` stays `hi`. A whitespace-only run survives
- // as one space only when it sits BETWEEN siblings — that is the bounded
- // over-generosity of this rule inside block containers like ``,
- // pinned in the tests.
- const text = this.readTextRun();
- const collapsed = text.replace(/\s+/g, ' ');
- const core = collapsed.trim();
- const afterSibling = children.length > 0;
- const beforeElement = this.peek() === '<' && !this.src.startsWith('', this.pos);
- if (core) {
- const lead = afterSibling && collapsed.startsWith(' ') ? ' ' : '';
- const trail = beforeElement && collapsed.endsWith(' ') ? ' ' : '';
- children.push(`${lead}${core}${trail}`);
- } else if (collapsed && afterSibling && beforeElement) {
- children.push(' ');
- }
- }
- return children;
- }
-
- /* ----------------------------- lexing ----------------------------- */
-
- private peek(): string {
- return this.pos < this.src.length ? this.src[this.pos] : '';
- }
-
- private eat(ch: string): boolean {
- if (this.src[this.pos] === ch) {
- this.pos++;
- return true;
- }
- return false;
- }
-
- private readName(): string {
- if (!isNameStart(this.peek())) return '';
- const start = this.pos;
- this.pos++;
- while (this.pos < this.src.length && isNameChar(this.src[this.pos])) this.pos++;
- return this.src.slice(start, this.pos);
- }
-
- private readString(quote: string): string {
- this.pos++; // opening quote
- const start = this.pos;
- while (this.pos < this.src.length && this.src[this.pos] !== quote) this.pos++;
- const value = this.src.slice(start, this.pos);
- if (!this.eat(quote)) this.error('unterminated-string', 'Unterminated string literal', start);
- return value;
- }
-
- /** Reads a balanced `{ ... }` run and returns the inner text (no outer braces). */
- private readBraced(): string {
- const start = this.pos;
- let depth = 0;
- let inStr: string | null = null;
- for (; this.pos < this.src.length; this.pos++) {
- const ch = this.src[this.pos];
- if (inStr) {
- if (ch === inStr && this.src[this.pos - 1] !== '\\') inStr = null;
- continue;
- }
- if (ch === '"' || ch === "'") inStr = ch;
- else if (ch === '{') depth++;
- else if (ch === '}') {
- depth--;
- if (depth === 0) {
- const inner = this.src.slice(start + 1, this.pos);
- this.pos++; // consume closing brace
- return inner;
- }
- }
- }
- this.error('unterminated-brace', 'Unterminated "{"', start);
- return this.src.slice(start + 1);
- }
-
- private readTextRun(): string {
- const start = this.pos;
- while (this.pos < this.src.length && this.src[this.pos] !== '<' && this.src[this.pos] !== '{') this.pos++;
- return this.src.slice(start, this.pos);
- }
-
- private skipWs(): void {
- while (this.pos < this.src.length && /\s/.test(this.src[this.pos])) this.pos++;
- }
-
- /** whitespace + top-level JSX comments */
- private skipTrivia(): void {
- for (;;) {
- this.skipWs();
- if (this.src.startsWith('{/*', this.pos)) {
- const end = this.src.indexOf('*/}', this.pos);
- this.pos = end === -1 ? this.src.length : end + 3;
- continue;
- }
- break;
- }
- }
-
- private error(code: string, message: string, start?: number, tag?: string): void {
- this.diagnostics.push({ severity: 'error', code, message, start: start ?? this.pos, tag });
- }
-}
-
-/**
- * Interpret a braced attribute value `{...}`.
- *
- * Strict-JSON values are materialized by `JSON.parse`, exactly as they always
- * were. Beyond that, the JS **literal subset** below is materialized too
- * (objectui#6614 Q1-A, maintainer ruling 2026-08-28). Anything left over — a
- * genuine expression — is kept as the deferred marker `{ $expr }`: typed and
- * validated later, drawing `inert-expression`, and NEVER evaluated here.
- *
- * ORDER IS LOAD-BEARING. `JSON.parse` runs FIRST and is untouched, so every
- * input JSON accepts takes byte-identically the path it took before the literal
- * subset existed. The reader below only ever sees strings `JSON.parse` has
- * already thrown on, which makes strict-JSON invariance a property of the
- * structure rather than of a test.
- */
-export function interpretBrace(raw: string): unknown {
- const trimmed = raw.trim();
- try {
- return JSON.parse(trimmed);
- } catch {
- const literal = readLiteral(trimmed);
- return literal === NOT_LITERAL ? { $expr: trimmed } : literal;
- }
-}
-
-/* ---------------------- the JS literal subset (#6614) ---------------------- */
-
-/**
- * EXACTLY TWO widenings over JSON, and nothing else:
- *
- * 1. **single-quoted strings** — `{'name'}`, `{['name','amount']}`, and in
- * key position `{{'pageSize': 25}}`;
- * 2. **unquoted identifier object keys** — `{{pageSize: 25}}`.
- *
- * Everything else JSON refuses is still refused and still becomes `{ $expr }`:
- * trailing commas, comments, array holes, spreads, `undefined` / `NaN` /
- * `Infinity`, `+1` / `.5` / `1.` / `0x1f`, template literals, and every genuine
- * expression — identifiers, member access, calls, operators, ternaries.
- *
- * That list is deliberately short. This is a VALUE grammar, not an evaluator:
- * it contains no identifier lookup and no operator, so there is nothing here to
- * execute (ADR-0080 — this tier parses, never executes). The widening moves the
- * spellings an author writes by habit onto the materialized side; it does not
- * move the boundary between data and code.
- */
-const NOT_LITERAL = Symbol('not-a-literal');
-
-/** JSON's whitespace set, not JS's — narrower, and one less thing to diverge. */
-const LITERAL_WS = /[ \t\n\r]/;
-const IDENT_START = /[A-Za-z_$]/;
-const IDENT_CHAR = /[A-Za-z0-9_$]/;
-/** JSON's number grammar verbatim: no leading `+`, no `.5`, no `1.`, no hex. */
-const NUMBER = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/;
-/** JSON's escape set. `\'` is added for single-quoted strings only. */
-const SIMPLE_ESCAPE: Record = {
- '"': '"',
- '\\': '\\',
- '/': '/',
- b: '\b',
- f: '\f',
- n: '\n',
- r: '\r',
- t: '\t',
-};
-
-function readLiteral(src: string): unknown {
- const reader = new LiteralReader(src);
- const value = reader.value();
- if (value === NOT_LITERAL) return NOT_LITERAL;
- reader.ws();
- // Trailing anything means the input was an expression that merely STARTS with
- // a literal (`['a'] + x`, `1 + 2`). Refuse the whole input.
- return reader.done() ? value : NOT_LITERAL;
-}
-
-class LiteralReader {
- private pos = 0;
-
- constructor(private readonly src: string) {}
-
- done(): boolean {
- return this.pos >= this.src.length;
- }
-
- ws(): void {
- while (this.pos < this.src.length && LITERAL_WS.test(this.src[this.pos])) this.pos++;
- }
-
- value(): unknown {
- this.ws();
- const c = this.src[this.pos];
- if (c === undefined) return NOT_LITERAL;
- if (c === '"' || c === "'") return this.string(c);
- if (c === '[') return this.array();
- if (c === '{') return this.object();
- if (this.keyword('true')) return true;
- if (this.keyword('false')) return false;
- if (this.keyword('null')) return null;
- return this.number();
- }
-
- /** A keyword only when it is not the prefix of a longer identifier. */
- private keyword(word: string): boolean {
- if (!this.src.startsWith(word, this.pos)) return false;
- const after = this.src[this.pos + word.length];
- if (after !== undefined && IDENT_CHAR.test(after)) return false;
- this.pos += word.length;
- return true;
- }
-
- private number(): unknown {
- const m = NUMBER.exec(this.src.slice(this.pos));
- if (!m) return NOT_LITERAL;
- this.pos += m[0].length;
- return Number(m[0]);
- }
-
- private string(quote: string): unknown {
- this.pos++; // opening quote
- let out = '';
- for (;;) {
- const c = this.src[this.pos];
- if (c === undefined) return NOT_LITERAL; // unterminated
- if (c === quote) {
- this.pos++;
- return out;
- }
- if (c === '\\') {
- const esc = this.src[this.pos + 1];
- if (esc === undefined) return NOT_LITERAL;
- if (esc === 'u') {
- const hex = this.src.slice(this.pos + 2, this.pos + 6);
- if (!/^[0-9a-fA-F]{4}$/.test(hex)) return NOT_LITERAL;
- out += String.fromCharCode(parseInt(hex, 16));
- this.pos += 6;
- continue;
- }
- // `\'` is legal inside a single-quoted string only — JSON's set otherwise.
- if (esc === "'" && quote === "'") {
- out += "'";
- this.pos += 2;
- continue;
- }
- const simple = SIMPLE_ESCAPE[esc];
- if (simple === undefined) return NOT_LITERAL; // `\x41`, `\0`, line continuation
- out += simple;
- this.pos += 2;
- continue;
- }
- // JSON forbids raw control characters inside a string; so does this.
- if (c < ' ') return NOT_LITERAL;
- out += c;
- this.pos++;
- }
- }
-
- private array(): unknown {
- this.pos++; // '['
- const out: unknown[] = [];
- this.ws();
- if (this.src[this.pos] === ']') {
- this.pos++;
- return out;
- }
- for (;;) {
- const item = this.value();
- if (item === NOT_LITERAL) return NOT_LITERAL;
- out.push(item);
- this.ws();
- const c = this.src[this.pos];
- // A trailing comma leaves `value()` facing `]`, which it refuses — so
- // `['a',]` is NOT in the subset. Only two widenings were ruled.
- if (c === ',') {
- this.pos++;
- continue;
- }
- if (c === ']') {
- this.pos++;
- return out;
- }
- return NOT_LITERAL;
- }
- }
-
- private object(): unknown {
- this.pos++; // '{'
- const out: Record = {};
- this.ws();
- if (this.src[this.pos] === '}') {
- this.pos++;
- return out;
- }
- for (;;) {
- this.ws();
- const key = this.key();
- if (key === NOT_LITERAL) return NOT_LITERAL;
- this.ws();
- if (this.src[this.pos] !== ':') return NOT_LITERAL;
- this.pos++;
- const item = this.value();
- if (item === NOT_LITERAL) return NOT_LITERAL;
- // ⚠️ Plain `out[key] = item` would hand an authored `__proto__` key the
- // prototype SETTER. `JSON.parse` creates an ordinary own data property,
- // and this path must too: the whole point of this tier is that untrusted
- // source is safe to parse, so a widening must not open a
- // prototype-pollution lever the strict-JSON path never had.
- Object.defineProperty(out, key as string, {
- value: item,
- writable: true,
- enumerable: true,
- configurable: true,
- });
- this.ws();
- const c = this.src[this.pos];
- if (c === ',') {
- this.pos++;
- continue;
- }
- if (c === '}') {
- this.pos++;
- return out;
- }
- return NOT_LITERAL;
- }
- }
-
- /** A quoted string, or a bare identifier — the second ruled widening. */
- private key(): unknown {
- const c = this.src[this.pos];
- if (c === '"' || c === "'") return this.string(c);
- if (c !== undefined && IDENT_START.test(c)) {
- const start = this.pos;
- this.pos++;
- while (this.pos < this.src.length && IDENT_CHAR.test(this.src[this.pos])) this.pos++;
- return this.src.slice(start, this.pos);
- }
- return NOT_LITERAL;
- }
-}
diff --git a/ui/packages/sdui-parser/src/provenance.ts b/ui/packages/sdui-parser/src/provenance.ts
deleted file mode 100644
index 9a85b1391f0..00000000000
--- a/ui/packages/sdui-parser/src/provenance.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * ObjectUI — html-tier provenance (ADR-0080, objectui#4000)
- *
- * Records the one fact a renderer cannot otherwise recover: whether the node it
- * is about to render was EMITTED BY THIS PARSER (an author's `kind:'html'`
- * source, compiled tag-name-straight-through) or AUTHORED AS JSON metadata.
- *
- * Why a renderer needs it: the two surfaces have different vocabularies. A type
- * the JSON surface is steering authors away from can still be a supported tag
- * in the html tier — the plain inline element (`span`) is exactly that. (The
- * plain box element was too, until objectui#10757 deprecated `div` on the html
- * surface as well; the console's html compile now refuses it outright, so no
- * notice is involved there.) Advice aimed at the JSON surface, delivered to an
- * html-tier author, is advice they cannot act on: in that tier there is no
- * other spelling. The
- * deprecation notice fired at them anyway (objectui#4000), which is also why the
- * type could never be retired — the engine's own compiler keeps emitting it.
- *
- * The judgement therefore has to be PROVENANCE, established by the producer,
- * not a guess made at the consumer from the node's shape.
- *
- * ## Why a symbol, and why `Symbol.for`
- *
- * The marker is deliberately NOT a JSON-visible key:
- *
- * - `JSON.stringify` skips symbol keys, so provenance never lands in a
- * persisted tree. That is the point, not a limitation: "our parser produced
- * this node in this process" is not a fact about the saved document, and a
- * saved document must not be able to REPLAY it. A string key like
- * `_provenance: 'html'` would be copyable — an authored (or AI-generated)
- * JSON page could paste it in and buy itself silence from a notice that is
- * meant for it. Provenance that authored metadata can forge is not
- * provenance.
- * - `Object.keys` / `for...in` skip symbol keys, so the marker cannot leak out
- * of a renderer onto a host element as a stray DOM attribute.
- *
- * It IS enumerable, which matters more than it looks: `SchemaRenderer` hands
- * renderers a shallow copy of the node (`{ ...schema }`), and object spread
- * copies own ENUMERABLE symbol keys while dropping non-enumerable ones. A
- * non-enumerable marker would be lost in that copy and the misfire would come
- * back — silently, and only in the render path. `render.test.tsx` and the
- * components-side pin both go through the real `SchemaRenderer` for this reason.
- *
- * `Symbol.for` (the cross-realm registry) rather than a module-local `Symbol()`:
- * this package ships as both ESM and CJS and is consumed from several packages,
- * so two module instances are possible. Two local symbols would not compare
- * equal, and the failure mode of that mismatch is the ORIGINAL BUG returning
- * with no error anywhere. A registry key cannot drift.
- */
-
-/** Registry key for the marker. Stable — it is a cross-instance contract. */
-const HTML_TIER_NODE_KEY = '@object-ui/sdui-parser.html-tier-node';
-
-/**
- * The provenance marker itself. Exported for tests and for any consumer that
- * needs to reason about the key; prefer {@link isHtmlTierNode} for reading.
- */
-export const HTML_TIER_NODE: symbol = Symbol.for(HTML_TIER_NODE_KEY);
-
-/**
- * Stamp a node as html-tier output. Called by the parser as it builds each
- * element — the producer is the only place that knows this for certain.
- */
-export function markHtmlTierNode(node: T): T {
- (node as Record)[HTML_TIER_NODE] = true;
- return node;
-}
-
-/**
- * True when this node came out of the html tier's parser.
- *
- * Deliberately a plain boolean read of the marker — no fallback to a
- * string-keyed spelling, no shape heuristic ("looks like a raw tag"). Either
- * the producer marked it or it did not; a consumer-side guess is precisely what
- * this replaces.
- */
-export function isHtmlTierNode(node: unknown): boolean {
- return (
- typeof node === 'object' &&
- node !== null &&
- (node as Record)[HTML_TIER_NODE] === true
- );
-}
diff --git a/ui/packages/sdui-parser/src/types.ts b/ui/packages/sdui-parser/src/types.ts
deleted file mode 100644
index 9bf13689b74..00000000000
--- a/ui/packages/sdui-parser/src/types.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-/**
- * ObjectUI — SDUI JSX-source parser (ADR-0080)
- *
- * Types shared by the constrained JSX-source compiler. The parser turns a
- * constrained JSX *text* into an SDUI node tree (`ParsedSchemaNode`, below).
- * It PARSES — it never executes. No `import`, no `eval`, no JS.
- */
-
-/**
- * A node in the compiled SDUI tree, as parsed: an element or a text child.
- *
- * Not `SchemaNode`: `@object-ui/types` publishes that name for the closed
- * AUTHORING face, a union discriminated on a literal `type` with no index
- * signature and with primitive children admitted. This is the parser's open,
- * as-parsed data (any `type` string, the keys as authored), neither is
- * assignable to the other, and this package takes no runtime dependency on
- * `@object-ui/types` to re-export from. One exported name stood for both until
- * this declaration was renamed (objectui#6349, batch 11).
- */
-export type ParsedSchemaNode = SchemaElement | string;
-
-export interface SchemaElement {
- type: string;
- children?: ParsedSchemaNode[];
- [prop: string]: unknown;
-}
-
-export type Severity = 'error' | 'warning';
-
-export interface Diagnostic {
- severity: Severity;
- /** stable machine code, e.g. 'forbidden-tag' */
- code: string;
- message: string;
- /** byte offset into the source where the issue starts */
- start?: number;
- /** the tag/component involved, when relevant */
- tag?: string;
-}
-
-export interface ParseOptions {
- /**
- * Whitelist of allowed tag names (= registry `type` set, from the manifest).
- * When provided, any tag outside it is a `forbidden-tag` error — this is the
- * sanitization boundary. When omitted, all tags are accepted (lexing only).
- */
- allowedTags?: Set;
-}
-
-export interface ParseResult {
- /** the compiled tree, or null when the source has no valid root */
- tree: SchemaElement | null;
- diagnostics: Diagnostic[];
-}
-
-/* ------------------------------------------------------------------ *
- * Manifest — the serialized public-tier contract from the registry.
- * Produced by serializing `ComponentRegistry.getAllConfigs()` (ADR-0080 §3/§6).
- * ------------------------------------------------------------------ */
-
-export type ManifestInputType =
- | 'string'
- | 'number'
- | 'boolean'
- | 'enum'
- | 'array'
- | 'object'
- | 'color'
- | 'date'
- | 'code'
- | 'file'
- | 'slot';
-
-export interface ManifestInput {
- name: string;
- /**
- * The input's coarse type: ONE kind, or an ARRAY of kinds when the key's
- * contract is a union (objectui#3832).
- *
- * A value passes {@link validateTree}'s coarse check when ANY arm accepts it,
- * and is reported when none does — the array widens what is legal, it does
- * not switch the check off.
- *
- * The single-kind form is unchanged and stays the canonical spelling for a
- * one-arm key: `manifestFromConfigs` collapses a one-element array back to
- * the bare string, so a manifest gains arrays only where a union was really
- * declared and every already-published entry serializes byte-identically.
- */
- type: ManifestInputType | ManifestInputType[];
- /**
- * The coarse kind of the input's MEMBERS — array elements, or the values of
- * an object used as a map — as ONE kind or an ARRAY of kinds for a member
- * contract that is a union (objectui#8067).
- *
- * Absent means "not declared", which is what every input published before
- * this key existed says: {@link validateTree} checks no member and the
- * codegen emits the unnarrowed element type, exactly as before. So a
- * manifest gains this key only where a member kind was really declared, and
- * every already-published entry serializes byte-identically.
- *
- * Read the arms through `inputTypeArms(input.of)` — the same accessor
- * `type`'s arms go through, since the two fields carry the same shape and a
- * reader that forgets the array form is silently inert on it.
- */
- of?: ManifestInputType | ManifestInputType[];
- required?: boolean;
- /** allowed values for `enum` inputs */
- enum?: Array;
- /**
- * Marks a data-binding input the server must resolve (ADR-0080 §6.3):
- * `binding: 'object'` says the input NAMES an object, so the server-side
- * binding check knows what to resolve it against. Unrelated to
- * `type: 'object'`, the coarse control kind — a record-shaped value and an
- * object-naming input are two different facts.
- *
- * ## The vocabulary is exactly `'object'` — `'field'` is retired on this face too
- *
- * `'field'` stood beside it from the first draft of ADR-0080 and was never
- * written. It was retired on the serializer's INPUT boundary
- * (`RegistryConfigLike` in `index.ts`) under ADR-0049 enforce-or-remove by
- * the maintainer ruling of 2026-09-07 (decision batch #69), and on THIS
- * face by objectui#8315 — the residue that ruling did not name, which left
- * one published package narrow on one face and wide on the other.
- *
- * ⚠️ The argument for leaving this face wide was ANSWERED, not overlooked.
- * It runs: producer → reader is a subset relation, so a reader accepting a
- * value no producer emits is permissive rather than wrong. Three measured
- * facts decided against it (objectui#8315):
- *
- * 1. **This is not a pure reader face.** `manifestFromConfigs` RETURNS a
- * `Manifest`, so `ManifestInput` is also this package's OUTPUT type,
- * and this key is fed straight from the already-narrowed boundary. A
- * union wider than the producer's is imprecision on the way out, not
- * permissiveness on the way in.
- * 2. **Its sibling is a pure producer face.**
- * `ManifestValidationResult.bindings[].kind` is written by
- * `validateTree` by copying this key, so the subset relation runs the
- * other way there — see that declaration. The two are COUPLED by that
- * assignment: narrowing one alone needs a cast at the only conversion
- * site, which is the lenient fallback AGENTS.md #0.1 bans. So "both
- * narrow" and "both wide" were the only self-consistent states, and
- * only one of them can be justified on the producer face.
- * 3. **The permissiveness protected nothing.** `binding: 'field'` has
- * zero writers here and zero in the objectstack copy of this package
- * (measured 2026-09-09 on both heads, each with a firing
- * `binding: 'object'` control), and the only manifest producer in
- * either tree is `manifestFromConfigs`, whose input face is narrow.
- *
- * The reopen route is the ruling's own: a MEASURED need for field bindings
- * is filed as a widening with the vocabulary decided then — not
- * pre-declared here for a producer that does not exist.
- */
- binding?: 'object';
- description?: string;
-}
-
-export interface ManifestComponent {
- type: string;
- /** plugin namespace — provenance that drives `requires` */
- namespace?: string;
- /**
- * The declared authoring surface. An input named `children` (type `slot`)
- * is ALSO the containment declaration: `validateTree` draws
- * `not-a-container` on a child list under any component whose `inputs`
- * carry no such entry (objectui#9910; `acceptsChildren` in `validate.ts`).
- */
- inputs: ManifestInput[];
- /**
- * The NODE SLOTS this component's renderer reads besides `children`
- * (objectui#11170): positions in the key-path grammar `@object-ui/types`'
- * `NODE_SLOT_DECLARATIONS` states — `trigger`, `items[].content`,
- * `regions[].components`. `validateTree` descends each one the way it
- * descends `children`, so a slot-held node is judged too. Projected from
- * that one declaration by `manifestFromConfigs`'s `slotsFor` option; the
- * authoring tier leaves the declaration's RETIRED positions out, as the
- * spec's own authoring walks do. ⛔ This tier keeps no slot list of its own:
- * a manifest built without the option carries none, and the walk then
- * follows `children` alone.
- */
- slots?: readonly string[];
- /**
- * LAYOUT containment (objectui#6804, objectui#9910 Q2-A): the flag the
- * react-page JSX scope builder skips and the public layout ledger lists.
- * ⛔ Not "accepts children" — this tier's containment check does not read it.
- */
- isContainer?: boolean;
- /**
- * The authoring tier this entry belongs to (objectui#10735).
- *
- * Absent = the curated PUBLIC tier (ADR-0080 §4), the JSON-surface
- * AI-authoring vocabulary. `'html'` = one of the intrinsic HTML elements a
- * `kind:'html'` page may author (ADR-0081 §2), declared by the registry's
- * `HTML_TIER_INTRINSICS` roster and carried here so the ONE flat `components`
- * map can serve both of its readers: a whitelist reader (`compile`, the
- * objectstack gate) takes every key and needs no change; a reader that means
- * the curated vocabulary — the `kind:'react'` scope, the block list, a census
- * of curated blocks — filters this key out.
- *
- * ⛔ Not the registration's `tier` copied through. `manifestFromConfigs`
- * writes exactly `'html'` or nothing, so every entry published before this
- * key existed serialises byte-identically.
- */
- tier?: 'html';
-}
-
-export interface Manifest {
- /** keyed by component `type` */
- components: Record;
-}
-
-/**
- * Result of validating a compiled tree against the MANIFEST.
- *
- * Named `ManifestValidationResult`, not `ValidationResult` (objectui#3161,
- * objectstack#4115 ledger batch 7), following the convention registered on
- * objectstack#4115 for this name: **`Validation`**. `ValidationResult` is exported twice by the spec — `kernel` and
- * `contracts`, both `{ valid, errors?: [{ field, message, code? }], warnings? }`
- * for PLUGIN MANIFEST validation — and `@object-ui/core` took
- * `SchemaNodeValidationResult` under the same convention in batch 4
- * (objectui#3188). Three results, three subjects: a plugin, a schema node, and
- * this one, which carries no `valid` flag at all — it returns `diagnostics`
- * (severity lives per entry), the `requires` set the compiled page needs, and
- * the binding sites the server must resolve. Nothing here is assignable to
- * anything there in either direction.
- */
-export interface ManifestValidationResult {
- diagnostics: Diagnostic[];
- /** unique plugin namespaces referenced — the page's `requires` */
- requires: string[];
- /**
- * Binding sites the server must resolve against object schema.
- *
- * `kind` is a PRODUCER face, not a reader face: `validateTree` writes it,
- * copying {@link ManifestInput.binding} at the one site that builds this
- * array. So the subset relation that licenses a permissive READER runs the
- * other way here — a wider union accepts nothing extra, it obliges every
- * consumer to handle an arm this package cannot emit. That is why the
- * retired `'field'` arm (`9e37d9b39` on the input boundary, objectui#8315
- * here) is gone from this end as well; the measurements are on
- * {@link ManifestInput.binding}.
- */
- bindings: Array<{ tag: string; input: string; kind: 'object'; value: unknown }>;
-}
diff --git a/ui/packages/sdui-parser/src/validate.ts b/ui/packages/sdui-parser/src/validate.ts
deleted file mode 100644
index de9579c0f40..00000000000
--- a/ui/packages/sdui-parser/src/validate.ts
+++ /dev/null
@@ -1,535 +0,0 @@
-/**
- * ObjectUI — SDUI tree validation against the registry manifest (ADR-0080 §3/§6)
- *
- * Shallow, author-time validation: unknown component, unknown/missing prop,
- * wrong coarse type, illegal enum value, and containment — a child list under
- * a component whose registration declares no `children` input draws
- * `not-a-container` (objectui#9910; see `acceptsChildren`). Collects
- * `requires` (plugin provenance) and binding sites the SERVER must resolve
- * against object schema (we cannot resolve objects/fields here — that check is
- * framework-side by design).
- *
- * The walk descends `children` on every node and, for a component the
- * manifest knows, the NODE SLOTS its entry carries (`ManifestComponent.slots`,
- * objectui#11170) — the positions besides `children` through which that
- * renderer hands nodes back to `SchemaRenderer`, projected from the one
- * declaration in `@object-ui/types` by `manifestFromConfigs`. ⛔ No slot list
- * lives here.
- */
-
-import type {
- Diagnostic,
- Manifest,
- ManifestComponent,
- ManifestInput,
- ManifestInputType,
- SchemaElement,
- ParsedSchemaNode,
- ManifestValidationResult,
-} from './types.js';
-import { inputTypeArms } from './input-type.js';
-import { checkDashboardWidgetOptions } from './dashboard-widget-options.js';
-import { checkRetiredBodyDialect } from './body-dialect.js';
-import { checkRetiredVisibilityDialect } from './retired-visibility-dialects.js';
-
-/**
- * The protocol's ONE child-list key — `BaseSchema.children` — and therefore the
- * name of the `inputs` entry a registration declares when its renderer puts
- * that list on the page: `{ name: 'children', type: 'slot' }` (objectui#9910).
- */
-export const CHILD_LIST_KEY = 'children';
-
-/**
- * Does this component ACCEPT an authored child list? (objectui#9910)
- *
- * Read from exactly ONE declaration: an input named {@link CHILD_LIST_KEY} in
- * the component's `inputs`. That is the shape ten registrations already
- * carried when the maintainer ruled it the containment contract
- * (2026-09-24, objectui#9910 Q1-A "declare-and-pin"), and it is held in both
- * directions by the runtime census in
- * `packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx`:
- * a renderer that puts `schema.children` on the page without declaring the
- * input is red, and a declaration whose renderer never renders the list is
- * red too.
- *
- * ⛔ `isContainer` is NOT consulted and is NOT a fallback. It used to decide
- * this branch and it lied both ways: a hand-kept flag drifted from the code
- * (objectui#3900 / `7c9b044f4` / #6764 / #6779 found the same drift four times),
- * and after objectui#6771 converged a dozen registrations onto `children`
- * the flag put a FALSE `not-a-container` on the one key they read
- * (objectui#9910). The flag now means LAYOUT containment only — the
- * react-page JSX scope and the public layout ledger read it; this tier does
- * not (objectui#6804, objectui#9910 Q2-A).
- */
-export function acceptsChildren(comp: Pick): boolean {
- return comp.inputs.some((input) => input.name === CHILD_LIST_KEY);
-}
-
-/**
- * Where a base prop is legal without a declaration (objectui#11044).
- *
- * - `'every-node'` — on every node, and a registration's own input of the
- * same name is not consulted: {@link validateTree} skips the key before the
- * declared-input lookup.
- * - `'where-undeclared'` — on a type whose registration declares NO input of
- * that name. Where one does, the declared input wins, its `type-mismatch`
- * check included, and the generated JSX types take the declared type too.
- */
-export type SduiBasePropScope = 'every-node' | 'where-undeclared';
-
-/** One entry of {@link SDUI_BASE_PROPS}. */
-export interface SduiBaseProp {
- /** The `BaseSchema` member (`@object-ui/types`). */
- readonly name: string;
- readonly scope: SduiBasePropScope;
- /**
- * The attribute's type in the generated JSX surface (`SduiBaseProps` in
- * `sdui-intrinsics.d.ts`), or `null` for the one key that is no attribute:
- * `type`, which the tag name carries (`parse.ts` refuses a `type` attribute).
- */
- readonly tsType: string | null;
-}
-
-/**
- * The base props: the `BaseSchema` members this tier accepts on a node without
- * a registration declaring them. ONE list, read by BOTH of its consumers —
- * {@link validateTree}'s `unknown-prop` branch and `generateDts`'s
- * `SduiBaseProps` (objectui#11044). Before this list the two were separate
- * hand-kept copies and had drifted: the validator accepted `bind` and `hidden`
- * (objectui#11008) while the generated types still refused both.
- *
- * `bind` and `hidden` are `'every-node'` (objectui#11008). `BaseSchema` declares
- * both for every node and no registration declares either as an input, so
- * before they joined, the undeclared-key branch below answered every authored
- * one with `unknown-prop` — "has no prop" about a key the protocol declares,
- * on the nodes that honour it: `hidden` is read for every node by
- * `SchemaRenderer`'s hide chain, and `bind` by every renderer that calls
- * `useDataScope` (`list`, `tree-view`, the `object-*` widgets). The
- * declaration outranks the implementation, so the parser's view is the
- * declared type's, not a per-registration subset. The cost is accepted and
- * named: a `bind` on a node that does not read it — `data-table`
- * (objectui#6575) — draws nothing here either, and its render-time console
- * warning is the one signal left.
- *
- * `visibleWhen` and `testId` are `'every-node'` for the same reason
- * (objectui#11044): no registration declares either, `SchemaRenderer`'s
- * hide chain reads the first for every node, and it strips `testId` and
- * re-emits it as `data-testid`. `visibleWhen` is the canonical ADR-0089
- * predicate.
- *
- * ⛔ `visibleOn`, `hiddenOn` and `disabledOn` are NOT here and must not be
- * added. They were `BaseSchema` dialects of `visibleWhen` / `hidden` /
- * `disabled`, retired at both faces (a `?: never` tombstone on the
- * declaration, an alias refusal on the zod mirror, ADR-0089 / ADR-0049).
- * `./retired-visibility-dialects.ts` answers each by name with its canonical
- * key, the way `./body-dialect.ts` answers `body`.
- *
- * The `'where-undeclared'` members (objectui#11044, triage ruling) are the
- * `BaseSchema` members some registrations DECLARE as typed inputs — the input
- * family's `placeholder`, `label`, `name`, … . Skipping them the way the
- * `'every-node'` members are skipped would silence those registrations'
- * `type-mismatch`, so they are base props only where the type declares no
- * input of that name. ⛔ Never move one to `'every-node'` to accept a key: that
- * silences a declared type check.
- *
- * Held over the live registry by `base-props-one-list-11044.test.tsx` in
- * `@object-ui/components` — every member a `BaseSchema` member, and `body` the
- * one member left out.
- *
- * ⛔ `body` is NOT here and must not be added. It was `BaseSchema`'s second
- * child-list spelling until objectui#6771 retired it; teaching this list the
- * key was the option that ruling refused, because it would have blessed a
- * second permanent spelling of one concept. `./body-dialect.ts` answers it by
- * name instead.
- *
- * `children` IS here: the key is legal on every node, so it never draws
- * `unknown-prop` and its declared `slot` input is never type-checked. Whether
- * a given component RENDERS it is the containment question below, answered by
- * {@link acceptsChildren} from the declared input.
- */
-export const SDUI_BASE_PROPS: readonly SduiBaseProp[] = Object.freeze([
- { name: 'type', scope: 'every-node', tsType: null },
- { name: 'id', scope: 'every-node', tsType: 'string' },
- { name: 'className', scope: 'every-node', tsType: 'string' },
- { name: 'style', scope: 'every-node', tsType: 'Record' },
- { name: 'visible', scope: 'every-node', tsType: 'boolean' },
- { name: 'visibleWhen', scope: 'every-node', tsType: 'string' },
- { name: 'hidden', scope: 'every-node', tsType: 'boolean' },
- { name: 'disabled', scope: 'every-node', tsType: 'boolean' },
- { name: 'bind', scope: 'every-node', tsType: 'string' },
- { name: 'testId', scope: 'every-node', tsType: 'string' },
- { name: CHILD_LIST_KEY, scope: 'every-node', tsType: 'unknown' },
- { name: 'name', scope: 'where-undeclared', tsType: 'string' },
- { name: 'label', scope: 'where-undeclared', tsType: 'string | Record' },
- { name: 'description', scope: 'where-undeclared', tsType: 'string | Record' },
- { name: 'placeholder', scope: 'where-undeclared', tsType: 'string' },
- { name: 'data', scope: 'where-undeclared', tsType: 'unknown' },
- {
- name: 'ariaLabel',
- scope: 'where-undeclared',
- tsType: 'string | { key: string; defaultValue?: string; params?: Record }',
- },
-] satisfies SduiBaseProp[]);
-
-const basePropNames = (scope: SduiBasePropScope): Set =>
- new Set(SDUI_BASE_PROPS.filter((prop) => prop.scope === scope).map((prop) => prop.name));
-
-/** The `'every-node'` members of {@link SDUI_BASE_PROPS}: never "unknown prop". */
-const BASE_PROPS = basePropNames('every-node');
-
-/** The `'where-undeclared'` members of {@link SDUI_BASE_PROPS}. */
-const WHERE_UNDECLARED_BASE_PROPS = basePropNames('where-undeclared');
-
-const isExpr = (v: unknown): boolean =>
- typeof v === 'object' && v !== null && '$expr' in (v as Record);
-
-const isPlainObject = (v: unknown): v is Record =>
- typeof v === 'object' && v !== null && !Array.isArray(v);
-
-const isElement = (v: unknown): v is SchemaElement =>
- isPlainObject(v) && typeof v.type === 'string';
-
-/**
- * The element nodes a slot position holds on `node`, in document order.
- *
- * The position is spelled in `@object-ui/types`' key-path grammar, which that
- * package's `nodeSlotValues` implements for the other two readers and whose
- * header states it: keys separated by `.`, a key followed by `[]` meaning each
- * element of the array under it, and the value at the end being one node or a
- * list of nodes. Restated here because this package imports nothing at
- * runtime; the components-side census for objectui#11170 holds the two walks
- * to the same answer over the same fixtures. Only element-shaped values are
- * returned — a string child is legal in a slot and has nothing to validate.
- */
-function slotElements(node: SchemaElement, path: string): SchemaElement[] {
- let holders: unknown[] = [node];
- for (const segment of path.split('.')) {
- const each = segment.endsWith('[]');
- const key = each ? segment.slice(0, -2) : segment;
- const next: unknown[] = [];
- for (const holder of holders) {
- if (!isPlainObject(holder)) continue;
- const value = holder[key];
- if (!each) next.push(value);
- else if (Array.isArray(value)) next.push(...value);
- }
- holders = next;
- }
- const found: SchemaElement[] = [];
- for (const holder of holders) {
- for (const value of Array.isArray(holder) ? holder : [holder]) {
- if (isElement(value)) found.push(value);
- }
- }
- return found;
-}
-
-export function validateTree(tree: SchemaElement | null, manifest: Manifest): ManifestValidationResult {
- const diagnostics: Diagnostic[] = [];
- const requires = new Set();
- const bindings: ManifestValidationResult['bindings'] = [];
-
- const visit = (node: ParsedSchemaNode): void => {
- if (typeof node === 'string') return;
- const comp = manifest.components[node.type];
- if (!comp) {
- diagnostics.push({
- severity: 'error',
- code: 'unknown-component',
- message: `<${node.type}> is not a known component`,
- tag: node.type,
- });
- } else {
- if (comp.namespace) requires.add(comp.namespace);
- const byName = new Map(comp.inputs.map((i) => [i.name, i]));
-
- // required present?
- for (const input of comp.inputs) {
- if (input.required && !(input.name in node)) {
- diagnostics.push({
- severity: 'error',
- code: 'missing-required-prop',
- message: `<${node.type}> is missing required prop "${input.name}"`,
- tag: node.type,
- });
- }
- }
-
- // each provided prop
- for (const [key, value] of Object.entries(node)) {
- if (BASE_PROPS.has(key)) continue;
- // A declaration outranks a `'where-undeclared'` base prop
- // (objectui#11044): skipped only when this type declares no input of
- // that name, so a declared one keeps its type check below.
- if (WHERE_UNDECLARED_BASE_PROPS.has(key) && !byName.has(key)) continue;
- const input = byName.get(key);
- if (!input) {
- // The retired `body` child-list dialect gets its replacement named
- // rather than the bare "has no prop" every typo gets
- // (objectui#6771). Asked INSIDE this branch, so a component that
- // declares its own `body` input keeps its declared type check —
- // mechanism in `./body-dialect.ts`. Its containment verdict is the
- // SAME predicate the `children` branch below reads — the declared
- // `children` input, never `isContainer` (objectui#9910) — so the two
- // spellings cannot disagree about which components take a list.
- // The retired node-level visibility dialects (`visibleOn` /
- // `hiddenOn` / `disabledOn`) get their canonical key named the same
- // way — mechanism in `./retired-visibility-dialects.ts`.
- const retiredBody = checkRetiredBodyDialect(node.type, key, value, acceptsChildren(comp));
- const retiredDialect = retiredBody ?? checkRetiredVisibilityDialect(node.type, key);
- diagnostics.push(
- retiredDialect ?? {
- severity: 'warning',
- code: 'unknown-prop',
- message: `<${node.type}> has no prop "${key}"`,
- tag: node.type,
- },
- );
- continue;
- }
- if (input.binding) {
- bindings.push({ tag: node.type, input: key, kind: input.binding, value });
- }
- if (isExpr(value)) {
- // A braced value that failed JSON materialization compiled to the
- // parser's deferred `{ $expr }` marker — and NOTHING downstream
- // evaluates that marker: this tier parses, never executes
- // (ADR-0080), and no renderer consumes `$expr`. The value therefore
- // reaches the renderer as an opaque object, every defensive
- // non-array/non-object read degrades it to "not declared", and the
- // author's binding silently vanishes (objectui#6598: eight `columns`
- // spellings on a data block, all eaten without a single diagnostic —
- // rows rendered, zero data columns). ADR-0078 prohibits exactly this
- // parsed-but-silently-inert state, so name it at compile time, with
- // the fix in the message.
- //
- // The message must name the CURRENT accepted grammar, and objectui#6614
- // (Q1-A, ruled 2026-08-28) moved it: `interpretBrace` now materializes
- // the JS literal subset, so single-quoted strings and unquoted
- // identifier keys REACH the renderer and can no longer draw this
- // warning. The old wording ("write it as JSON, double-quoted") named a
- // now-legal spelling as the illegal one — advice that would have sent
- // an author to edit working source. What is left on this side of the
- // boundary is a genuine expression, so that is what the message names.
- //
- // Warning, not error, per the `8d58f46b4` precedent for inert
- // authored keys. ⛔ Escalation to error is objectui#6614 Q2 and is
- // deliberately NOT part of this change: it belongs at the SAVE GATE,
- // once the framework wires the registry manifest into
- // `validate-jsx-pages` (objectstack#12719 records that gap).
- diagnostics.push({
- severity: 'warning',
- code: 'inert-expression',
- message:
- `<${node.type}> prop "${key}" is a braced expression this tier never evaluates — ` +
- `the value will be silently ignored at render. This tier materializes LITERALS only ` +
- `(strings, numbers, booleans, null, arrays, objects; quotes may be single or double, ` +
- `object keys may be unquoted), e.g. columns={['name','amount']} works — ` +
- `columns={rows.map((r) => r.name)} cannot`,
- tag: node.type,
- });
- } else {
- const typeDiag = checkType(node.type, input, value);
- if (typeDiag) {
- diagnostics.push(typeDiag);
- } else {
- // Members only once the CONTAINER kind was accepted. Reporting a
- // member of a value that is not even the declared container is two
- // diagnostics for one mistake, and the second one names positions
- // of a shape the author did not write (objectui#8067).
- const memberDiag = checkMemberTypes(node.type, input, value);
- if (memberDiag) diagnostics.push(memberDiag);
- }
- }
- }
-
- // containment — decided by the declared `children` input and by NOTHING
- // else (objectui#9910 Q1-A). ⛔ No `isContainer` fallback: see
- // `acceptsChildren` for why the flag stopped deciding this.
- if (node.children?.length && !acceptsChildren(comp)) {
- diagnostics.push({
- severity: 'warning',
- code: 'not-a-container',
- message: `<${node.type}> does not accept children`,
- tag: node.type,
- });
- }
-
- // Dashboard widgets: an `options` key riding the spec's `.passthrough()`
- // that no renderer consumes is legal, silent and inert — warn, naming
- // the consumed set (the 2026-08-23 ruling; census + scope in
- // `./dashboard-widget-options.ts`). Like `not-a-container`, this runs
- // only for a component the manifest knows: an unresolved tag already
- // drew `unknown-component`, and deep diagnostics on it would be noise.
- diagnostics.push(...checkDashboardWidgetOptions(node));
- }
-
- if (node.children) node.children.forEach(visit);
- // The node slots this component's renderer reads besides `children`
- // (objectui#11170), as its manifest entry declares them. An unknown
- // component has no entry and so no slots: its `unknown-component` above is
- // the answer, and nothing under it is judged.
- for (const path of comp?.slots ?? []) {
- for (const element of slotElements(node, path)) visit(element);
- }
- };
-
- if (tree) visit(tree);
- return { diagnostics, requires: [...requires], bindings };
-}
-
-/** The values an `enum` arm admits, flattened from either declaration form. */
-const enumValues = (input: ManifestInput): unknown[] =>
- (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
-
-/**
- * Does ONE coarse arm accept this value?
- *
- * An arm outside the vocabulary — and `'slot'`, which describes a child
- * position rather than a value — accepts everything, preserving the old
- * `default: return null` branch: those inputs never drew a diagnostic and must
- * not start now.
- */
-function armAccepts(arm: ManifestInputType, input: ManifestInput, value: unknown): boolean {
- switch (arm) {
- case 'number':
- return typeof value === 'number';
- case 'boolean':
- return typeof value === 'boolean';
- case 'string':
- case 'color':
- case 'date':
- case 'code':
- case 'file':
- return typeof value === 'string';
- case 'array':
- return Array.isArray(value);
- case 'object':
- return typeof value === 'object' && value !== null && !Array.isArray(value);
- case 'enum':
- return enumValues(input).includes(value as never);
- default:
- return true;
- }
-}
-
-/** How one arm is named in a diagnostic message. */
-function armExpectation(arm: ManifestInputType, input: ManifestInput): string {
- switch (arm) {
- case 'number':
- return 'a number';
- case 'boolean':
- return 'a boolean';
- case 'array':
- return 'an array';
- case 'object':
- return 'an object';
- case 'enum':
- return `one of ${JSON.stringify(enumValues(input))}`;
- default:
- return 'a string';
- }
-}
-
-/**
- * The member positions of a container value, as `[position, member]` pairs, or
- * `null` when the value has no member position to speak of.
- *
- * Arrays index by position and objects by key, which is exactly the pair
- * `ComponentInput.of` describes: array ELEMENTS, and the VALUES of an object
- * used as a map. A scalar returns `null` rather than an empty list, so a value
- * that only satisfied a non-container arm of a union declaration
- * (`type: ['string', 'array'], of: 'string'`) is not reported as an empty
- * container that trivially conforms — it is simply not the arm `of` describes.
- */
-function memberEntries(value: unknown): Array<[string, unknown]> | null {
- if (Array.isArray(value)) return value.map((member, index) => [String(index), member]);
- if (typeof value === 'object' && value !== null) return Object.entries(value);
- return null;
-}
-
-/**
- * Coarse MEMBER check, over the arms `of` declares (objectui#8067).
- *
- * The same question `checkType` asks, one level down and with the same answer
- * shape: ANY declared arm accepting a member clears it, a member no arm accepts
- * is reported, and an input that declares no `of` is checked exactly as it was
- * before the key existed — this function returns immediately on an empty arm
- * list, so nothing published today changes severity or gains a diagnostic.
- *
- * ONE diagnostic per prop, naming every offending position, rather than one per
- * member: a page that passes an array of the wrong member kind is one mistake
- * made once, and N copies of it is the noise this repo treats as the thing that
- * trains authors to dismiss real reports.
- *
- * Severity mirrors `checkType`'s rule for the same reason — `error` when an
- * `enum` arm is present, because a closed list is the one fact this layer can
- * be certain about; `warning` otherwise, since the coarse kind is a KIND claim
- * and `os validate` / `os build` remain the judge of values.
- */
-function checkMemberTypes(tag: string, input: ManifestInput, value: unknown): Diagnostic | null {
- const arms = inputTypeArms(input.of);
- if (arms.length === 0) return null;
- const entries = memberEntries(value);
- if (entries === null) return null;
- const offenders = entries.filter(
- ([, member]) => !arms.some((arm) => armAccepts(arm, input, member)),
- );
- if (offenders.length === 0) return null;
- const expectation = arms.map((arm) => armExpectation(arm, input)).join(' or ');
- return {
- severity: arms.includes('enum') ? 'error' : 'warning',
- code: 'member-type-mismatch',
- message: `<${tag}> prop "${input.name}" expected every member to be ${expectation}` +
- ` — ${offenders.map(([position]) => `[${position}]`).join(', ')} ` +
- `${offenders.length === 1 ? 'is' : 'are'} not`,
- tag,
- };
-}
-
-/**
- * Coarse type check, over the arms an input declares (objectui#3832).
- *
- * ANY arm accepting the value clears the prop — that is what lets a key whose
- * contract is a union (`string | number`, or a string plus an inline
- * translation map) be declared honestly instead of picking one arm and having
- * this function report the other arm's legal values.
- *
- * When NO arm accepts it the prop is still reported; a union widens what counts
- * as legal, it does not turn the check off. Two properties of the reporting are
- * deliberate:
- *
- * - A single-arm input produces the byte-identical diagnostic it always did,
- * `invalid-enum` included. This change adds a form; it does not restate the
- * old one.
- * - A multi-arm input produces ONE diagnostic naming every arm, at the
- * STRICTEST arm's severity — `error` when an `enum` arm is present, because
- * an enum's closed list is the one fact this layer can be certain about, and
- * a value outside it should not become dismissible merely because a second
- * arm was added next to it. Its code is `type-mismatch` (not `invalid-enum`)
- * since the reported fact is "fits none of the declared arms", and the
- * message carries the allowed values so the author still sees the list.
- */
-function checkType(tag: string, input: ManifestInput, value: unknown): Diagnostic | null {
- const arms = inputTypeArms(input.type);
- if (arms.length === 0) return null;
- if (arms.some((arm) => armAccepts(arm, input, value))) return null;
-
- if (arms.length === 1 && arms[0] === 'enum') {
- return {
- severity: 'error',
- code: 'invalid-enum',
- message: `<${tag}> prop "${input.name}"=${JSON.stringify(value)} is not one of ${JSON.stringify(enumValues(input))}`,
- tag,
- };
- }
-
- return {
- severity: arms.includes('enum') ? 'error' : 'warning',
- code: 'type-mismatch',
- message: `<${tag}> prop "${input.name}" expected ${arms
- .map((arm) => armExpectation(arm, input))
- .join(' or ')}`,
- tag,
- };
-}
diff --git a/ui/packages/sdui-parser/tsconfig.json b/ui/packages/sdui-parser/tsconfig.json
deleted file mode 100644
index 27f58e966be..00000000000
--- a/ui/packages/sdui-parser/tsconfig.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "compilerOptions": {
- "outDir": "dist",
- "rootDir": "src",
- "noEmit": false,
- "declaration": true,
- // The incremental build RECORD, pinned OUTSIDE the published build output.
- // Left unpinned, TypeScript derives it from `outDir`/`rootDir`: a package with
- // no `rootDir` gets `dist/tsconfig.tsbuildinfo` -- inside what `files: ["dist",
- // ...]` publishes -- and `pnpm check:published-dist` then reds on a byte-identical
- // tree as soon as anything runs `tsc` (objectui#9189). Pinned in every composite
- // package so the location is a decision, not a side effect of an unrelated option.
- "tsBuildInfoFile": "./tsconfig.tsbuildinfo",
- "composite": true,
- "lib": ["ES2020"]
- },
- "include": ["src"],
- // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under
- // `__tests__/` that is not itself called `*.test.ts` is otherwise a program
- // input, and an EMITTING program writes it into the published `dist`
- // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found
- // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude`
- // (objectui#7212), which derives these directory names from `TOOLING_FILE`
- // in scripts/check-phantom-dependencies.mjs.
- "exclude": [
- "**/__tests__/**",
- "**/__mocks__/**",
- "**/__benchmarks__/**",
- "src/**/*.test.ts",
- "src/**/*.test.tsx"
- ]
-}
diff --git a/ui/packages/sdui-parser/tsconfig.test.json b/ui/packages/sdui-parser/tsconfig.test.json
deleted file mode 100644
index 0b2cce7e024..00000000000
--- a/ui/packages/sdui-parser/tsconfig.test.json
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- // Type-checks this package's TESTS, which `tsconfig.json` excludes.
- // See `packages/types/tsconfig.test.json` for why that exclusion was a hole:
- // the build correctly keeps tests out of `dist`, but nothing else compiled
- // them, so a test could assert a contract the compiler never checked.
- //
- // Wiring this up is what revealed that `render.test.tsx` imported
- // `@object-ui/core` and `@object-ui/react` with neither declared in
- // package.json — phantom dependencies that only resolved through the ROOT
- // tsconfig's `paths` alias and vitest. Both are devDependencies now, so the
- // test resolves them the way a consumer would.
- "extends": "../../tsconfig.json",
- "compilerOptions": {
- // objectui#8691: test programs only. One notch above the package build's
- // ES2020, mirroring packages/app-shell. The package SOURCE tsconfig is
- // untouched, so what this package SHIPS still compiles at the ES2020
- // baseline; nothing here is emitted.
- "lib": ["ES2022", "DOM", "DOM.Iterable"],
- "noEmit": true,
- // The package build emits `dist`; this project emits nothing.
- "composite": false,
- // Drop the root tsconfig's source-tree `paths` so `@object-ui/*` and
- // `@objectstack/spec` resolve through the workspace dependency's built
- // `.d.ts` instead of pulling sibling sources in as program inputs (TS6059).
- // This is also what makes the phantom-dependency check above meaningful:
- // with the alias in place, an undeclared workspace import still compiles.
- "paths": {}
- },
- "include": ["src/**/*.test.ts", "src/**/*.test.tsx"]
-}
diff --git a/ui/packages/sdui-parser/verify.ts b/ui/packages/sdui-parser/verify.ts
deleted file mode 100644
index d345e40642b..00000000000
--- a/ui/packages/sdui-parser/verify.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/* Standalone functional verification (run with tsx — no vitest infra needed). */
-import assert from 'node:assert/strict';
-import { compile, generateDts, manifestFromConfigs } from './src/index.ts';
-
-const manifest = manifestFromConfigs([
- { type: 'flex', namespace: 'ui', isContainer: true, inputs: [
- { name: 'direction', type: 'enum', enum: ['row', 'col'] },
- { name: 'gap', type: 'number' },
- { name: 'wrap', type: 'boolean' },
- { name: 'children', type: 'slot' },
- ] },
- { type: 'card', namespace: 'ui', isContainer: true, inputs: [{ name: 'title', type: 'string' }, { name: 'children', type: 'slot' }] },
- { type: 'object-table', namespace: 'plugin-grid', isContainer: false, inputs: [
- { name: 'object', type: 'string', required: true, binding: 'object' },
- { name: 'columns', type: 'array' },
- { name: 'pageSize', type: 'number' },
- ] },
-]);
-
-const check = (_name: string, fn: () => void) => {
- fn();
-};
-
-check('compiles valid JSX → tree + requires + bindings', () => {
- const r = compile(
- `
-
- `,
- manifest,
- );
- assert.equal(r.ok, true);
- assert.deepEqual(r.diagnostics, []);
- assert.equal(r.tree?.type, 'flex');
- assert.equal((r.tree as any).gap, 4);
- assert.equal((r.tree as any).wrap, true);
- assert.deepEqual(r.requires.sort(), ['plugin-grid', 'ui']);
- assert.deepEqual(r.bindings, [{ tag: 'object-table', input: 'object', kind: 'object', value: 'account' }]);
-});
-
-check('rejects unknown component via whitelist', () => {
- const r = compile(``, manifest);
- assert.equal(r.ok, false);
- assert.ok(r.diagnostics.some((d) => d.code === 'forbidden-tag'));
-});
-
-check('flags missing required prop', () => {
- const r = compile(``, manifest);
- assert.equal(r.ok, false);
- assert.ok(r.diagnostics.some((d) => d.code === 'missing-required-prop'));
-});
-
-check('flags illegal enum + coarse type mismatch', () => {
- const r = compile(``, manifest);
- const codes = r.diagnostics.map((d) => d.code);
- assert.ok(codes.includes('invalid-enum'));
- assert.ok(codes.includes('type-mismatch'));
-});
-
-check('rejects event handlers + raw-html injection', () => {
- const r = compile(``, manifest);
- assert.equal(r.diagnostics.filter((d) => d.code === 'forbidden-attr').length, 2);
-});
-
-check('codegen emits a JSX.IntrinsicElements augmentation', () => {
- const dts = generateDts(manifest);
- assert.ok(dts.includes('"object-table": ObjectTableProps;'));
- assert.ok(dts.includes('object: string;'));
- assert.ok(dts.includes('pageSize?: number;'));
- assert.ok(dts.includes('direction?: "row" | "col";'));
-});
diff --git a/ui/packages/types/src/__tests__/component-input-retired-keys-7493.test.ts b/ui/packages/types/src/__tests__/component-input-retired-keys-7493.test.ts
index a23cb10d110..5aa178b7751 100644
--- a/ui/packages/types/src/__tests__/component-input-retired-keys-7493.test.ts
+++ b/ui/packages/types/src/__tests__/component-input-retired-keys-7493.test.ts
@@ -66,6 +66,8 @@ import type { ComponentInput, ComponentMeta } from '../base';
import { ComponentInputSchema, ComponentMetaSchema } from '../zod/base.zod';
const ROOT = resolve(__dirname, '../../../..');
+/** The monorepo root: the serializer lives in `@objectstack/sdui-parser` there (objectstack#25712 item 3). */
+const MONOREPO_ROOT = resolve(__dirname, '../../../../..');
/** The three retired keys, each with the value the registrations used to write. */
const RETIRED = {
@@ -296,7 +298,7 @@ describe('the `ComponentInput` census after the retirement', () => {
describe('the publication path: `manifestFromConfigs` forwards every live key', () => {
it('reads the serializer source and finds the seven, and none of the three', () => {
- const src = readFileSync(resolve(ROOT, 'packages/sdui-parser/src/index.ts'), 'utf8');
+ const src = readFileSync(resolve(MONOREPO_ROOT, 'packages/sdui-parser/src/index.ts'), 'utf8');
const fn = src.slice(src.indexOf('export function manifestFromConfigs('));
const forwarded = /inputs: \(c\.inputs \?\? \[\]\)\.map\(\(i\) => \(\{([\s\S]*?)\}\)\)/.exec(fn)?.[1] ?? '';
expect(forwarded.length, 'the serializer no longer maps inputs where this pin reads it').toBeGreaterThan(0);
diff --git a/ui/packages/types/src/__tests__/content-channel-parser-tier-clause-10928.test.ts b/ui/packages/types/src/__tests__/content-channel-parser-tier-clause-10928.test.ts
index a7790544677..9395b99be60 100644
--- a/ui/packages/types/src/__tests__/content-channel-parser-tier-clause-10928.test.ts
+++ b/ui/packages/types/src/__tests__/content-channel-parser-tier-clause-10928.test.ts
@@ -15,7 +15,7 @@
* rendered NOTHING. They used to go on "no error, no warning, no element". The
* render half of that is true — `SchemaRenderer` strips both keys out of the
* props bag it spreads and no renderer reads them. The warning half was false:
- * the parser tier's `validateTree` (`@object-ui/sdui-parser`) warns
+ * the parser tier's `validateTree` (`@objectstack/sdui-parser`) warns
* `not-a-container` for a child list under a registration that declares no
* `children` input (objectui#9910), and it answers a `body` child list with the
* same code (`checkRetiredBodyDialect`, objectui#6771). A reads-neither
@@ -28,7 +28,7 @@
* ## Why this file pins the prose, and where the parser half is pinned
*
* `@object-ui/types` declares no workspace dependency, and its tests import
- * none: `@object-ui/sdui-parser` is not importable here without declaring it,
+ * none: `@objectstack/sdui-parser` is not importable here without declaring it,
* which a prose pin does not justify. The behaviour the clause describes is
* held where the parser and the registry live, and those pins are named here
* so a change to either side has a place to look:
diff --git a/ui/packages/types/src/__tests__/silence-clause-parser-tier-closure-10981.test.ts b/ui/packages/types/src/__tests__/silence-clause-parser-tier-closure-10981.test.ts
index 4266f4b3394..339e72d4578 100644
--- a/ui/packages/types/src/__tests__/silence-clause-parser-tier-closure-10981.test.ts
+++ b/ui/packages/types/src/__tests__/silence-clause-parser-tier-closure-10981.test.ts
@@ -12,7 +12,7 @@
* (`unknown-prop`).
*
* Four more sites in this package said an authored value drew no error and no
- * warning. The parser tier's `validateTree` (`@object-ui/sdui-parser`)
+ * warning. The parser tier's `validateTree` (`@objectstack/sdui-parser`)
* answered each of them when objectui#10981 landed, with the code the
* registration's declared inputs decide (one has moved since — the `bind` row):
*
@@ -37,7 +37,7 @@
* ## Why this file pins the prose, and where the parser half lives
*
* `@object-ui/types` declares no workspace dependency and its tests import
- * none, so `@object-ui/sdui-parser` is not importable here (the same reason as
+ * none, so `@objectstack/sdui-parser` is not importable here (the same reason as
* the objectui#10928 and objectui#10959 pins). The four readings behind these
* clauses — every key spelling and value shape each sentence describes, over
* the live registry, with a render leg — are on objectui#10981's pull request:
diff --git a/ui/packages/types/src/__tests__/unknown-prop-parser-tier-clause-10959.test.ts b/ui/packages/types/src/__tests__/unknown-prop-parser-tier-clause-10959.test.ts
index b080e3cce7c..5cd21661ca9 100644
--- a/ui/packages/types/src/__tests__/unknown-prop-parser-tier-clause-10959.test.ts
+++ b/ui/packages/types/src/__tests__/unknown-prop-parser-tier-clause-10959.test.ts
@@ -23,7 +23,7 @@
*
* The render half is true: the value changed nothing on the page and logged
* nothing. The warning half was false. The parser tier's `validateTree`
- * (`@object-ui/sdui-parser`) answers a key its registration does not declare
+ * (`@objectstack/sdui-parser`) answers a key its registration does not declare
* with `unknown-prop`, and `header-bar` declares only `crumbs`, `search`,
* `actions` and `rightContent`. It answers `body` the same way on these nine,
* whatever its shape, because each of them declares the `children` input:
@@ -42,7 +42,7 @@
* ## Why this file pins the prose, and where the parser half lives
*
* `@object-ui/types` declares no workspace dependency and its tests import
- * none, so `@object-ui/sdui-parser` is not importable here without declaring
+ * none, so `@objectstack/sdui-parser` is not importable here without declaring
* it (the same reason as objectui#10928's pin). The `body` half of the parser
* behaviour is pinned in `packages/sdui-parser`: "the retired `body` spelling
* follows the SAME predicate" in `containment-declared-slot-9910.test.ts`, and
diff --git a/ui/packages/types/src/node-slots.ts b/ui/packages/types/src/node-slots.ts
index 6798a9abf02..5100798a6b2 100644
--- a/ui/packages/types/src/node-slots.ts
+++ b/ui/packages/types/src/node-slots.ts
@@ -21,7 +21,7 @@
* declared them, so the three walks that judge a document — the `objectui
* check` unevaluated-expression gate (`findUnbindableTextExpressions` in
* `@object-ui/cli`), `validateChildren` in `@object-ui/core`'s schema
- * validator and `validateTree` in `@object-ui/sdui-parser` — stopped at
+ * validator and `validateTree` in `@objectstack/sdui-parser` — stopped at
* `children` and never reached a node under a slot: a `${…}` on such a node's
* `title` reached the user as literal text, unrefused.
*
@@ -38,7 +38,7 @@
* spelling (objectui#6771) and stays retired: it appears below ONLY on the
* types whose renderer was measured still reading it for stored documents,
* marked `retired`, and the parser tier's refusal of the spelling by name
- * (`RETIRED_CHILD_LIST_KEY` in `@object-ui/sdui-parser`) is unchanged.
+ * (`RETIRED_CHILD_LIST_KEY` in `@objectstack/sdui-parser`) is unchanged.
*
* ## Where each row comes from
*
diff --git a/ui/packages/types/src/zod/layout.zod.ts b/ui/packages/types/src/zod/layout.zod.ts
index a3297804db6..e1efc70a41f 100644
--- a/ui/packages/types/src/zod/layout.zod.ts
+++ b/ui/packages/types/src/zod/layout.zod.ts
@@ -614,7 +614,7 @@ const FLEX_FLAT_PROP =
* bag, and as code composes it. A stored flat `flex` node keeps rendering,
* because `SchemaRenderer` reads both spellings and the narrowing is on the
* authoring faces only; a node compiled from the `kind: 'html'` JSX tier
- * (`@object-ui/sdui-parser`) never passes through this face.
+ * (`@objectstack/sdui-parser`) never passes through this face.
*/
export const FlexBlockSchema = BaseSchema.extend({
type: z.literal('flex'),
diff --git a/ui/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts b/ui/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts
index 8a3f662cddd..ef9a516c2f5 100644
--- a/ui/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts
+++ b/ui/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts
@@ -59,7 +59,7 @@
* dropped that second arm — the past tense is load-bearing, because a reader
* replaying this sentence against `@object-ui/core` today finds one spelling),
* and the manifest
- * tier's `BASE_PROPS` in `@object-ui/sdui-parser` (which carries `children` and
+ * tier's `BASE_PROPS` in `@objectstack/sdui-parser` (which carries `children` and
* does NOT carry `body`). Both readers in this package guarded their child-list
* recursion on `schema.body` ALONE.
*
@@ -525,7 +525,8 @@ describe('the two spellings of the retired key cannot drift (objectui#6771)', ()
it('the extension and `sdui-parser` name the same key', () => {
const parserSource = readFileSync(
- resolve(HERE, '../../../sdui-parser/src/body-dialect.ts'),
+ // The one parser: the monorepo root's `packages/sdui-parser` (#25712 item 3).
+ resolve(HERE, '../../../../../packages/sdui-parser/src/body-dialect.ts'),
'utf8'
);
diff --git a/ui/scripts/__tests__/check-dist-completeness.test.ts b/ui/scripts/__tests__/check-dist-completeness.test.ts
index c317fec3581..9b533c9e6a7 100644
--- a/ui/scripts/__tests__/check-dist-completeness.test.ts
+++ b/ui/scripts/__tests__/check-dist-completeness.test.ts
@@ -269,7 +269,7 @@ describe('this repository', () => {
const tscPackages = discoverTscPackages(repoRoot) as Array<{ name: string; dir: string }>;
it('finds the tsc-built packages', () => {
- expect(tscPackages.length).toBeGreaterThanOrEqual(12);
+ expect(tscPackages.length).toBeGreaterThanOrEqual(11);
});
it.each(tscPackages.map((p) => [p.name, p.dir] as const))(
diff --git a/ui/scripts/__tests__/package-files-exist.test.ts b/ui/scripts/__tests__/package-files-exist.test.ts
index 308a64caef9..dd382071c31 100644
--- a/ui/scripts/__tests__/package-files-exist.test.ts
+++ b/ui/scripts/__tests__/package-files-exist.test.ts
@@ -442,20 +442,21 @@ describe('published packages that declare a license ship its text (objectui#3696
// Same anti-vacuity floor as the guard above, sitting just under the 39
// measured on main@3e601773e. A predicate that quietly matches nothing —
// a renamed `license` field, a lost workspace root, a `private` default
- // flipped — would otherwise report success over an empty set.
- expect(owingPackages.length).toBeGreaterThanOrEqual(38);
-
- // The two packages objectui#3696 fixed must be in scope BY NAME. If either
- // drops out of the population, this guard has stopped watching the exact
- // spot that motivated it, and the pin below would pass on a package nobody
- // is checking any more.
+ // flipped — would otherwise report success over an empty set. Lowered by
+ // one when `@object-ui/sdui-parser` was retired for the root
+ // `@objectstack/sdui-parser` (objectstack#25712 item 3): 37 measured then.
+ expect(owingPackages.length).toBeGreaterThanOrEqual(37);
+
+ // The package objectui#3696 fixed must be in scope BY NAME. If it drops
+ // out of the population, this guard has stopped watching the exact spot
+ // that motivated it, and the pin below would pass on a package nobody is
+ // checking any more. (The second, `@object-ui/sdui-parser`, was retired
+ // for `@objectstack/sdui-parser` by objectstack#25712 item 3, which carries
+ // its MIT notice at the repo root.)
const names = owingPackages.map((p) => p.name);
expect(names, '@object-ui/react-runtime must still be required to ship license text').toContain(
'@object-ui/react-runtime',
);
- expect(names, '@object-ui/sdui-parser must still be required to ship license text').toContain(
- '@object-ui/sdui-parser',
- );
});
it('every package that declares a license has the text on disk', () => {
@@ -521,14 +522,13 @@ describe('published packages that declare a license ship its text (objectui#3696
).toEqual([]);
});
- it('pins the three packages fixed in objectui#3696 / objectui#3702', () => {
+ it('pins the packages fixed in objectui#3696 / objectui#3702', () => {
// The general assertion covers these, but naming them means a revert points
// straight at the issue that explains why the files have to be there —
// and, unlike the general assertion, this cannot be satisfied by adding a
// baseline line.
const fixed: Record = {
'packages/react-runtime': 'objectui#3696',
- 'packages/sdui-parser': 'objectui#3696',
// Found by re-running objectui#3696's census over ALL FOUR workspace
// roots instead of `packages/*` alone, which is how it was missed twice.
'apps/console': 'objectui#3702',
diff --git a/ui/scripts/__tests__/registry-assertion-ratchet-8316.test.ts b/ui/scripts/__tests__/registry-assertion-ratchet-8316.test.ts
index cca71a23b43..b5c7134de4c 100644
--- a/ui/scripts/__tests__/registry-assertion-ratchet-8316.test.ts
+++ b/ui/scripts/__tests__/registry-assertion-ratchet-8316.test.ts
@@ -108,7 +108,7 @@ describe('the scope is one file, not the repo (objectui#8316 fence)', () => {
});
it('does not reach the `gen-manifest.ts` sibling the card recorded and did not file', async () => {
- const sibling = path.join(REPO_ROOT, 'packages/sdui-parser/scripts/gen-manifest.ts');
+ const sibling = path.join(REPO_ROOT, 'apps/console/scripts/gen-manifest.ts');
const code = 'export const configs = [] as unknown as RegistryConfigLike[];\n';
// Two reports at the guarded path, because `as unknown as T` is two
// assertions — which is the control that makes the sibling's zero a
diff --git a/ui/scripts/__tests__/select-type-check-scope.test.ts b/ui/scripts/__tests__/select-type-check-scope.test.ts
index 003b9a1b622..1b5accad221 100644
--- a/ui/scripts/__tests__/select-type-check-scope.test.ts
+++ b/ui/scripts/__tests__/select-type-check-scope.test.ts
@@ -72,9 +72,11 @@ describe('select-type-check-scope — the root rule (objectui#12222)', () => {
'e2e/smoke.spec.ts',
'playwright.config.ts',
'tsconfig.e2e.json',
- // A `build` input that sits INSIDE a package: every package's build hash
- // reads it, while the package graph would only select its owner.
- 'packages/sdui-parser/src/parse.ts',
+ // A `type-check` input that sits INSIDE a package: every package's
+ // type-check hash reads it, while the package graph would only select its
+ // owner. (The `build` specimen, `packages/sdui-parser/src/parse.ts`, left
+ // with the ui copy of the parser — objectstack#25712 item 3.)
+ 'packages/cli/src/utils/issue-path.ts',
];
expect(caught(roots)).toEqual(roots);
});
diff --git a/ui/scripts/__tests__/side-effects-declaration-consistency.test.ts b/ui/scripts/__tests__/side-effects-declaration-consistency.test.ts
index 42c6ce1e742..4977a39b071 100644
--- a/ui/scripts/__tests__/side-effects-declaration-consistency.test.ts
+++ b/ui/scripts/__tests__/side-effects-declaration-consistency.test.ts
@@ -1075,9 +1075,11 @@ describe('`sideEffects` declarations match load-time behaviour (objectui#3943)',
// sit at the census re-measured for objectui#13464, when every published
// package came to declare the field: 12 `false` and 17 arrays, the rest
// `true` — so a lost workspace root, a broken glob parser or a renamed
- // field is a failure and not a quiet pass.
- expect(declaringPackages.length).toBeGreaterThanOrEqual(29);
- expect(falsePackages.length).toBeGreaterThanOrEqual(12);
+ // field is a failure and not a quiet pass. One fewer since
+ // `@object-ui/sdui-parser` (a `false`) was retired for the root
+ // `@objectstack/sdui-parser` (objectstack#25712 item 3): 28, 11 of them `false`.
+ expect(declaringPackages.length).toBeGreaterThanOrEqual(28);
+ expect(falsePackages.length).toBeGreaterThanOrEqual(11);
expect(arrayPackages.length).toBeGreaterThanOrEqual(17);
// The census by name. `true` packages (`components`, `fields`, four
@@ -1111,7 +1113,6 @@ describe('`sideEffects` declarations match load-time behaviour (objectui#3943)',
'@object-ui/providers',
'@object-ui/react',
'@object-ui/react-runtime',
- '@object-ui/sdui-parser',
'@object-ui/types',
]);
diff --git a/ui/scripts/__tests__/unconsumed-widget-option-claim-6186.test.ts b/ui/scripts/__tests__/unconsumed-widget-option-claim-6186.test.ts
index e5b57c816d2..c85a1ac8af2 100644
--- a/ui/scripts/__tests__/unconsumed-widget-option-claim-6186.test.ts
+++ b/ui/scripts/__tests__/unconsumed-widget-option-claim-6186.test.ts
@@ -59,7 +59,7 @@ import { blank, maskComments, scanSource } from '../js-comment-mask.mjs';
* - **A computed access through a VARIABLE key is invisible** (`x[k]` where
* `k === 'thresholds'`). Nothing can see that textually. The census test's
* leg 2 covers the one file where it would matter by REFUSING the shape:
- * `packages/sdui-parser/src/__tests__/dashboard-widget-options-census.test.ts`
+ * `packages/plugin-dashboard/src/__tests__/dashboard-widget-options-census.test.ts`
* fails loudly if `DatasetWidget.tsx` ever gains a computed access, a spread
* or a destructuring of the options bag.
*
@@ -103,6 +103,13 @@ import { blank, maskComments, scanSource } from '../js-comment-mask.mjs';
*/
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+/**
+ * The monorepo root, one level above `ui/`. The census module lives in the one
+ * parser, `@objectstack/sdui-parser` at the repo root, since the ui copy was
+ * retired (objectstack#25712 item 3); `CENSUS_MODULE` is spelled from there,
+ * which is also how the plugin-dashboard page names it.
+ */
+const monorepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const CENSUS_MODULE = 'packages/sdui-parser/src/dashboard-widget-options.ts';
const DASHBOARD_PAGE = 'content/docs/plugins/plugin-dashboard.mdx';
@@ -270,7 +277,7 @@ describe('objectui#6186 claim 2 — the claim itself, re-derived from source', (
describe('objectui#6186 claim 2 — the claim is single-sourced, and the pointer resolves', () => {
const page = fs.readFileSync(path.join(repoRoot, DASHBOARD_PAGE), 'utf8');
- const census = fs.readFileSync(path.join(repoRoot, CENSUS_MODULE), 'utf8');
+ const census = fs.readFileSync(path.join(monorepoRoot, CENSUS_MODULE), 'utf8');
/**
* A docblock WRAPS. `toContain` over raw text therefore fails when someone
@@ -301,6 +308,6 @@ describe('objectui#6186 claim 2 — the claim is single-sourced, and the pointer
it('the pointer names a file that really exists', () => {
// A path in prose is a claim too. This one is cheap to re-derive, so it is.
- expect(fs.existsSync(path.join(repoRoot, CENSUS_MODULE))).toBe(true);
+ expect(fs.existsSync(path.join(monorepoRoot, CENSUS_MODULE))).toBe(true);
});
});
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
index ad00bdec1e6..f7c3839497c 100644
--- a/ui/tsconfig.json
+++ b/ui/tsconfig.json
@@ -71,8 +71,8 @@
"@object-ui/core": ["packages/core/src"],
"@object-ui/core/*": ["packages/core/src/*"],
"@object-ui/react-runtime": ["packages/react-runtime/src"],
- "@object-ui/sdui-parser": ["packages/sdui-parser/src"],
- "@object-ui/sdui-parser/*": ["packages/sdui-parser/src/*"],
+ "@objectstack/sdui-parser": ["../packages/sdui-parser/src"],
+ "@objectstack/sdui-parser/*": ["../packages/sdui-parser/src/*"],
"@object-ui/protocol": ["packages/core/src"],
"@object-ui/protocol/*": ["packages/core/src/*"],
"@object-ui/console": ["apps/console/plugin.ts"]
diff --git a/ui/turbo.objectui.json b/ui/turbo.objectui.json
index f1900d25947..92073c0762c 100644
--- a/ui/turbo.objectui.json
+++ b/ui/turbo.objectui.json
@@ -20,18 +20,7 @@
"$TURBO_ROOT$/scripts/build-plugin-stylesheet.mjs",
"$TURBO_ROOT$/scripts/check-dist-completeness.mjs",
"$TURBO_ROOT$/scripts/invoked-as.mjs",
- "$TURBO_ROOT$/scripts/vitest-invocation-guard.mjs",
- "$TURBO_ROOT$/packages/sdui-parser/scripts/gen-manifest.ts",
- "$TURBO_ROOT$/packages/sdui-parser/src/body-dialect.ts",
- "$TURBO_ROOT$/packages/sdui-parser/src/codegen.ts",
- "$TURBO_ROOT$/packages/sdui-parser/src/dashboard-widget-options.ts",
- "$TURBO_ROOT$/packages/sdui-parser/src/index.ts",
- "$TURBO_ROOT$/packages/sdui-parser/src/input-type.ts",
- "$TURBO_ROOT$/packages/sdui-parser/src/parse.ts",
- "$TURBO_ROOT$/packages/sdui-parser/src/provenance.ts",
- "$TURBO_ROOT$/packages/sdui-parser/src/retired-visibility-dialects.ts",
- "$TURBO_ROOT$/packages/sdui-parser/src/types.ts",
- "$TURBO_ROOT$/packages/sdui-parser/src/validate.ts"
+ "$TURBO_ROOT$/scripts/vitest-invocation-guard.mjs"
]
},
"test": {
diff --git a/ui/vitest.config.mts b/ui/vitest.config.mts
index a302b05b0c8..b33f16f1b71 100644
--- a/ui/vitest.config.mts
+++ b/ui/vitest.config.mts
@@ -510,7 +510,7 @@ export default defineConfig({
'@object-ui/i18n': path.resolve(__dirname, './packages/i18n/src'),
'@object-ui/core': path.resolve(__dirname, './packages/core/src'),
'@object-ui/react-runtime': path.resolve(__dirname, './packages/react-runtime/src'),
- '@object-ui/sdui-parser': path.resolve(__dirname, './packages/sdui-parser/src'),
+ '@objectstack/sdui-parser': path.resolve(__dirname, '../packages/sdui-parser/src'),
'@object-ui/types/zod': path.resolve(__dirname, './packages/types/src/zod/index.zod.ts'),
'@object-ui/types': path.resolve(__dirname, './packages/types/src'),
'@object-ui/react': path.resolve(__dirname, './packages/react/src'),