From 46595458834a65e26f41a6b6e552a0377040eafc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:52:08 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(spec)!:=20retire=20the=20page=20kind?= =?UTF-8?q?=20jsx=20=E2=80=94=20refused=20naming=20html,=20its=20D2=20conv?= =?UTF-8?q?ersion=20leaves=20the=20load=20path=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DdfPiKZUYAduc1ERcEvDso Co-authored-by: Claude --- .../23543-f5-jsx-retire-page-kind-jsx.md | 25 ++++++++ content/docs/ui/pages.mdx | 8 ++- content/docs/ui/react-pages.mdx | 5 +- packages/cli/src/utils/i18n-extract.ts | 4 +- packages/cli/src/utils/sdui-manifest.test.ts | 4 +- packages/cli/src/utils/sdui-manifest.ts | 4 +- ...build-json-failure-conversions.e2e.test.ts | 59 ++++++++++-------- .../test/lint-conversion-notices.e2e.test.ts | 61 +++++++++++-------- ...idate-json-failure-conversions.e2e.test.ts | 59 ++++++++++-------- packages/lint/src/authoring-rules.ts | 2 +- .../src/page-walk-conversion-parity.test.ts | 9 ++- packages/lint/src/page-walk.ts | 4 +- packages/lint/src/rule-explanations.ts | 2 +- .../src/validate-action-name-refs.test.ts | 2 +- packages/lint/src/validate-jsx-pages.test.ts | 21 +++---- packages/lint/src/validate-jsx-pages.ts | 6 +- .../lint/src/validate-page-source-styling.ts | 2 +- .../src/validate-translation-references.ts | 2 +- .../src/artifact-forward-conversion.test.ts | 15 +++-- .../protocol.batch-verb-driver-text.test.ts | 5 +- .../src/runtime-authoring-gate.ts | 9 ++- packages/spec/liveness/page.json | 2 +- .../spec/src/conversions/conversions.test.ts | 61 ++++++++++++++++--- packages/spec/src/conversions/registry.ts | 38 ++++++++---- packages/spec/src/conversions/walk.ts | 2 +- .../semantic/18.page-kind-jsx-retired.ts | 34 +++++++++++ ...page-requires-non-compiled-kind-refused.ts | 10 +-- .../semantic/18.ui-html-page-div-refused.ts | 2 +- .../spec/src/migrations/registry.ts.template | 4 +- .../retired-key-removal-version.pin.test.ts | 10 +++ .../spec/src/stack-conversions-record.test.ts | 23 ++++--- .../object-refinement-check-exports.test.ts | 2 - packages/spec/src/ui/page-print.test.ts | 1 - packages/spec/src/ui/page.form.ts | 2 +- packages/spec/src/ui/page.zod.ts | 55 ++++++++++++----- 35 files changed, 375 insertions(+), 179 deletions(-) create mode 100644 .changeset/23543-f5-jsx-retire-page-kind-jsx.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.page-kind-jsx-retired.ts diff --git a/.changeset/23543-f5-jsx-retire-page-kind-jsx.md b/.changeset/23543-f5-jsx-retire-page-kind-jsx.md new file mode 100644 index 00000000000..24f91eeab26 --- /dev/null +++ b/.changeset/23543-f5-jsx-retire-page-kind-jsx.md @@ -0,0 +1,25 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': patch +'@objectstack/cli': patch +'@objectstack/lint': patch +--- + +feat(spec)!: the page kind `jsx` is retired — an authored `kind: 'jsx'` is refused with the prescription naming `html`, and `page-kind-jsx-to-html` leaves the load path + +Clause-②: yes (narrowing) + +**BREAKING (authoring):** `jsx` was the first name of the constrained-JSX page tier (ADR-0080), which ADR-0081 renamed `html`. Through 17 it stayed a declared `PageSchema.kind` member and the loader rewrote an authored `kind: 'jsx'` to `html` with a notice. At 18 it is retired: `PageSchema.kind` declares it through `enumWithRetiredValues`, so every door that parses a page (`defineStack`, `os validate`, `os build`, `os lint`, the metadata save door) refuses it by name: "Page `kind: 'jsx'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `kind: 'html'`." Writing it in TypeScript no longer compiles either. The ADR-0087 D2 conversion `page-kind-jsx-to-html` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the value. `COMPILED_PAGE_KINDS` is now `['html']`: `requires` is accepted on an `html` page only. + +Migration: + +- `kind: 'jsx'` → `kind: 'html'`. Change the value; the page's `source` is compiled and rendered exactly as before, and `requires` stays legal on it. +- A script or integration that saves a page through the metadata API with `kind: 'jsx'` → send `kind: 'html'`. + +`os migrate meta` does not list this edit: the rename belongs to protocol 11, below the migration floor, so make it by hand. The D3 entry `page-kind-jsx-retired` states it for upgraders. + +Stored metadata needs no edit. Every stored-row rehydration seam replays the retired conversion, so a `sys_metadata` page row written as `jsx` keeps loading as an `html` page, and a built artifact whose declared protocol floor is at or below `17.7.0` is still converted at the artifact-ingestion door. + +The readers that branched on the retired value dropped it: the metadata save door's html-page compile gate (`@objectstack/metadata-protocol`), the CLI's SDUI-manifest page count, and the lint page walk, `validateJsxPages` and the page-source styling rule (`@objectstack/lint`) now read `html` (and `react` where they did before) only. + + diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 45a7537b25a..a9b6a8d5348 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -62,7 +62,7 @@ const homePage = { | `type` | `enum` | optional | Page type (see below; default `'record'`) | | `object` | `string` | optional | Associated object (for `record` type) | | `template` | `string` | optional | Layout template name (default: `'default'`) | -| `kind` | `enum` | optional | Page override mode (default `'full'`). `full` \| `slotted` = structured authoring; `html` = author-written constrained JSX/HTML compiled (parsed, never executed) to the tree (ADR-0080; the legacy value `'jsx'` is a deprecated alias); `react` = real-React source executed at render by the runtime (ADR-0081) — it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via `OS_PAGE_REACT=off`. See [Authoring modes](#authoring-modes) | +| `kind` | `enum` | optional | Page override mode (default `'full'`). `full` \| `slotted` = structured authoring; `html` = author-written constrained JSX/HTML compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it `html`); `react` = real-React source executed at render by the runtime (ADR-0081) — it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via `OS_PAGE_REACT=off`. See [Authoring modes](#authoring-modes) | | `regions` | `PageRegion[]` | optional | Layout regions with components (default `[]` — `list` pages render via `interfaceConfig`, and an empty `record`/`home`/`app` page falls back to the synthesized default layout) | | `source` | `string` | optional | Page source text — required (and authoritative over `regions`) when `kind` is `'html'` or `'react'`. For `kind: 'html'` it is constrained JSX/HTML compiled to the tree by `@objectstack/sdui-parser` at save time (parse, never execute). For `kind: 'react'` it is real React/JSX executed at render by `@object-ui/react-runtime` (trusted tier). See [React Pages](/docs/ui/react-pages) | | `variables` | `PageVariable[]` | optional | Local state variables | @@ -93,10 +93,12 @@ this page describes the first one: The two source tiers set `source` instead of `regions`. **`source` is authoritative over `regions`**: when both are present the source wins, and `regions` holds at most a derived -cache of it. A page whose `kind` is `html`, `react` or `jsx` with no non-empty `source` +cache of it. A page whose `kind` is `html` or `react` with no non-empty `source` is rejected by the schema rather than rendering empty. -`'jsx'` is a deprecated alias for `'html'`, still accepted and converted at load time. +`'jsx'`, the tier's first name, was retired in `@objectstack/spec` 18: an authored +`kind: 'jsx'` is refused with the prescription naming `'html'`. Stored pages written as +`'jsx'` keep loading as `html` pages. Because `kind: 'react'` executes author JavaScript, it is gated by a host capability that defaults **ON** and is disabled server-side per deployment with `OS_PAGE_REACT=off`. The diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 706043398f0..684acf7302d 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -15,8 +15,9 @@ string** instead, for layouts and interactions the fixed schema cannot express. Both set `source` and leave `regions` unused. `source` is **authoritative over `regions`** on both tiers, and a page with either `kind` and no non-empty `source` is -rejected by the schema rather than rendering empty. `'jsx'` is a deprecated alias for -`'html'` that is still accepted and converted at load. +rejected by the schema rather than rendering empty. `'jsx'`, the first name of the +`'html'` tier, was retired in `@objectstack/spec` 18 and is refused with the prescription +naming `'html'`. ## Choosing between `react` and `html` diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index fd3cdcd003c..72b2eb71860 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -965,7 +965,7 @@ function walkObjectSections(config: any, out: ExpectedEntry[]): void { // live inside the untyped `properties` bag at the positions spec's // `pageComponentSlotPositions()` derives (`page:tabs` → // `properties.items[].children`, `page:card` → `properties.footer`, every - // container → `properties.children`), and source-authored pages (`kind: 'html' | 'react' | 'jsx'`) hold only a + // container → `properties.children`), and source-authored pages (`kind: 'html' | 'react'`) hold only a // DERIVED region cache that the author never wrote — scaffolding translation // keys off that cache would invent an authoring surface. // @@ -1152,7 +1152,7 @@ export function authorWarnedTranslationGroups(): ReadonlySet { * derived slot list, and `page:card`'s `properties.body` / `.footer` were the * last positions lint walked and the resolver did not (`slots.` roots * and `properties.items[].children` closed at #16772) — but it is still - * NARROWER in one way (it skips `kind: 'html' | 'react' | 'jsx'` pages, which + * NARROWER in one way (it skips `kind: 'html' | 'react'` pages, which * `translatePage` walks), and a mismatch in either direction is one half of * the failure pair `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names. */ diff --git a/packages/cli/src/utils/sdui-manifest.test.ts b/packages/cli/src/utils/sdui-manifest.test.ts index 069c1f9e3e9..c7e8987ab49 100644 --- a/packages/cli/src/utils/sdui-manifest.test.ts +++ b/packages/cli/src/utils/sdui-manifest.test.ts @@ -273,7 +273,7 @@ describe('countJsxGatePages — the pages the JSX gate checks against a manifest it('counts the name-keyed map shape too, and ignores non-records', () => { expect( countJsxGatePages({ - pages: { a: { kind: 'html', source: '
' }, b: { kind: 'jsx', source: '
' }, c: 'x', d: null }, + pages: { a: { kind: 'html', source: '
' }, b: { kind: 'html', source: '
' }, c: 'x', d: null }, }), ).toBe(2); expect(countJsxGatePages({ pages: 'nope' })).toBe(0); @@ -350,7 +350,7 @@ describe('resolveJsxGateManifest — one decision, three commands', () => { it('absent with a page to check: parse level, and ONE notice carrying the count and every place looked', () => { const r = resolveJsxGateManifest( - { pages: [...HTML_STACK.pages, { name: 'b', kind: 'jsx', source: '
' }] }, + { pages: [...HTML_STACK.pages, { name: 'b', kind: 'html', source: '
' }] }, PROJECT_DIR, ABSENT, ); diff --git a/packages/cli/src/utils/sdui-manifest.ts b/packages/cli/src/utils/sdui-manifest.ts index f1e81285063..3d12708f32d 100644 --- a/packages/cli/src/utils/sdui-manifest.ts +++ b/packages/cli/src/utils/sdui-manifest.ts @@ -293,7 +293,7 @@ export function jsxGateStacks(stack: AnyRec): AnyRec[] { /** * The pages one stack's JSX gate checks AGAINST a manifest: `kind: 'html'` - * (and its deprecated alias `'jsx'`) with a non-empty `source`, over the + * with a non-empty `source`, over the * collection authored either as an array or as a name-keyed map — each keyed * by the name the gate reports it under (`page "NAME"`; a map entry's key when * the entry carries no `name`), or by the record itself when it has neither. @@ -314,7 +314,7 @@ function checkedPageKeys(stack: AnyRec): unknown[] { const keys: unknown[] = []; for (const [mapKey, page] of entries) { if (!isRecord(page)) continue; - if (page.kind !== 'html' && page.kind !== 'jsx') continue; + if (page.kind !== 'html') continue; if (typeof page.source !== 'string' || page.source.trim() === '') continue; const name = typeof page.name === 'string' && page.name !== '' ? page.name : mapKey; keys.push(name === undefined ? page : `page:${name}`); diff --git a/packages/cli/test/build-json-failure-conversions.e2e.test.ts b/packages/cli/test/build-json-failure-conversions.e2e.test.ts index 7ac62ee3e4d..d74b4925115 100644 --- a/packages/cli/test/build-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/build-json-failure-conversions.e2e.test.ts @@ -58,10 +58,10 @@ * * ⭐ A pin asserting `'conversions' in payload` passes against a `conversions: * []` hard-coded at every exit. So every fixture drives a LIVE conversion - * (`page-kind-jsx-to-html`, ADR-0087 D2, protocol 11, on `pages[0].kind: - * 'jsx'`) and each exit is asserted to carry exactly that one notice — no + * (`flow-node-http-callout-rename`, ADR-0087 D2, protocol 11, on + * `flows[0].nodes[1].type: 'http_request'`) and each exit is asserted to carry exactly that one notice — no * fewer, and NO MORE, the array being asserted whole. `converts nothing` runs - * the same exit with the canonical `kind: 'html'` and requires `[]`, which is + * the same exit with the canonical `type: 'http'` and requires `[]`, which is * the negative whose positive is every other test here. * * ## Which half of the measured path is `dist/` @@ -90,8 +90,11 @@ const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); const COMPILE_TS = resolve(HERE, '../src/commands/compile.ts'); -/** The live ADR-0087 D2 conversion these fixtures drive (protocol 11). */ -const CONVERSION_ID = 'page-kind-jsx-to-html'; +/** + * The live ADR-0087 D2 conversion these fixtures drive (protocol 11). It was + * `page-kind-jsx-to-html` until `jsx` retired as a page kind at 18. + */ +const CONVERSION_ID = 'flow-node-http-callout-rename'; /** The FATAL capability token — `unavailable` whatever is installed. */ const FATAL_TOKEN = 'governance'; @@ -127,23 +130,31 @@ function payloadOf(run: Run, label: string): Record { } /** - * A stack whose `pages[0].kind` drives the live conversion. `pageKind` is a - * parameter so the negative control can run the identical shape with the - * CANONICAL spelling, where there is nothing to convert. - * - * The page is ``, not `
`: where the CLI reaches the manifest - * `@objectstack/console` ships, the html tier refuses `div` (#19922), and that - * would add an author-time error no exit here is about. + * A stack whose `flows[0].nodes[1].type` drives the live conversion. + * `nodeType` is a parameter so the negative control can run the identical + * shape with the CANONICAL spelling, where there is nothing to convert. */ -function stack(ns: string, opts: { pageKind?: string; requires?: string[]; extraFields?: string; extraTop?: string } = {}): string { - const { pageKind = 'jsx', requires = [], extraFields = '', extraTop = '' } = opts; +function stack(ns: string, opts: { nodeType?: string; requires?: string[]; extraFields?: string; extraTop?: string } = {}): string { + const { nodeType = 'http_request', requires = [], extraFields = '', extraTop = '' } = opts; return ` import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, requires: [${requires.map((r) => `'${r}'`).join(', ')}], - pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: 'hi' }], + flows: [ + { + name: '${ns}_notify', + label: 'Notify', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', type: '${nodeType}', label: 'Call', config: { url: 'https://example.com/hook' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'end' }], + }, + ], objects: [ { name: '${ns}_ticket', @@ -190,10 +201,10 @@ function conversionsOf(payload: Record): unknown[] { */ const THE_NOTICE = { conversionId: CONVERSION_ID, - surface: 'page.kind', - from: 'jsx', - to: 'html', - path: 'pages[0].kind', + surface: 'flow.node.type', + from: 'http_request', + to: 'http', + path: 'flows[0].nodes[1].type', }; /** Asserts EXACTLY the one computed notice. `toEqual` is the "and NO MORE" half. */ @@ -245,8 +256,8 @@ beforeAll(() => { make('zodfail', stack('zfail', { extraFields: ` broken: { type: 'this_is_not_a_field_type', label: 'Broken' },` })); - // ⭐ the negative control — the SAME exit, the CANONICAL page kind. - make('zodfail_nc', stack('znc', { pageKind: 'html', extraFields: ` + // ⭐ the negative control — the SAME exit, the CANONICAL node type. + make('zodfail_nc', stack('znc', { nodeType: 'http', extraFields: ` broken: { type: 'this_is_not_a_field_type', label: 'Broken' },` })); // 3b — an author-time rule ERROR. @@ -306,7 +317,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(payload.success).toBe(true); expectTheOneNotice(payload, 'control', run); // The expiry rides the payload entry (`expectTheOneNotice`) and the producer's line. - expect(run.stderr).toMatch(/conversion 'page-kind-jsx-to-html', retires in protocol \d+\)/); + expect(run.stderr).toMatch(/conversion 'flow-node-http-callout-rename', retires in protocol \d+\)/); }, 180_000); it('2b (--strict-body) — ⭐ carries the notice, where `warnings` is empty by construction', async () => { @@ -338,9 +349,9 @@ describe('#12125 — every `os build --json` failure exit carries the conversion const payload = payloadOf(run, 'zodfail_nc'); expect(payload.success).toBe(false); expect('conversions' in payload, 'the field must be PRESENT even when empty').toBe(true); - expect(payload.conversions, 'a canonical page kind converts nothing').toEqual([]); + expect(payload.conversions, 'a canonical node type converts nothing').toEqual([]); // …and, since the producer is what converts now, raised no notice at load either. - expect(run.stderr).not.toContain("conversion 'page-kind-jsx-to-html'"); + expect(run.stderr).not.toContain("conversion 'flow-node-http-callout-rename'"); }, 180_000); it('3b (author-time rules) — the notice rides the rule gate', async () => { diff --git a/packages/cli/test/lint-conversion-notices.e2e.test.ts b/packages/cli/test/lint-conversion-notices.e2e.test.ts index 8874382761d..27b18a76810 100644 --- a/packages/cli/test/lint-conversion-notices.e2e.test.ts +++ b/packages/cli/test/lint-conversion-notices.e2e.test.ts @@ -47,12 +47,13 @@ * converts nothing, and `expect('conversions' in payload)` is green against a * `conversions: []` hard-coded at the exit — the defect with a lid on it. So: * - * - every positive fixture drives a LIVE conversion (`page-kind-jsx-to-html`, - * ADR-0087 D2, protocol 11, on `pages[0].kind: 'jsx'`), and the notice is + * - every positive fixture drives a LIVE conversion + * (`flow-node-http-callout-rename`, ADR-0087 D2, protocol 11, on + * `flows[0].nodes[1].type: 'http_request'`), and the notice is * asserted by IDENTITY — `conversionId` plus the converted path — over the * WHOLE array, so "and NO MORE" is asserted too; * - `converts nothing` runs the identical shape with the canonical spelling - * `kind: 'html'` and requires the field to be present and EMPTY, in both + * `type: 'http'` and requires the field to be present and EMPTY, in both * faces. That is the negative whose positive is every other test here: * without it, a hard-coded notice would satisfy all of them. * @@ -82,8 +83,11 @@ const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); -/** The live ADR-0087 D2 conversion these fixtures drive (protocol 11). */ -const CONVERSION_ID = 'page-kind-jsx-to-html'; +/** + * The live ADR-0087 D2 conversion these fixtures drive (protocol 11). It was + * `page-kind-jsx-to-html` until `jsx` retired as a page kind at 18. + */ +const CONVERSION_ID = 'flow-node-http-callout-rename'; interface Run { code: number; @@ -117,21 +121,28 @@ function payloadOf(run: Run, label: string): Record { } /** - * A stack whose `pages[0].kind` drives the live conversion. `pageKind` is a - * parameter so the negative control runs the identical shape with the CANONICAL - * spelling, where there is nothing to convert. - * - * `source` is a single-root element: the `jsx-no-root` authoring rule rejects a - * bare string, so a fixture that used one would never reach the exit under test. - * It is ``, not `
`: where the CLI reaches the manifest - * `@objectstack/console` ships, the html tier refuses `div` (#19922). + * A stack whose `flows[0].nodes[1].type` drives the live conversion. + * `nodeType` is a parameter so the negative control runs the identical shape + * with the CANONICAL spelling, where there is nothing to convert. */ -function stack(ns: string, opts: { pageKind?: string } = {}): string { - const { pageKind = 'jsx' } = opts; +function stack(ns: string, opts: { nodeType?: string } = {}): string { + const { nodeType = 'http_request' } = opts; return ` export default { manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, - pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: 'hi' }], + flows: [ + { + name: '${ns}_notify', + label: 'Notify', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', type: '${nodeType}', label: 'Call', config: { url: 'https://example.com/hook' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'end' }], + }, + ], objects: [ { name: '${ns}_ticket', @@ -158,10 +169,10 @@ function conversionsOf(payload: Record): unknown[] { */ const THE_NOTICE = { conversionId: CONVERSION_ID, - surface: 'page.kind', - from: 'jsx', - to: 'html', - path: 'pages[0].kind', + surface: 'flow.node.type', + from: 'http_request', + to: 'http', + path: 'flows[0].nodes[1].type', }; /** Asserts the payload carries EXACTLY the one computed notice, and no more. */ @@ -187,8 +198,8 @@ beforeAll(() => { // Drives the live conversion. make('converts', stack('cvt')); - // ⭐ The negative control — the identical shape, the CANONICAL page kind. - make('canonical', stack('cnl', { pageKind: 'html' })); + // ⭐ The negative control — the identical shape, the CANONICAL node type. + make('canonical', stack('cnl', { nodeType: 'http' })); // Catch-all exit, AT LOAD — the config throws on import, ABOVE the normalize // step, so the run must report the field EMPTY rather than absent. @@ -229,16 +240,16 @@ describe('#12297 — `os lint` surfaces ADR-0087 conversion notices in both face expect(Object.keys(payload), 'the field must be PRESENT even when empty').toContain('conversions'); expect(run.stdout, 'and present in the serialized bytes').toContain('"conversions"'); - expect(payload.conversions, 'a canonical page kind converts nothing').toEqual([]); + expect(payload.conversions, 'a canonical node type converts nothing').toEqual([]); }, 120_000); it('⭐ the human face says it too — the notice is printed, with its expiry', async () => { // The face an author actually reads. `--json` is not the authoring gate for // a human, and this command had NEITHER face. const run = await runCli(['lint'], dirs.converts); - expect(run.stdout, 'the converted path').toContain('pages[0].kind'); + expect(run.stdout, 'the converted path').toContain('flows[0].nodes[1].type'); expect(run.stdout, 'the conversion identity').toContain(CONVERSION_ID); - expect(run.stdout, 'the from → to rewrite').toMatch(/'jsx'.*'html'/); + expect(run.stdout, 'the from → to rewrite').toMatch(/'http_request'.*'http'/); expect(run.stdout, 'the EXPIRY — the part that makes it actionable').toMatch(/retires in protocol \d+/); }, 120_000); diff --git a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts index 49902737c66..8aff1c6503d 100644 --- a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts @@ -66,8 +66,8 @@ * * ⭐ A pin asserting `'conversions' in payload` passes against a `conversions: * []` hard-coded at every exit — the defect with a lid on it. So every fixture - * here drives a LIVE conversion (`page-kind-jsx-to-html`, ADR-0087 D2, protocol - * 11, on `pages[0].kind: 'jsx'`) and each exit is asserted to carry exactly the + * here drives a LIVE conversion (`flow-node-http-callout-rename`, ADR-0087 D2, + * protocol 11, on `flows[0].nodes[1].type: 'http_request'`) and each exit is asserted to carry exactly the * notice the run had computed by then — no fewer, and NO MORE: * * exit | conversions @@ -85,7 +85,7 @@ * * ## Both directions of the instrument are proven * - * `converts nothing` runs the SAME failure exit with `kind: 'html'` — already + * `converts nothing` runs the SAME failure exit with `type: 'http'` — already * canonical, nothing to convert — and requires `[]`. Without it, a payload with * the notice hard-coded in would satisfy every assertion above; with it, the * field is shown to track what the run actually computed. That is the negative @@ -117,8 +117,11 @@ const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); const VALIDATE_TS = resolve(HERE, '../src/commands/validate.ts'); -/** The live ADR-0087 D2 conversion these fixtures drive (protocol 11). */ -const CONVERSION_ID = 'page-kind-jsx-to-html'; +/** + * The live ADR-0087 D2 conversion these fixtures drive (protocol 11). It was + * `page-kind-jsx-to-html` until `jsx` retired as a page kind at 18. + */ +const CONVERSION_ID = 'flow-node-http-callout-rename'; /** The FATAL capability token — `{package: null, edition: 'cloud'}` in the spec * registry, so it classifies `unavailable` whatever is installed. */ const FATAL_TOKEN = 'governance'; @@ -155,23 +158,31 @@ function payloadOf(run: Run, label: string): Record { } /** - * A stack whose `pages[0].kind` drives the live conversion. `pageKind` is a - * parameter so the negative control can run the identical shape with the - * CANONICAL spelling, where there is nothing to convert. - * - * The page is ``, not `
`: where the CLI reaches the manifest - * `@objectstack/console` ships, the html tier refuses `div` (#19922), and that - * would add an author-time error no exit here is about. + * A stack whose `flows[0].nodes[1].type` drives the live conversion. + * `nodeType` is a parameter so the negative control can run the identical + * shape with the CANONICAL spelling, where there is nothing to convert. */ -function stack(ns: string, opts: { pageKind?: string; requires?: string[]; extraFields?: string } = {}): string { - const { pageKind = 'jsx', requires = [], extraFields = '' } = opts; +function stack(ns: string, opts: { nodeType?: string; requires?: string[]; extraFields?: string } = {}): string { + const { nodeType = 'http_request', requires = [], extraFields = '' } = opts; return ` import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, requires: [${requires.map((r) => `'${r}'`).join(', ')}], - pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: 'hi' }], + flows: [ + { + name: '${ns}_notify', + label: 'Notify', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', type: '${nodeType}', label: 'Call', config: { url: 'https://example.com/hook' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'end' }], + }, + ], objects: [ { name: '${ns}_ticket', @@ -205,10 +216,10 @@ function conversionsOf(payload: Record): unknown[] { */ const THE_NOTICE = { conversionId: CONVERSION_ID, - surface: 'page.kind', - from: 'jsx', - to: 'html', - path: 'pages[0].kind', + surface: 'flow.node.type', + from: 'http_request', + to: 'http', + path: 'flows[0].nodes[1].type', }; /** @@ -261,9 +272,9 @@ beforeAll(() => { make('parsefail', stack('pfail', { extraFields: ` broken: { type: 'this_is_not_a_field_type', label: 'Broken' },` })); - // ⭐ the negative control — the SAME exit, the CANONICAL page kind. Nothing + // ⭐ the negative control — the SAME exit, the CANONICAL node type. Nothing // to convert, so `conversions` must be empty. - make('parsefail_nc', stack('pnc', { pageKind: 'html', extraFields: ` + make('parsefail_nc', stack('pnc', { nodeType: 'http', extraFields: ` broken: { type: 'this_is_not_a_field_type', label: 'Broken' },` })); // rule errors — an expression naming a field that does not resolve. @@ -310,7 +321,7 @@ describe('#12125 — every `os validate --json` failure exit carries the convers expectTheOneNotice(payload, 'control', run); // The expiry is the reason this field cannot just be dropped into prose: // it rides the payload entry (`expectTheOneNotice`) and the producer's line. - expect(run.stderr).toMatch(/conversion 'page-kind-jsx-to-html', retires in protocol \d+\)/); + expect(run.stderr).toMatch(/conversion 'flow-node-http-callout-rename', retires in protocol \d+\)/); }, 120_000); it('parse failure — THE HEADLINE: the notice computed at step 2 survives the schema error', async () => { @@ -331,9 +342,9 @@ describe('#12125 — every `os validate --json` failure exit carries the convers const payload = payloadOf(run, 'parsefail_nc'); expect(payload.valid).toBe(false); expect('conversions' in payload, 'the field must be PRESENT even when empty').toBe(true); - expect(payload.conversions, 'a canonical page kind converts nothing').toEqual([]); + expect(payload.conversions, 'a canonical node type converts nothing').toEqual([]); // …and, since the producer is what converts now, raised no notice at load either. - expect(run.stderr).not.toContain("conversion 'page-kind-jsx-to-html'"); + expect(run.stderr).not.toContain("conversion 'flow-node-http-callout-rename'"); }, 120_000); it('rule errors — the notice rides the author-time gate', async () => { diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 4119489255e..e6ce0919039 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -1253,7 +1253,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT, run: (stack) => validateResponsiveStyles(stack), }, - // ADR-0080 — a `kind:'jsx'` page's `source` is parsed (never executed) and + // ADR-0080 — a `kind:'html'` page's `source` is parsed (never executed) and // compiled to the SDUI tree at save time, so malformed source must fail loudly // here (ADR-0078) instead of being stored and breaking only at render. { diff --git a/packages/lint/src/page-walk-conversion-parity.test.ts b/packages/lint/src/page-walk-conversion-parity.test.ts index 1c6214ce3b0..09fc4c32129 100644 --- a/packages/lint/src/page-walk-conversion-parity.test.ts +++ b/packages/lint/src/page-walk-conversion-parity.test.ts @@ -171,16 +171,15 @@ describe('#6775 — walkPageComponents and the conversion walk reach the same co it('source-authored pages are the one deliberate difference', () => { // Lint yields nothing for them (the regions are a derived cache, not // authored metadata); the conversion still normalizes that cache. - const jsxPage = { name: 'j', kind: 'jsx', source: '
', regions: [{ name: 'main', components: [probe('cached')] }] }; - expect(walkPageComponents(jsxPage as unknown as Record, 'pages[0]')).toEqual([]); + const htmlPage = { name: 'j', kind: 'html', source: '
', regions: [{ name: 'main', components: [probe('cached')] }] }; + expect(walkPageComponents(htmlPage as unknown as Record, 'pages[0]')).toEqual([]); const notices: string[] = []; applyConversions( - { pages: [structuredClone(jsxPage)] }, + { pages: [structuredClone(htmlPage)] }, { includeRetired: true, - // `kind: 'jsx'` itself converts (`page-kind-jsx-to-html`, protocol 11); - // what this pins is the component inside the derived cache. + // What this pins is the component inside the derived cache. onNotice: (n) => { if (n.conversionId === 'page-header-subtitle-alias') notices.push(n.path); }, }, ); diff --git a/packages/lint/src/page-walk.ts b/packages/lint/src/page-walk.ts index f3310cf3711..c1fdfeb8213 100644 --- a/packages/lint/src/page-walk.ts +++ b/packages/lint/src/page-walk.ts @@ -32,7 +32,7 @@ * sub-tree under a refused key as if it were authored is what the ruling * rules out. The conversion walker still descends it — stored documents * are its population, not this walk's. - * - `kind: 'html' | 'react' | 'jsx'` pages are authored as `source`, which is + * - `kind: 'html' | 'react'` pages are authored as `source`, which is * authoritative; their `regions` hold at most a DERIVED cache that the * source wins over. Linting that cache reports findings about metadata the * author never wrote, so those pages are skipped here and covered by @@ -77,7 +77,7 @@ function strName(v: unknown): string | undefined { } /** Page kinds whose component tree is a derived cache, not authored metadata. */ -const SOURCE_AUTHORED_KINDS = new Set(['html', 'react', 'jsx']); +const SOURCE_AUTHORED_KINDS = new Set(['html', 'react']); /** Is this page authored as `source` (so its `regions` must not be linted)? */ export function isSourceAuthoredPage(page: AnyRec): boolean { diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 0ebc18bbff7..8f76e4d0ea9 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3492,7 +3492,7 @@ const PAGE_SOURCE_CLASSNAME_TAILWIND_EXPLANATION: RuleExplanation = { rule: 'page-source-className-tailwind', covers: 'why Tailwind classes in page source produce no CSS', paragraphs: [ - 'A `kind: \'html\'`, `\'react\'` or `\'jsx\'` page\'s `source` is runtime metadata, and the ' + + 'A `kind: \'html\'` or `\'react\'` page\'s `source` is runtime metadata, and the ' + 'Console\'s Tailwind build scans only the renderer\'s own source, never an authored page string. ' + 'So a Tailwind `className` in page source produces no CSS (ADR-0065): it styles the page only by ' + 'coincidence, when the class happens to be one the renderer already ships.', diff --git a/packages/lint/src/validate-action-name-refs.test.ts b/packages/lint/src/validate-action-name-refs.test.ts index 74c205e3133..34827f0a558 100644 --- a/packages/lint/src/validate-action-name-refs.test.ts +++ b/packages/lint/src/validate-action-name-refs.test.ts @@ -206,7 +206,7 @@ describe('validateActionNameRefs — page quick actions', () => { pages: [ { name: 'p', - kind: 'jsx', + kind: 'html', source: '
', regions: [ { diff --git a/packages/lint/src/validate-jsx-pages.test.ts b/packages/lint/src/validate-jsx-pages.test.ts index ca3c594c457..a32c870bacb 100644 --- a/packages/lint/src/validate-jsx-pages.test.ts +++ b/packages/lint/src/validate-jsx-pages.test.ts @@ -3,29 +3,28 @@ import { describe, it, expect } from 'vitest'; import { validateJsxPages } from './validate-jsx-pages.js'; describe('validateJsxPages (ADR-0080 build gate)', () => { - it('passes a well-formed jsx page', () => { - const stack = { pages: [{ name: 'cc', kind: 'jsx', source: '' }] }; + it('passes a well-formed html page', () => { + const stack = { pages: [{ name: 'cc', kind: 'html', source: '' }] }; expect(validateJsxPages(stack)).toEqual([]); }); - it('flags an empty source on a jsx page', () => { - expect(validateJsxPages({ pages: [{ name: 'cc', kind: 'jsx' }] }).some((f) => f.rule === 'jsx-page-empty-source')).toBe(true); + it('flags an empty source on an html page', () => { + expect(validateJsxPages({ pages: [{ name: 'cc', kind: 'html' }] }).some((f) => f.rule === 'jsx-page-empty-source')).toBe(true); }); it('flags malformed jsx (mismatched close) loudly, at the source path', () => { - const f = validateJsxPages({ pages: [{ name: 'cc', kind: 'jsx', source: 'oops' }] }); + const f = validateJsxPages({ pages: [{ name: 'cc', kind: 'html', source: 'oops' }] }); expect(f.some((x) => x.severity === 'error')).toBe(true); expect(f.every((x) => x.path === 'pages[0].source')).toBe(true); }); it('rejects event handlers and dangerouslySetInnerHTML at parse level', () => { - const f = validateJsxPages({ pages: [{ name: 'cc', kind: 'jsx', source: '' }] }) + const f = validateJsxPages({ pages: [{ name: 'cc', kind: 'html', source: '' }] }) .filter((x) => x.rule === 'jsx-forbidden-attr'); expect(f).toHaveLength(2); }); it('ignores non-jsx pages', () => { expect(validateJsxPages({ pages: [{ name: 'full', kind: 'full', regions: [] }] })).toEqual([]); }); - it('validates kind:\'html\' (the renamed tier) too', () => { - expect(validateJsxPages({ pages: [{ name: 'cc', kind: 'html', source: '' }] })).toEqual([]); - expect(validateJsxPages({ pages: [{ name: 'cc', kind: 'html' }] }).some((f) => f.rule === 'jsx-page-empty-source')).toBe(true); + it('does NOT read the retired kind:\'jsx\' — the page parse refuses that kind before any rule runs', () => { + expect(validateJsxPages({ pages: [{ name: 'cc', kind: 'jsx', source: 'oops' }] })).toEqual([]); }); it('does NOT lint kind:\'react\' (real JS, not constrained JSX)', () => { const stack = { pages: [{ name: 'rx', kind: 'react', source: 'function P(){const[n]=React.useState(0);return
{n}
}' }] }; @@ -44,13 +43,13 @@ describe('validateJsxPages — full validation with a manifest', () => { }, }; it('catches unknown components and missing required props', () => { - const stack = { pages: [{ name: 'cc', kind: 'jsx', source: '' }] }; + const stack = { pages: [{ name: 'cc', kind: 'html', source: '' }] }; const f = validateJsxPages(stack, { manifest } as never); expect(f.some((x) => x.rule === 'jsx-missing-required-prop')).toBe(true); expect(f.some((x) => x.rule === 'jsx-forbidden-tag')).toBe(true); }); it('passes when components + required props are satisfied', () => { - const stack = { pages: [{ name: 'cc', kind: 'jsx', source: '' }] }; + const stack = { pages: [{ name: 'cc', kind: 'html', source: '' }] }; expect(validateJsxPages(stack, { manifest } as never)).toEqual([]); }); }); diff --git a/packages/lint/src/validate-jsx-pages.ts b/packages/lint/src/validate-jsx-pages.ts index 1767db5a505..5d32242e3b5 100644 --- a/packages/lint/src/validate-jsx-pages.ts +++ b/packages/lint/src/validate-jsx-pages.ts @@ -2,7 +2,7 @@ // // Build-time diagnostics for AI-authored HTML-source pages (ADR-0080). // -// Applies to `kind:'html'` (and its deprecated alias `kind:'jsx'`) — the tier +// Applies to `kind:'html'` — the tier // whose `source` is constrained JSX/HTML parsed (never executed) to the tree. // `kind:'react'` (ADR-0081) is intentionally NOT linted here: its source is // real JavaScript, not constrained JSX, so the constrained parser would @@ -41,8 +41,8 @@ type AnyRec = Record; export function validateJsxPages(stack: AnyRec, opts: { manifest?: Manifest } = {}): JsxPageFinding[] { const findings: JsxPageFinding[] = []; for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, 'pages')) { - // html tier (+ deprecated 'jsx' alias). react pages are not constrained JSX. - if (!page || (page.kind !== 'html' && page.kind !== 'jsx')) continue; + // html tier only. react pages are not constrained JSX. + if (!page || page.kind !== 'html') continue; const name = String(page.name ?? pagePath); const source = page.source; if (typeof source !== 'string' || source.trim() === '') { diff --git a/packages/lint/src/validate-page-source-styling.ts b/packages/lint/src/validate-page-source-styling.ts index 320c19185ac..22f59326fce 100644 --- a/packages/lint/src/validate-page-source-styling.ts +++ b/packages/lint/src/validate-page-source-styling.ts @@ -38,7 +38,7 @@ export function validatePageSourceStyling(stack: AnyRec): SourceStyleFinding[] { for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, 'pages')) { if (!page) continue; const kind = page.kind; - if (kind !== 'html' && kind !== 'react' && kind !== 'jsx') continue; + if (kind !== 'html' && kind !== 'react') continue; const source = page.source; if (typeof source !== 'string' || source.trim() === '') continue; const name = String(page.name ?? pagePath); diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index b7fa5022b51..761ae19d4d0 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -577,7 +577,7 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec * must emit each key exactly once; a `Set` is the same de-duplication with * nothing left to choose between two authorings. * - * ⚠️ Source-authored pages (`kind: 'html' | 'react' | 'jsx'`) are NOT skipped + * ⚠️ Source-authored pages (`kind: 'html' | 'react'`) are NOT skipped * here, deliberately, even though {@link walkPageComponents} skips them. That * skip exists because such a page's `regions` are a derived cache the `source` * wins over; `interfaceConfig` is authored metadata at the page root, and both diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index f327b2c76b6..b476e28c58d 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -441,18 +441,23 @@ describe('the artifact door never turns an authored `hidden: true` into an unpub /** * ⭐ FIRING CONTROL 1 — a NON-RETIRED conversion still fires in the subject's - * own window. `page-kind-jsx-to-html` (ADR-0080) is not retired, so a door - * that had stopped converting anything would fail here. + * own window. `page-component-visibility-to-visibleWhen` is not retired, so a + * door that had stopped converting anything would fail here. (The subject was + * `page-kind-jsx-to-html` until `jsx` retired as a page kind at 18.) */ it('still applies a non-retired conversion in that same window', () => { + const header = { type: 'page:header', properties: { title: 'Hi' }, visibility: "page.mode != 'edit'" }; const def = { ...hiddenAppDefinition('^17.0.0'), - pages: [{ name: 'landing', kind: 'jsx', source: '
hi
' }], + pages: [{ name: 'landing', regions: [{ name: 'main', components: [header] }] }], }; const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); expect(result.verdict).toBe('converted-forward'); - expect(result.notices.map((n) => n.conversionId)).toContain('page-kind-jsx-to-html'); - expect((result.definition as { pages: Record[] }).pages[0]!.kind).toBe('html'); + expect(result.notices.map((n) => n.conversionId)).toContain('page-component-visibility-to-visibleWhen'); + const converted = (result.definition as { pages: { regions: { components: Record[] }[] }[] }) + .pages[0]!.regions[0]!.components[0]!; + expect(converted.visibleWhen).toBe("page.mode != 'edit'"); + expect('visibility' in converted).toBe(false); // …and the app in the SAME artifact still keeps its authored key. expect((result.definition as { apps: Record[] }).apps[0]!.hidden).toBe(true); }); diff --git a/packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts b/packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts index d23ec0b7b38..11a90b8ccb0 100644 --- a/packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts +++ b/packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts @@ -388,8 +388,9 @@ describe('[#8333] the batch verbs report a failure without quoting the driver', it('P13 `migrateStoredMetadata` — `rows[].reason`', async () => { const { protocol, engine } = makeKernel({ - // `page.kind: 'jsx'` → `'html'` is a live ADR-0087 conversion, so - // this row is genuinely non-canonical and the migration really + // `page.kind: 'jsx'` → `'html'` is an ADR-0087 conversion every + // stored-row seam replays (retired from the load path at 18, it + // still heals rows at rest), so this row is genuinely non-canonical and the migration really // attempts the rewrite. A canonical row would never reach the save. seed: [row({ type: 'page', name: 'crm_landing', diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index b57e89c3575..cb9d5d057ff 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -667,8 +667,13 @@ export const PAGE_REQUIRES_DISAGREES_WITH_SOURCE = 'page-requires-disagrees-with */ export const HTML_PAGE_SOURCE_COMPILE = 'html-page-source-compile'; -/** The page kinds whose `source` is constrained JSX (the deprecated `jsx` spells `html`). */ -const COMPILED_PAGE_KINDS: ReadonlySet = new Set(['html', 'jsx']); +/** + * The page kinds whose `source` is constrained JSX — `html` alone, the spec's + * `COMPILED_PAGE_KINDS`. Its first name `jsx` was retired at 18: the page parse + * refuses it before this gate runs, and a stored `jsx` row reaches the gate as + * `html`, rewritten by the stored-row conversion pass. + */ +const COMPILED_PAGE_KINDS: ReadonlySet = new Set(['html']); /** * Whether a value can be compiled against: an object with a `components` map, diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index 955126178f1..0d63d06d856 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -10,7 +10,7 @@ "status": "live", "verifiedAt": "2026-10-03", "evidenceScope": "in-repo", - "evidence": "PARSE: packages/spec/src/ui/page.zod.ts#checkPageRequiresKind refuses the key on every kind but html / jsx (COMPILED_PAGE_KINDS) — a react, full or slotted page, a page that omits kind included — at requires, naming the key, the kind and the compiled kinds. SAVE: packages/metadata-protocol/src/runtime-authoring-gate.ts#findHtmlPageSourceGaps reads the authored list of a kind 'html' page and refuses one that disagrees with the namespaces its compiled source uses — 422 INVALID_METADATA under page-requires-disagrees-with-source, on an active save and on a draft's publish; packages/metadata-protocol/src/runtime-authoring-gate.ts#stampHtmlPageRequires stores the compiled list on save, and packages/metadata-protocol/src/protocol.ts#promoteDraftForPublish applies it again to the body a draft promotion writes. LOAD: packages/metadata-protocol/src/protocol.ts#reportPageRequiresAbsentAtLoad, called from boot hydration (loadMetaFromDb), reports a stored page whose list names a namespace no component in the manifest carries (packages/metadata-protocol/src/runtime-authoring-gate.ts#findPageRequiresAbsentFromManifest), page and plugin named; the page still loads and is served", + "evidence": "PARSE: packages/spec/src/ui/page.zod.ts#checkPageRequiresKind refuses the key on every kind but html (COMPILED_PAGE_KINDS) — a react, full or slotted page, a page that omits kind included — at requires, naming the key, the kind and the compiled kinds. SAVE: packages/metadata-protocol/src/runtime-authoring-gate.ts#findHtmlPageSourceGaps reads the authored list of a kind 'html' page and refuses one that disagrees with the namespaces its compiled source uses — 422 INVALID_METADATA under page-requires-disagrees-with-source, on an active save and on a draft's publish; packages/metadata-protocol/src/runtime-authoring-gate.ts#stampHtmlPageRequires stores the compiled list on save, and packages/metadata-protocol/src/protocol.ts#promoteDraftForPublish applies it again to the body a draft promotion writes. LOAD: packages/metadata-protocol/src/protocol.ts#reportPageRequiresAbsentAtLoad, called from boot hydration (loadMetaFromDb), reports a stored page whose list names a namespace no component in the manifest carries (packages/metadata-protocol/src/runtime-authoring-gate.ts#findPageRequiresAbsentFromManifest), page and plugin named; the page still loads and is served", "producer": "packages/cli/src/utils/sdui-manifest.ts#registerDeploymentSduiManifest — both moments compare the list against the deployment's SDUI component manifest, a second input: os serve (packages/cli/src/commands/serve.ts, which dev and start spawn) resolves it at boot and registers it under SDUI_MANIFEST_SERVICE, and packages/metadata-protocol/src/protocol.ts#resolveSduiManifest reads that key per publish and at load. A host that registers no manifest judges neither moment and prints one boot line saying so", "note": "Plugin namespaces an html page's `source` uses (ADR-0080 §5), derived from the source at save. planned → live 2026-10-02 (#20871): refused at save since the save door landed (PR #20852, #20312 stages ① and ②), reported at load and re-stamped on draft promotion since #20870 (PR #21121). An authored list survives only when it agrees with the source; omitting it is the intended authoring. Boundaries: the key exists only on kind 'html' / 'jsx' pages, the kinds compiled at save; on 'react' (not compiled at save, ADR-0081), 'full' and 'slotted' pages, a page with no kind being 'full', the parse refuses it since 2026-10-03 (#21459, ruling A), and the protocol-18 conversion page-requires-non-compiled-kind-removed strips it from stored rows and built artifacts at load; a draft is stored as written and judged at its publish; stored html rows are reported, never rewritten." }, diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index c650057178d..646185057c9 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -119,7 +119,7 @@ describe('conversion layer (ADR-0087 D2)', () => { const plugin = { onEnable() {} }; // non-clonable value that must be preserved by reference const stack: Record = { plugins: [plugin], - pages: [{ name: 'p', kind: 'jsx', source: '
' }], + flows: [{ name: 'f', nodes: [{ id: 'n', type: 'http_request', config: { url: 'x' } }] }], }; const out = applyConversions(stack); expect(out).not.toBe(stack); @@ -404,7 +404,6 @@ describe('conversion layer (ADR-0087 D2)', () => { 'flow-node-http-callout-rename', 'flow-node-wait-event-config-lift', 'page-component-visibility-to-visibleWhen', - 'page-kind-jsx-to-html', 'retry-policy-converged', 'view-visibleOn-to-visibleWhen', ]); @@ -418,6 +417,8 @@ describe('conversion layer (ADR-0087 D2)', () => { 'sharing-rule-access-level-full-to-edit', // #23543 F1: retired once `mongo` itself was retired as a value. 'datasource-driver-mongo-to-mongodb', + // #23543 F5-jsx: retired once `jsx` itself was retired as a page kind. + 'page-kind-jsx-to-html', ]) { expect(ALL_CONVERSIONS.find((c) => c.id === id), id).toMatchObject({ retiredFromLoadPath: true, retiredAfter: '17.7.0' }); } @@ -615,17 +616,17 @@ describe('conversion layer (ADR-0087 D2)', () => { it('converts at load and surfaces notices through the sink', () => { const notices: ConversionNotice[] = []; const out = normalizeStackInput( - { pages: [{ name: 'p', kind: 'jsx', source: '
' }] }, + { flows: [{ name: 'f', nodes: [{ id: 'n', type: 'http_request' }] }] }, { onConversionNotice: (n) => notices.push(n) }, ); - expect((out.pages as any[])[0].kind).toBe('html'); + expect((out.flows as any[])[0].nodes[0].type).toBe('http'); expect(notices).toHaveLength(1); - expect(notices[0]!.message).toContain("'jsx' → 'html'"); + expect(notices[0]!.message).toContain("'http_request' → 'http'"); }); it('still converts silently when no sink is provided (zero consumer action)', () => { - const out = normalizeStackInput({ pages: [{ name: 'p', kind: 'jsx', source: '
' }] }); - expect((out.pages as any[])[0].kind).toBe('html'); + const out = normalizeStackInput({ flows: [{ name: 'f', nodes: [{ id: 'n', type: 'http_request' }] }] }); + expect((out.flows as any[])[0].nodes[0].type).toBe('http'); }); it('normalizes map collections and converts in one pass', () => { @@ -1044,6 +1045,48 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); + // #23543 F5-jsx retired `jsx` as a page kind at 18. Three claims hold + // together: a STORED `jsx` page still converges to `html` and parses (the + // data-at-rest seams replay the retired entry), the authoring funnel no + // longer rewrites an authored `jsx`, and an authored `jsx` meets the + // prescription naming `html` instead. + describe('page-kind-jsx-to-html', () => { + const page = (kind: string) => ({ name: 'landing', label: 'Landing', kind, source: '
hi
' }); + + it('a stored `jsx` page row still converges to `html`, and the converted row parses', () => { + const out = applyConversionsToStoredItem('page', page('jsx')) as { kind: string }; + expect(out.kind).toBe('html'); + expect(PageSchema.safeParse(out).success).toBe(true); + }); + + it('is retired from the load path — the authoring funnel no longer rewrites an authored `jsx`', () => { + const before = { pages: [page('jsx')] }; + const { stack, notices } = collectConversionNotices(structuredClone(before)); + expect(stack).toEqual(before); + expect(notices).toHaveLength(0); + }); + + it('an authored `jsx` reaches defineStack as written and is refused naming `html` and the release', () => { + let refusal: unknown; + try { + defineStack({ pages: [page('jsx')] } as never); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = (refusal as { issues: { path: PropertyKey[]; message: string }[] }).issues; + // The kind is the ONE issue: the page is otherwise a complete html page. + expect(issues.map((i) => i.path.join('.'))).toEqual(['pages.0.kind']); + expect(issues[0]!.message).toMatch(/^Page `kind: 'jsx'` was removed in @objectstack\/spec 18 \(ADR-0087 D2\)/); + expect(issues[0]!.message).toContain("Use `kind: 'html'`"); + // The canonical kind is untouched, and so is a misspelling's own message. + expect(PageSchema.safeParse(page('html')).success).toBe(true); + const typo = PageSchema.safeParse(page('htm')); + expect(typo.success).toBe(false); + expect(typo.error!.issues[0]!.message).not.toContain('was removed'); + }); + }); + // #4456 — the driver-factory `??` fallback graduation. The mappings are // driver-scoped by construction; these pin the two edges the flat fixture // pair cannot express as sharply: the same key converting under one driver @@ -1767,8 +1810,8 @@ describe('conversion layer (ADR-0087 D2)', () => { /** * ⭐ FIRING CONTROL for the refusal above — it excludes ONE id, not the - * window. `page-kind-jsx-to-html` (not retired) and the retired app entry - * are driven through the SAME call: the named one is refused, the other + * window. `page-kind-jsx-to-html` (retired too, at 18) and the retired app + * entry are driven through the SAME call: the named one is refused, the other * still fires. */ it('refuses only the named id — the rest of the chain still runs', () => { diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 3e8f95ba837..3e1bfb1b04f 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -126,22 +126,36 @@ const flowNodeHttpRename: MetadataConversion = { }; /** - * Page `kind: 'jsx'` → `kind: 'html'` (protocol 11.4). - * - * `'jsx'` is a documented deprecated alias of the canonical `'html'` page kind - * (ADR-0080; see `spec/src/ui/page.zod.ts`). The `source` semantics are - * identical, so the rename is lossless. - * - * **Stays on the load path at 18 — a recorded verdict (#23487).** `'jsx'` is - * still a declared member of `PageSchema.kind` and of `COMPILED_PAGE_KINDS`, - * which the save-time compile reads, so retired, this entry would leave the - * value silently accepted at every door rather than refused. It can retire - * once the value itself is retired from the enum (an `enumWithRetiredValues` - * prescription naming `html`), together with the paths that still branch on it. + * Page `kind: 'jsx'` → `kind: 'html'` (protocol 11.4; retired from the load + * path at 18, #23543 F5-jsx). + * + * `'jsx'` was the first name of the constrained-JSX page tier (ADR-0080), which + * ADR-0081 renamed `'html'`. The `source` semantics are identical, so the + * rename is lossless. + * + * ## Retired from the load path at 18 (#23543, ruling F5-jsx A) + * + * Through 17 it stayed live because `'jsx'` was still a declared member of + * `PageSchema.kind` and of `COMPILED_PAGE_KINDS`, so retired alone it would + * have left the value silently accepted at every door (the verdict #23487 + * recorded). The ruling retired the value: `PageSchema.kind` declares it + * through `enumWithRetiredValues` with the prescription naming `html` + * (`PAGE_KIND_RETIRED_GUIDANCE`, `ui/page.zod.ts`), and `COMPILED_PAGE_KINDS` + * and every reader that branched on it dropped the member. So the authoring + * funnel (`defineStack`, `os validate` / `os build` / `os lint`) no longer + * replays this entry, and an author is taught the canonical kind instead of + * being rewritten. Stored pages are the `includeRetired` seams: stored-row + * rehydration (`applyConversionsToStoredItem`) still rewrites a `sys_metadata` + * page row written as `jsx` before anything parses it, and so does the + * artifact-ingestion door inside its window. `os migrate meta` does not reach + * it: protocol 11 is below `MIGRATION_SUPPORT_FLOOR`. The family's D3 entry, + * `page-kind-jsx-retired`, states the edit for authored sources. */ const pageKindJsxToHtml: MetadataConversion = { id: 'page-kind-jsx-to-html', toMajor: 11, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'page.kind', summary: "page kind 'jsx' → 'html' (ADR-0080 canonical spelling)", apply(stack, emit) { diff --git a/packages/spec/src/conversions/walk.ts b/packages/spec/src/conversions/walk.ts index 705202d750f..effd2695fbe 100644 --- a/packages/spec/src/conversions/walk.ts +++ b/packages/spec/src/conversions/walk.ts @@ -383,7 +383,7 @@ function mapComponentTree( * Three differences from the lint walk remain, all deliberate, and each makes * this walker reach MORE, never less: * - * 1. **Source-authored pages** (`kind: 'html' | 'react' | 'jsx'`) are visited + * 1. **Source-authored pages** (`kind: 'html' | 'react'`) are visited * here and skipped there. For lint that skip prevents findings about a * DERIVED region cache the author never wrote; a conversion still has to * normalize that cache, or a stored page rehydrates in a shape the runtime diff --git a/packages/spec/src/migrations/entries/semantic/18.page-kind-jsx-retired.ts b/packages/spec/src/migrations/entries/semantic/18.page-kind-jsx-retired.ts new file mode 100644 index 00000000000..20369648ec1 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.page-kind-jsx-retired.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #23543 F5-jsx — the value retirement's one D3 entry. The D2 half is +// `page-kind-jsx-to-html` (protocol 11), now retired from the load path. It +// carries no `conversionIds`: protocol 11 is below MIGRATION_SUPPORT_FLOOR, so +// no step of the chain replays that conversion, and `os migrate meta` lists +// this entry as the hand edit it is. +export const entry: SemanticMigration = { + id: 'page-kind-jsx-retired', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: "page kind authored as 'jsx' (PageSchema.kind, ui/page.zod.ts)", + replacement: + "`kind: 'html'` — the constrained-JSX page tier's name since ADR-0081; the page's `source` and " + + '`requires` stay as they are', + reason: + "A maintainer ruling retired `jsx` as a page kind at 18. It was the first name of the constrained-JSX " + + 'tier (ADR-0080), which ADR-0081 renamed `html`; through 17 the loader rewrote an authored ' + + "`kind: 'jsx'` to `html` with a notice. At 18 the page parse refuses it by name with the " + + 'prescription naming `html`, at every door that parses a page: `objectstack validate`, ' + + '`objectstack build`, `objectstack lint`, `defineStack` and the metadata save door (a 422). The ' + + 'rename is lossless — the source is compiled and rendered exactly as before — but no tool makes it ' + + 'for an authored source: the D2 conversion `page-kind-jsx-to-html` belongs to protocol 11, below ' + + 'the migration floor, so change the value by hand. Stored pages need no edit: every stored-row ' + + "rehydration seam replays the retired conversion, so a `sys_metadata` page row written as `jsx` " + + 'keeps loading as an html page. A script or integration that saves a page through the metadata ' + + "API with `kind: 'jsx'` is refused and needs the same one-word change.", + acceptanceCriteria: + "`objectstack validate` reports no refusal at a page's `kind` path, no page source in the project " + + "or in a script that writes pages through the metadata API says `kind: 'jsx'`, and each former " + + 'jsx page renders the same tree as an html page.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.page-requires-non-compiled-kind-refused.ts b/packages/spec/src/migrations/entries/semantic/18.page-requires-non-compiled-kind-refused.ts index cd05da695c2..ffef536a552 100644 --- a/packages/spec/src/migrations/entries/semantic/18.page-requires-non-compiled-kind-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.page-requires-non-compiled-kind-refused.ts @@ -6,7 +6,7 @@ import type { SemanticMigration } from '../../types.js'; // (one D3 entry per retirement family, even when D2 is lossless): page // `requires` narrowed to the kinds whose source the save door compiles, ruling // record 5964312254, letter A. It is a narrowing by `kind`, not a key removal: -// the key stays live on html / jsx pages, so there is no tombstone and no +// the key stays live on html pages, so there is no tombstone and no // RETIRED_KEYS_BY_MAJOR row — the parse refuses it through // `checkPageRequiresKind` (ui/page.zod.ts) on the other kinds. export const entry: SemanticMigration = { @@ -17,18 +17,18 @@ export const entry: SemanticMigration = { 'page.requires on a page whose kind is react, full or slotted — a page that omits kind ' + 'included, since its kind is full', replacement: - 'Nothing: delete the key. On an html page (and its deprecated jsx alias) the platform derives ' + 'Nothing: delete the key. On an html page the platform derives ' + '`requires` from the source at save and stores it, so it is omitted there too; on a react, full ' + 'or slotted page nothing ever derived or enforced it, and nothing takes its place.', reason: '`PageSchema` admitted `requires` on every page kind, but the platform derives it only on the ' - + 'kinds whose source the metadata save door compiles: saving an html page (alias jsx) on a server ' + + 'kinds whose source the metadata save door compiles: saving an html page on a server ' + 'that has the deployment\'s SDUI component manifest compiles the source, stores the plugin ' + 'namespaces it uses as `requires`, and refuses a written list that disagrees. A react source is ' + 'executed at render and never compiled at save, and full and slotted pages have no source, so on ' + 'those kinds nothing derived the key, the Studio page editor dropped it on every save, and its ' + 'one reader was a load-time warning. The maintainer ruled (2026-10-03) that the key is accepted ' - + 'only on html and jsx pages. The parse now refuses it on react, full and slotted pages, and a ' + + 'only on html pages. The parse now refuses it on react, full and slotted pages, and a ' + 'page that omits kind is a full page: `objectstack validate`, the metadata save door (a 422) and ' + 'every other door that parses a page name the key, the page\'s kind and the compiled kinds. An ' + 'empty list is refused like a full one, because the key is what is refused. No page body ' @@ -41,7 +41,7 @@ export const entry: SemanticMigration = { + 'html page, where the platform derives the list from the source and judges it at save and load.', acceptanceCriteria: '`objectstack validate` reports no issue at a page\'s `requires` path: no react, full or slotted ' - + 'page, and no page that omits kind, carries the key, and each html or jsx page either omits it ' + + 'page, and no page that omits kind, carries the key, and each html page either omits it ' + 'or carries exactly the list its source compiles to. Saving each formerly affected page through ' + 'the metadata API succeeds instead of answering a 422 that names `requires`. Replaying ' + '`objectstack migrate meta --from 17` over the edited source lists no ' diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-html-page-div-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-html-page-div-refused.ts index ecfa5b60cf3..5cb5592f1c9 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-html-page-div-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-html-page-div-refused.ts @@ -18,7 +18,7 @@ export const entry: SemanticMigration = { // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it // inside a code span. surface: - "kind:'html' page source (and its deprecated kind:'jsx' alias) in a project with no " + "kind:'html' page source in a project with no " + 'sdui.manifest.json of its own — the div tag, and any other tag or prop the SDUI ' + 'component manifest shipped in @objectstack/console does not declare', replacement: diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index bba8ff71aec..d0f9abb3403 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2503,8 +2503,8 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html ' + 'page\'s source when the page is saved, while on `react`, `full` and `slotted` pages nothing ' + 'derived it, the Studio page editor dropped it on every save, and a load-time warning was its ' - + 'one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated ' - + 'alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` ' + + 'one reader. `PageSchema` now accepts the key only when `kind` is `html` (the one compiled ' + + 'kind since `jsx` retired), and refuses it at `requires` on every other kind, a page that omits `kind` ' + 'included, naming the key, the page\'s kind and the compiled kinds. The key stays live on html ' + 'pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` ' + 'deletes the key from those pages, retired from the load path, so stored rows and artifacts ' diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 5c8b90dd591..fa3fea1da37 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -89,6 +89,7 @@ import { ScriptConfigSchema, SubflowConfigSchema } from '../automation/schemales import { AdminScopeSchema } from '../security/permission.zod'; import { SharingLevel } from '../security/sharing.zod'; import { DatasourceSchema } from '../data/datasource.zod'; +import { PageSchema } from '../ui/page.zod'; import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; import { @@ -310,6 +311,15 @@ const JOIN: Readonly> = { 'driver', ), ], + // #23543 F5-jsx: an enum member retired through `enumWithRetiredValues`; the + // page is otherwise a complete html page, so `kind` is the only issue. + 'page-kind-jsx-to-html': [ + refusal( + "page kind 'jsx'", + () => PageSchema.safeParse({ name: 'landing', label: 'Landing', kind: 'jsx', source: '
hi
' }), + 'kind', + ), + ], // Shipped by a 17.x minor; `toMajor: 18` like the rows above. 'page-header-breadcrumb-removed': [tombstone(PageHeaderProps, 'page:header', 'breadcrumb')], 'object-grid-default-sort-removed': [tombstone(ObjectGridPropsSchema, 'object-grid', 'defaultSort')], diff --git a/packages/spec/src/stack-conversions-record.test.ts b/packages/spec/src/stack-conversions-record.test.ts index b532b5db59b..5f1e2be6481 100644 --- a/packages/spec/src/stack-conversions-record.test.ts +++ b/packages/spec/src/stack-conversions-record.test.ts @@ -52,8 +52,13 @@ afterEach(() => { * `description` → `subtitle` alias, until that conversion retired from the * load path at 18 — any live page conversion carries what is pinned here.) * No standalone actions, so the same built stack can be composed twice. + * + * `callout` adds a flow whose node is authored as `http_request`, the spelling + * the live conversion `flow-node-http-callout-rename` rewrites to `http` — a + * second, different conversion for the ordering case. (It used to be a + * `kind: 'jsx'` page, until `jsx` retired as a page kind at 18.) */ -const source = (ns: string, headerKey: 'visibility' | 'visibleWhen', pageKind: 'jsx' | 'html' = 'html') => ({ +const source = (ns: string, headerKey: 'visibility' | 'visibleWhen', callout: 'http_request' | 'http' | null = null) => ({ manifest: { id: `com.example.${ns}`, name: ns, version: '1.0.0', type: 'app' as const, namespace: ns }, objects: [{ name: `${ns}_thing`, label: 'Thing', fields: { title: { type: 'text' as const, label: 'Title' } } }], pages: [ @@ -67,8 +72,8 @@ const source = (ns: string, headerKey: 'visibility' | 'visibleWhen', pageKind: ' }, ], }, - ...(pageKind === 'jsx' ? [{ name: `${ns}_landing`, label: 'Landing', kind: 'jsx', source: '
hi
' }] : []), ], + ...(callout ? { flows: [{ name: `${ns}_notify`, nodes: [{ id: 'n1', type: callout }] }] } : {}), }); const HEADER_PATH = 'pages[0].regions[0].components[0].visibleWhen'; @@ -92,12 +97,12 @@ const HEADER_NOTICE = { retiresIn: 16, }; -const KIND_NOTICE = { +const CALLOUT_NOTICE = { code: CONVERSION_NOTICE_CODE, - conversionId: 'page-kind-jsx-to-html', - path: 'pages[1].kind', - from: 'jsx', - to: 'html', + conversionId: 'flow-node-http-callout-rename', + path: 'flows[0].nodes[0].type', + from: 'http_request', + to: 'http', }; const quiet = () => vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -129,11 +134,11 @@ describe('defineStack records the conversions it applied', () => { it('two conversions in one source are recorded in the order they were applied', () => { quiet(); - const built = defineStack(source('aa', 'visibility', 'jsx') as never, { strict: false }); + const built = defineStack(source('aa', 'visibility', 'http_request') as never, { strict: false }); const record = stackConversionsOf(built).map(substance); expect(record).toHaveLength(2); expect(record).toContainEqual(HEADER_NOTICE); - expect(record).toContainEqual(expect.objectContaining(KIND_NOTICE)); + expect(record).toContainEqual(expect.objectContaining(CALLOUT_NOTICE)); }); it('⭐ control: the canonical spelling records nothing, on a stack that IS marked', () => { diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index 2f4ec3d1c0e..00a1cf93db6 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -238,7 +238,6 @@ const PAGE_BASE = { name: 'home_page', label: 'Home', type: 'home' } as const; const pageSourceFixtures: Fixture[] = [ { label: 'an `html` page with no `source`', value: { ...PAGE_BASE, kind: 'html' }, refusesAt: ['source'] }, { label: 'a `react` page with a whitespace-only `source`', value: { ...PAGE_BASE, kind: 'react', source: ' ' }, refusesAt: ['source'] }, - { label: 'a `jsx` page with an empty `source`', value: { ...PAGE_BASE, kind: 'jsx', source: '' }, refusesAt: ['source'] }, { label: 'an `html` page with a `source`', value: { ...PAGE_BASE, kind: 'html', source: 'Card' }, refusesAt: [] }, { label: 'a `full` page with no `source`', value: { ...PAGE_BASE, kind: 'full' }, refusesAt: [] }, { label: 'a page with no `kind` (the `full` default)', value: { ...PAGE_BASE }, refusesAt: [] }, @@ -256,7 +255,6 @@ const pageRequiresFixtures: Fixture[] = [ { label: '`requires` on a page with no `kind` (the `full` default)', value: { ...PAGE_BASE, requires: ['ui'] }, refusesAt: ['requires'] }, { label: 'an EMPTY `requires` on a `react` page — the key is refused, not its contents', value: { ...PAGE_BASE, kind: 'react', source: 'Card', requires: [] }, refusesAt: ['requires'] }, { label: '`requires` on an `html` page', value: { ...PAGE_BASE, kind: 'html', source: 'Card', requires: ['ui'] }, refusesAt: [] }, - { label: '`requires` on a `jsx` page (the deprecated `html` alias)', value: { ...PAGE_BASE, kind: 'jsx', source: 'Card', requires: ['ui'] }, refusesAt: [] }, { label: 'a `react` page with no `requires`', value: { ...PAGE_BASE, kind: 'react', source: 'Card' }, refusesAt: [] }, ]; diff --git a/packages/spec/src/ui/page-print.test.ts b/packages/spec/src/ui/page-print.test.ts index d6b7b965cc6..3aa2ffe1876 100644 --- a/packages/spec/src/ui/page-print.test.ts +++ b/packages/spec/src/ui/page-print.test.ts @@ -115,7 +115,6 @@ describe('checkPagePrintComposition — a print page prints exactly the blocks i it.each([ ['slotted', { kind: 'slotted' }, /kind: 'slotted'.*synthesized default layout/s], ['html', { kind: 'html', source: 'Card' }, /kind: 'html'.*compiled from `source`/s], - ['jsx', { kind: 'jsx', source: 'Card' }, /kind: 'jsx'.*compiled from `source`/s], ['react', { kind: 'react', source: 'Card' }, /kind: 'react'.*executed from `source`/s], ])('refuses `print` on a `%s` page, at `print`', (_kind, page, message) => { const issues = issuesOf({ ...BASE, ...page, regions: REGIONS, print: {} }); diff --git a/packages/spec/src/ui/page.form.ts b/packages/spec/src/ui/page.form.ts index 1b33812358a..2c8bd6ff8e1 100644 --- a/packages/spec/src/ui/page.form.ts +++ b/packages/spec/src/ui/page.form.ts @@ -50,7 +50,7 @@ export const pageForm = defineForm({ // not a parse gate — `PageSchema` accepts the key whatever the `kind`, // but only the three source-compiled kinds ever read it; the two layout // kinds (`full` / `slotted`) compose regions and slots instead. - { field: 'source', type: 'code', language: 'jsx', colSpan: 2, visibleWhen: "data.kind in ['html','jsx','react']", helpText: "Page source text. For kind 'html' (alias 'jsx') it is constrained JSX compiled to the component tree at save time — parsed, never executed. For kind 'react' it is real React executed at render by the trusted runtime." }, + { field: 'source', type: 'code', language: 'jsx', colSpan: 2, visibleWhen: "data.kind in ['html','react']", helpText: "Page source text. For kind 'html' it is constrained JSX compiled to the component tree at save time — parsed, never executed. For kind 'react' it is real React executed at render by the trusted runtime." }, ], }, { diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index df31e4aadbc..4ceaa5dffaf 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -8,7 +8,7 @@ import { VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; import { SortItemSchema } from '../shared/enums.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ResponsiveStylesSchema } from './responsive.zod'; -import { retiredKey } from '../shared/retired-key'; +import { enumWithRetiredValues, retiredKey } from '../shared/retired-key'; import { UserActionsConfigSchema, AppearanceConfigSchema, @@ -941,7 +941,7 @@ export const InterfacePageConfigSchema = lazySchema(() => strictObject({ /** * The `kind` ⇄ `source` completeness check attached to {@link PageSchema} - * (ADR-0080/0081 + ADR-0078): an `html` / `react` / `jsx` page with no + * (ADR-0080/0081 + ADR-0078): an `html` / `react` page with no * `source` is silently inert — it validates and then renders nothing — so it * is refused at author time, at `source`, naming the kind. A `kind` that is * absent is the spec default (`full`), which carries no source: nothing to @@ -959,7 +959,7 @@ export function checkPageSourceCompleteness( page: { kind?: string; source?: unknown }, ctx: z.RefinementCtx, ): void { - const sourceKinds = ['html', 'react', 'jsx']; + const sourceKinds = ['html', 'react']; if (page.kind === undefined || !sourceKinds.includes(page.kind)) return; if (!(typeof page.source === 'string' && page.source.trim().length > 0)) { ctx.addIssue({ @@ -970,18 +970,41 @@ export function checkPageSourceCompleteness( } } +/** + * The `PageSchema.kind` members retired from the vocabulary, each with the + * prescription an author meets in place of zod's anonymous enum error + * (`enumWithRetiredValues`, `shared/retired-key.ts`). + * + * `jsx` was the first name of the constrained-JSX tier (ADR-0080); ADR-0081 + * renamed it `html`, and the `page-kind-jsx-to-html` conversion (ADR-0087 D2, + * protocol 11) rewrote it at load until 18, when a maintainer ruling retired + * the value (#23543, F5-jsx A). Stored pages keep loading: every stored-row + * rehydration seam replays the retired conversion, so a `sys_metadata` row + * written as `jsx` reaches this schema as `html`. The prescription carries no + * `os migrate meta` sentence on purpose: the rename belongs to protocol 11, + * below `MIGRATION_SUPPORT_FLOOR`, so the tool lists no edit for it. + */ +const PAGE_KIND_RETIRED_GUIDANCE = { + jsx: + "Page `kind: 'jsx'` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the first name of the " + + "constrained-JSX tier, which ADR-0081 renamed `html`, and the loader rewrote it to `html` until this " + + "release. Use `kind: 'html'`. Change the value; the page's `source` is compiled and rendered exactly as " + + 'before, and `requires` stays legal on it.', +} as const; + /** * The page kinds whose `source` the metadata save door compiles at save — - * `html` and its deprecated alias `jsx` (ADR-0080) — and therefore the only + * `html` alone since its first name, `jsx`, was retired at 18 (ADR-0081 + * renamed it; `PAGE_KIND_RETIRED_GUIDANCE`) — and therefore the only * kinds that carry `requires`: on these the platform derives the list from the * compiled source and stores it (ADR-0080 §5). A `react` source is executed at * render, never compiled at save (ADR-0081), and `full` / `slotted` pages have * no source to derive from, so nothing derives the key there. * - * The save door's own compile gate holds the same two kinds + * The save door's own compile gate holds the same kind * (`COMPILED_PAGE_KINDS` in `metadata-protocol`'s `runtime-authoring-gate.ts`). */ -export const COMPILED_PAGE_KINDS = ['html', 'jsx'] as const; +export const COMPILED_PAGE_KINDS = ['html'] as const; /** * The `kind` ⇄ `requires` check attached to {@link PageSchema}: `requires` is @@ -1020,8 +1043,8 @@ export function checkPageRequiresKind( message: `\`requires\` is refused on a \`kind: '${kind}'\` page` + (kind === 'full' ? ' (`full` is also the kind of a page that omits `kind`)' : '') - + ': it exists only on the kinds whose source the platform compiles at save, `html` and its ' - + 'deprecated alias `jsx`, where it is derived from the source and stored. On a ' + + ': it exists only on the kind whose source the platform compiles at save, `html`, where ' + + 'it is derived from the source and stored. On a ' + `\`${kind}\` page nothing derives it and nothing enforces it. Delete the key. ` + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', }); @@ -1450,7 +1473,7 @@ const PRINT_PAGE_TYPES_PHRASE = "of `type: 'record'`, `'home'` or `'app'` with i * - `print` on a `slotted` page — every slot it does not override is drawn * from the synthesized default layout (related lists, the discussion feed), * which the subset refuses and no author ever wrote; - * - `print` on an `html` / `jsx` / `react` page — its blocks are compiled (or, + * - `print` on an `html` / `react` page — its blocks are compiled (or, * for `react`, executed) from `source`, so there are no authored blocks to * judge; * - `print` on a `list` page — it draws its records through `interfaceConfig` @@ -1722,11 +1745,11 @@ export const PageSchema = lazySchema(() => strictObject({ * amendment; the describe cites the ADRs and leaves the amendment date here, * since it is form help in Studio (#22093). */ - kind: z.enum(['full', 'slotted', 'html', 'react', 'jsx']).default('full') + kind: enumWithRetiredValues(['full', 'slotted', 'html', 'react'], PAGE_KIND_RETIRED_GUIDANCE).default('full') .describe( "Page override mode. full | slotted = structured authoring; " + "html = author-written constrained JSX compiled (parsed, never " + - "executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias), " + + "executed) to the tree (ADR-0080; ADR-0081 named it html), " + "styled by the registered components' structured props plus a JSON `style` " + "object with hsl(var(--token)) theme colors; " + "react = real-React source executed at render by the runtime (ADR-0081), styled " + @@ -1779,7 +1802,7 @@ export const PageSchema = lazySchema(() => strictObject({ }).optional().describe('Slot override map for slotted pages'), /** - * JSX-source authoring (ADR-0080). When `kind === 'html'` (alias `'jsx'`), + * JSX-source authoring (ADR-0080). When `kind === 'html'`, * `source` is the source-of-truth: a constrained JSX text compiled by * `@objectstack/sdui-parser` into the SchemaNode tree at SAVE time — parse, * never execute. `regions` then hold the DERIVED tree (a cache; the source @@ -1793,12 +1816,12 @@ export const PageSchema = lazySchema(() => strictObject({ * styles via inline `style` with the same token colors. */ source: z.string().optional() - .describe("Page source text. For kind==='html' (alias 'jsx') it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over `regions` in both."), + .describe("Page source text. For kind==='html' it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over `regions` in both."), /** * Plugin namespaces an html page's source uses (ADR-0080 §5; ADR-0048 * provenance). Derived from the source at save, so authors omit it. The key * exists only on the kinds whose source the save door compiles — - * {@link COMPILED_PAGE_KINDS}, `html` (alias `jsx`) — and is refused at + * {@link COMPILED_PAGE_KINDS}, `html` — and is refused at * parse on a `react`, `full` or `slotted` page, a page that omits `kind` * included ({@link checkPageRequiresKind}). On a server that has the * deployment's SDUI component manifest, the save door compiles a @@ -1809,7 +1832,7 @@ export const PageSchema = lazySchema(() => strictObject({ * boot. */ requires: z.array(z.string()).optional() - .describe("Plugin namespaces the page's source uses, derived from the source at save — omit it. The key exists only on a kind==='html' page (alias 'jsx'), the kinds whose source is compiled at save; on a 'react', 'full' or 'slotted' page — and a page that omits kind, which is 'full' — it is refused at parse. On a server that has the deployment's SDUI component manifest, saving an html page compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot."), + .describe("Plugin namespaces the page's source uses, derived from the source at save — omit it. The key exists only on a kind==='html' page, the kind whose source is compiled at save; on a 'react', 'full' or 'slotted' page — and a page that omits kind, which is 'full' — it is refused at parse. On a server that has the deployment's SDUI component manifest, saving an html page compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot."), // ADR-0010 — runtime protection envelope (internal — set by the loader). // `page` is a registered metadata type, so `MetadataPlugin`'s loader stamps @@ -1818,7 +1841,7 @@ export const PageSchema = lazySchema(() => strictObject({ // shape closed. ...MetadataProtectionFields, }) - // ADR-0080/0081 + ADR-0078 (completeness): an html/react/jsx page with no + // ADR-0080/0081 + ADR-0078 (completeness): an html/react page with no // `source` is silently inert — fail loudly at author time, never render empty. // Attached by identifier, not inlined: the export is the rule a `.shape` // mirror re-attaches (#16489), and it must be this binding, not a copy. From 8338252cd316bc37d14891cb2b8df1ab7399848a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 06:26:26 +0000 Subject: [PATCH 2/5] chore(spec): regenerate page reference doc and metadata-form help text for the jsx retirement Claude-Session: https://claude.ai/code/session_01DdfPiKZUYAduc1ERcEvDso Co-authored-by: Claude --- content/docs/references/ui/page.mdx | 6 +++--- .../src/apps/translations/en.metadata-forms.generated.ts | 2 +- .../src/apps/translations/es-ES.metadata-forms.generated.ts | 2 +- .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 2 +- .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 8fdd5a4b394..374314d87c7 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -214,10 +214,10 @@ View filter rule | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **print** | `{ paperSize?: Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>; orientation?: Enum<'portrait' \| 'landscape'>; margins?: object; repeatHeader?: boolean; … }` | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page of `type: 'record'`, `'home'` or `'app'` with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is `allowPrinting`, not this. | -| **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react' \| 'jsx'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). | +| **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it html), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). | | **slots** | `{ header?: object \| object[]; actions?: object \| object[]; alerts?: object \| object[]; highlights?: object \| object[]; … }` | optional | Slot override map for slotted pages | -| **source** | `string` | optional | Page source text. For kind==='html' (alias 'jsx') it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over `regions` in both. | -| **requires** | `string[]` | optional | Plugin namespaces the page's source uses, derived from the source at save — omit it. The key exists only on a kind==='html' page (alias 'jsx'), the kinds whose source is compiled at save; on a 'react', 'full' or 'slotted' page — and a page that omits kind, which is 'full' — it is refused at parse. On a server that has the deployment's SDUI component manifest, saving an html page compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot. | +| **source** | `string` | optional | Page source text. For kind==='html' it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over `regions` in both. | +| **requires** | `string[]` | optional | Plugin namespaces the page's source uses, derived from the source at save — omit it. The key exists only on a kind==='html' page, the kind whose source is compiled at save; on a 'react', 'full' or 'slotted' page — and a page that omits kind, which is 'full' — it is refused at parse. On a server that has the deployment's SDUI component manifest, saving an html page compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index bf64a19d08a..6cbf48c9ccc 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1204,7 +1204,7 @@ export const enMetadataForms: NonNullable = { }, source: { label: "Source", - helpText: "Page source text. For kind 'html' (alias 'jsx') it is constrained JSX compiled to the component tree at save time — parsed, never executed. For kind 'react' it is real React executed at render by the trusted runtime." + helpText: "Page source text. For kind 'html' it is constrained JSX compiled to the component tree at save time — parsed, never executed. For kind 'react' it is real React executed at render by the trusted runtime." }, object: { label: "Object", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index eadc7e785cf..53c0064d19a 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1204,7 +1204,7 @@ export const esESMetadataForms: NonNullable = }, source: { label: "Código fuente", - helpText: "Texto fuente de la página. Con kind 'html' (alias 'jsx') es JSX restringido que se compila al árbol de componentes al guardar: se analiza, nunca se ejecuta. Con kind 'react' es React real que el runtime de confianza ejecuta al renderizar." + helpText: "Texto fuente de la página. Con kind 'html' es JSX restringido que se compila al árbol de componentes al guardar: se analiza, nunca se ejecuta. Con kind 'react' es React real que el runtime de confianza ejecuta al renderizar." }, object: { label: "Objeto", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index e08f2e5e8dd..31188af32fa 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1204,7 +1204,7 @@ export const jaJPMetadataForms: NonNullable = }, source: { label: "ページソース", - helpText: "ページのソーステキスト。kind が 'html'(別名 'jsx')の場合は制約付きの JSX で、保存時にコンポーネントツリーへコンパイルされます(解析のみで、実行はしません)。kind が 'react' の場合は本物の React で、信頼済みランタイムが描画時に実行します。" + helpText: "ページのソーステキスト。kind が 'html' の場合は制約付きの JSX で、保存時にコンポーネントツリーへコンパイルされます(解析のみで、実行はしません)。kind が 'react' の場合は本物の React で、信頼済みランタイムが描画時に実行します。" }, object: { label: "オブジェクト", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index eb3caba54a3..0e33d65b3b7 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1204,7 +1204,7 @@ export const zhCNMetadataForms: NonNullable = }, source: { label: "页面源码", - helpText: "页面源码文本。kind 为 'html'(别名 'jsx')时,它是受约束的 JSX,在保存时被编译成组件树——只解析,绝不执行。kind 为 'react' 时,它是真正的 React,由受信任运行时在渲染时执行。" + helpText: "页面源码文本。kind 为 'html' 时,它是受约束的 JSX,在保存时被编译成组件树——只解析,绝不执行。kind 为 'react' 时,它是真正的 React,由受信任运行时在渲染时执行。" }, object: { label: "对象", From 08d2412877f0af0deed2a3a88c8673365abee532 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 06:46:02 +0000 Subject: [PATCH 3/5] test(spec): the page kind vocabulary pin reads four kinds and one compiled kind Claude-Session: https://claude.ai/code/session_01DdfPiKZUYAduc1ERcEvDso Co-authored-by: Claude --- .../spec/src/ui/page-requires-compiled-kinds.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/ui/page-requires-compiled-kinds.test.ts b/packages/spec/src/ui/page-requires-compiled-kinds.test.ts index 2f71696b117..9f9e0093db2 100644 --- a/packages/spec/src/ui/page-requires-compiled-kinds.test.ts +++ b/packages/spec/src/ui/page-requires-compiled-kinds.test.ts @@ -42,7 +42,7 @@ const D3_ID = 'page-requires-non-compiled-kind-refused'; const BASE = { name: 'home_page', label: 'Home', type: 'home' } as const; /** The kinds a `source` is required on — every fixture of one carries a source, so only `requires` is judged. */ -const SOURCE_KINDS = new Set(['html', 'jsx', 'react']); +const SOURCE_KINDS = new Set(['html', 'react']); const page = (kind: string | undefined, extra: Record = {}) => ({ ...BASE, ...(kind === undefined ? {} : { kind }), @@ -64,9 +64,10 @@ function refusalOf(value: unknown) { } describe('page `requires` — the parse refuses it outside the compiled kinds', () => { - it('the vocabulary: five kinds, of which `html` and `jsx` are the compiled ones', () => { - expect([...PAGE_KINDS].sort()).toEqual(['full', 'html', 'jsx', 'react', 'slotted']); - expect([...COMPILED_PAGE_KINDS]).toEqual(['html', 'jsx']); + // `jsx`, the compiled tier's first name, retired at 18 (#23543 F5-jsx). + it('the vocabulary: four kinds, of which `html` is the compiled one', () => { + expect([...PAGE_KINDS].sort()).toEqual(['full', 'html', 'react', 'slotted']); + expect([...COMPILED_PAGE_KINDS]).toEqual(['html']); expect(PAGE_KINDS.filter((k) => !(COMPILED_PAGE_KINDS as readonly string[]).includes(k)).sort()) .toEqual([...NON_COMPILED].sort()); }); @@ -79,7 +80,8 @@ describe('page `requires` — the parse refuses it outside the compiled kinds', expect(issue!.path).toEqual(['requires']); expect(issue!.message.startsWith(`\`requires\` is refused on a \`kind: '${kind}'\` page`)).toBe(true); expect(issue!.message).toContain('`html`'); - expect(issue!.message).toContain('`jsx`'); + // The retired `jsx` is no compiled kind to send an author to. + expect(issue!.message).not.toContain('jsx'); expect(issue!.message).toContain('Delete the key.'); // Runtime prose carries no tracker number (`check:doc-authoring`). expect(issue!.message).not.toMatch(/#\d/); From 5bf258408eba7efb5ecdecb97a86575e9e0683bb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 06:59:41 +0000 Subject: [PATCH 4/5] test(lint): the source-authored page pins read html and react only Claude-Session: https://claude.ai/code/session_01DdfPiKZUYAduc1ERcEvDso Co-authored-by: Claude --- packages/lint/src/page-walk.test.ts | 4 ++-- packages/lint/src/validate-page-source-styling.test.ts | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/lint/src/page-walk.test.ts b/packages/lint/src/page-walk.test.ts index a48529075ee..36f4ef09944 100644 --- a/packages/lint/src/page-walk.test.ts +++ b/packages/lint/src/page-walk.test.ts @@ -284,8 +284,8 @@ describe('walkPageComponents — cycle guard', () => { }); describe('isSourceAuthoredPage', () => { - it('treats html/react/jsx as source-authored and skips their regions', () => { - for (const kind of ['html', 'react', 'jsx']) { + it('treats html/react as source-authored and skips their regions', () => { + for (const kind of ['html', 'react']) { expect(isSourceAuthoredPage({ kind })).toBe(true); expect( paths({ kind, regions: [{ name: 'main', components: [{ type: 'a' }] }] }), diff --git a/packages/lint/src/validate-page-source-styling.test.ts b/packages/lint/src/validate-page-source-styling.test.ts index 1c45a4d75ef..68b35aca0e1 100644 --- a/packages/lint/src/validate-page-source-styling.test.ts +++ b/packages/lint/src/validate-page-source-styling.test.ts @@ -34,9 +34,9 @@ describe('validatePageSourceStyling (ADR-0065 guardrail)', () => { expect(validatePageSourceStyling({ pages: [{ name: 'p', kind: 'full', regions: [] }] })).toEqual([]); }); - it('also covers the deprecated jsx alias', () => { + it('does not read the retired jsx kind — the page parse refuses that kind first', () => { const f = validatePageSourceStyling(page('jsx', '')); - expect(f.length).toBe(1); + expect(f).toEqual([]); }); }); @@ -52,7 +52,6 @@ describe('[#22161] one-line fix — page-source-className-tailwind', () => { it('each kind reads as one remedy line of at most 200 characters', () => { const [react] = fire('react', 'function Page(){ return
; }'); const [html] = fire('html', ''); - const [jsx] = fire('jsx', ''); expect(react!.hint).toBe( "Replace className with inline style={{}} using hsl(var(--token)) theme colors, e.g. style={{ color: 'hsl(var(--foreground))' }}", ); @@ -60,8 +59,7 @@ describe('[#22161] one-line fix — page-source-className-tailwind', () => { "Replace className with the components' structured props (, ) and a JSON " + 'style object, e.g. style={{"color":"hsl(var(--foreground))"}}', ); - expect(jsx!.hint).toBe(html!.hint); - for (const f of [react!, html!, jsx!]) { + for (const f of [react!, html!]) { expect(f.hint).not.toContain('\n'); expect(f.hint.length, f.hint).toBeLessThanOrEqual(200); } From 2e1b65d066242d602f855ba62d5e4f247a09871b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:35:11 +0000 Subject: [PATCH 5/5] fix(spec,changeset): the F5-jsx changeset declares platform-objects; the jsx-retirement prose names the doors that parse a page Claude-Session: https://claude.ai/code/session_01DdfPiKZUYAduc1ERcEvDso Co-authored-by: Claude --- .changeset/23543-f5-jsx-retire-page-kind-jsx.md | 5 ++++- packages/spec/liveness/page.json | 2 +- .../migrations/entries/semantic/18.page-kind-jsx-retired.ts | 4 ++-- packages/spec/src/ui/page.form.ts | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.changeset/23543-f5-jsx-retire-page-kind-jsx.md b/.changeset/23543-f5-jsx-retire-page-kind-jsx.md index 24f91eeab26..f64c91097bd 100644 --- a/.changeset/23543-f5-jsx-retire-page-kind-jsx.md +++ b/.changeset/23543-f5-jsx-retire-page-kind-jsx.md @@ -3,13 +3,14 @@ '@objectstack/metadata-protocol': patch '@objectstack/cli': patch '@objectstack/lint': patch +'@objectstack/platform-objects': patch --- feat(spec)!: the page kind `jsx` is retired — an authored `kind: 'jsx'` is refused with the prescription naming `html`, and `page-kind-jsx-to-html` leaves the load path Clause-②: yes (narrowing) -**BREAKING (authoring):** `jsx` was the first name of the constrained-JSX page tier (ADR-0080), which ADR-0081 renamed `html`. Through 17 it stayed a declared `PageSchema.kind` member and the loader rewrote an authored `kind: 'jsx'` to `html` with a notice. At 18 it is retired: `PageSchema.kind` declares it through `enumWithRetiredValues`, so every door that parses a page (`defineStack`, `os validate`, `os build`, `os lint`, the metadata save door) refuses it by name: "Page `kind: 'jsx'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `kind: 'html'`." Writing it in TypeScript no longer compiles either. The ADR-0087 D2 conversion `page-kind-jsx-to-html` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the value. `COMPILED_PAGE_KINDS` is now `['html']`: `requires` is accepted on an `html` page only. +**BREAKING (authoring):** `jsx` was the first name of the constrained-JSX page tier (ADR-0080), which ADR-0081 renamed `html`. Through 17 it stayed a declared `PageSchema.kind` member and the loader rewrote an authored `kind: 'jsx'` to `html` with a notice. At 18 it is retired: `PageSchema.kind` declares it through `enumWithRetiredValues`, so it is refused by name at `os validate`, `os build`, the metadata save door and `defineStack`, so under `os lint` too when the config calls it: "Page `kind: 'jsx'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `kind: 'html'`." Writing it in TypeScript no longer compiles either. The ADR-0087 D2 conversion `page-kind-jsx-to-html` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the value. `COMPILED_PAGE_KINDS` is now `['html']`: `requires` is accepted on an `html` page only. Migration: @@ -22,4 +23,6 @@ Stored metadata needs no edit. Every stored-row rehydration seam replays the ret The readers that branched on the retired value dropped it: the metadata save door's html-page compile gate (`@objectstack/metadata-protocol`), the CLI's SDUI-manifest page count, and the lint page walk, `validateJsxPages` and the page-source styling rule (`@objectstack/lint`) now read `html` (and `react` where they did before) only. +`@objectstack/platform-objects` publishes the Studio page form's `source` help text in four locales (`en`, `zh-CN`, `ja-JP`, `es-ES`); it no longer names `jsx`. + diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index b0c08236ca1..d1a769a6122 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -12,7 +12,7 @@ "evidenceScope": "in-repo", "evidence": "PARSE: packages/spec/src/ui/page.zod.ts#checkPageRequiresKind refuses the key on every kind but html (COMPILED_PAGE_KINDS) — a react, full or slotted page, a page that omits kind included — at requires, naming the key, the kind and the compiled kinds. SAVE: packages/metadata-protocol/src/runtime-authoring-gate.ts#findHtmlPageSourceGaps reads the authored list of a kind 'html' page and refuses one that disagrees with the namespaces its compiled source uses — 422 INVALID_METADATA under page-requires-disagrees-with-source, on an active save and on a draft's publish; packages/metadata-protocol/src/runtime-authoring-gate.ts#stampHtmlPageRequires stores the compiled list on save, and packages/metadata-protocol/src/protocol.ts#promoteDraftForPublish applies it again to the body a draft promotion writes. LOAD: packages/metadata-protocol/src/protocol.ts#reportPageRequiresAbsentAtLoad, called from boot hydration (loadMetaFromDb), reports a stored page whose list names a namespace no component in the manifest carries (packages/metadata-protocol/src/runtime-authoring-gate.ts#findPageRequiresAbsentFromManifest), page and plugin named; the page still loads and is served", "producer": "packages/cli/src/utils/sdui-manifest.ts#registerDeploymentSduiManifest — both moments compare the list against the deployment's SDUI component manifest, a second input: os serve (packages/cli/src/commands/serve.ts, which dev and start spawn) resolves it at boot and registers it under SDUI_MANIFEST_SERVICE, and packages/metadata-protocol/src/protocol.ts#resolveSduiManifest reads that key per publish and at load. A host that registers no manifest judges neither moment and prints one boot line saying so", - "note": "Plugin namespaces an html page's `source` uses (ADR-0080 §5), derived from the source at save. planned → live 2026-10-02 (#20871): refused at save since the save door landed (PR #20852, #20312 stages ① and ②), reported at load and re-stamped on draft promotion since #20870 (PR #21121). An authored list survives only when it agrees with the source; omitting it is the intended authoring. Boundaries: the key exists only on kind 'html' / 'jsx' pages, the kinds compiled at save; on 'react' (not compiled at save, ADR-0081), 'full' and 'slotted' pages, a page with no kind being 'full', the parse refuses it since 2026-10-03 (#21459, ruling A), and the protocol-18 conversion page-requires-non-compiled-kind-removed strips it from stored rows and built artifacts at load; a draft is stored as written and judged at its publish; stored html rows are reported, never rewritten." + "note": "Plugin namespaces an html page's `source` uses (ADR-0080 §5), derived from the source at save. planned → live 2026-10-02 (#20871): refused at save since the save door landed (PR #20852, #20312 stages ① and ②), reported at load and re-stamped on draft promotion since #20870 (PR #21121). An authored list survives only when it agrees with the source; omitting it is the intended authoring. Boundaries: the key exists only on kind 'html' pages, the kind compiled at save; on 'react' (not compiled at save, ADR-0081), 'full' and 'slotted' pages, a page with no kind being 'full', the parse refuses it since 2026-10-03 (#21459, ruling A), and the protocol-18 conversion page-requires-non-compiled-kind-removed strips it from stored rows and built artifacts at load; a draft is stored as written and judged at its publish; stored html rows are reported, never rewritten." }, "name": { "status": "live", diff --git a/packages/spec/src/migrations/entries/semantic/18.page-kind-jsx-retired.ts b/packages/spec/src/migrations/entries/semantic/18.page-kind-jsx-retired.ts index 20369648ec1..b7edf97c40e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.page-kind-jsx-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.page-kind-jsx-retired.ts @@ -19,8 +19,8 @@ export const entry: SemanticMigration = { "A maintainer ruling retired `jsx` as a page kind at 18. It was the first name of the constrained-JSX " + 'tier (ADR-0080), which ADR-0081 renamed `html`; through 17 the loader rewrote an authored ' + "`kind: 'jsx'` to `html` with a notice. At 18 the page parse refuses it by name with the " - + 'prescription naming `html`, at every door that parses a page: `objectstack validate`, ' - + '`objectstack build`, `objectstack lint`, `defineStack` and the metadata save door (a 422). The ' + + 'prescription naming `html`: at `objectstack validate`, `objectstack build`, the metadata save ' + + 'door (a 422) and `defineStack`, so under `objectstack lint` too when the config calls it. The ' + 'rename is lossless — the source is compiled and rendered exactly as before — but no tool makes it ' + 'for an authored source: the D2 conversion `page-kind-jsx-to-html` belongs to protocol 11, below ' + 'the migration floor, so change the value by hand. Stored pages need no edit: every stored-row ' diff --git a/packages/spec/src/ui/page.form.ts b/packages/spec/src/ui/page.form.ts index 2c8bd6ff8e1..c6d323bd4ae 100644 --- a/packages/spec/src/ui/page.form.ts +++ b/packages/spec/src/ui/page.form.ts @@ -48,7 +48,7 @@ export const pageForm = defineForm({ // already uses for every other program-text row (`action.body.source`, // `object.fields.expression`). `visibleWhen` is a MEANINGFULNESS gate, // not a parse gate — `PageSchema` accepts the key whatever the `kind`, - // but only the three source-compiled kinds ever read it; the two layout + // but only the two source kinds (`html` / `react`) ever read it; the two layout // kinds (`full` / `slotted`) compose regions and slots instead. { field: 'source', type: 'code', language: 'jsx', colSpan: 2, visibleWhen: "data.kind in ['html','react']", helpText: "Page source text. For kind 'html' it is constrained JSX compiled to the component tree at save time — parsed, never executed. For kind 'react' it is real React executed at render by the trusted runtime." }, ],