diff --git a/.changeset/23543-f4-visibility-alias-keys-retired.md b/.changeset/23543-f4-visibility-alias-keys-retired.md new file mode 100644 index 00000000000..7ff16d15771 --- /dev/null +++ b/.changeset/23543-f4-visibility-alias-keys-retired.md @@ -0,0 +1,46 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': patch +'@objectstack/metadata-core': patch +--- + +feat(spec)!: retire the view form's `visibleOn` and the page component's `visibility` as keys; their ADR-0089 renames leave the load path + +Clause-②: yes (narrowing) + +**BREAKING (authoring):** ADR-0089 made `visibleWhen` the one conditional-visibility key, and through 17 the old spellings were declared `[DEPRECATED]` keys that the parse folded onto `visibleWhen` without a word: `visibleOn` on a form view's fields and sections, and `visibility` on a page component. At 18 all three positions carry a `retiredKey()` tombstone naming `visibleWhen`, the parse-time fold is gone, and the two ADR-0087 D2 conversions `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` (protocol 15) are retired from the load path (`retiredAfter: '17.7.0'`). + +## FROM → TO + +| you wrote (17.7 and earlier) | write instead | +| --- | --- | +| `visibleOn: "…"` on a form field (`view.form.sections[].fields[]`) | `visibleWhen: "…"` | +| `visibleOn: "…"` on a form section (`view.form.sections[]`) | `visibleWhen: "…"` | +| `visibility: "…"` on a page component (`page.components[]`, slots and nested containers included) | `visibleWhen: "…"` | + +**The one-line fix:** rename the key to `visibleWhen`; the CEL predicate is unchanged. Where a node wrote both keys, `visibleWhen` was the one in effect: delete the old key. + +**What an author who still writes it sees.** `tsc` fails at a typed authoring site (`FormFieldInput`, `FormSection`, `PageComponent` type the key as retired), and the parse refuses it by name at its own path — `defineStack` throws `STACK_SCHEMA_INVALID` (422), `os validate` / `os build` print the issue, and the metadata save door answers 422: + +> `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. + +**No tool makes the rename.** Both conversions belong to protocol 15, below the migration floor, so `os migrate meta --from 17` applies no edit for either key and `--write` writes none: it prints each written key among the refusals the migrated stack still has, and lists the D3 entry `ui-visibility-alias-keys-retired` as the hand edit. + +**Stored metadata needs no edit.** Stored-row rehydration and the artifact-ingestion door inside its window still replay both conversions, so a `sys_metadata` view or page row written with the old key keeps loading with `visibleWhen`. A script that saves a view or page through the metadata API with the old key is refused and needs the same rename. + +## The retirement kit + +- **Tombstones** on `FormFieldSchema`, `FormSectionSchema` and `PageComponentSchema`; `RETIRED_KEYS_BY_MAJOR[18]`: `ui/FormField:visibleOn`, `ui/FormSection:visibleOn`, `ui/PageComponent:visibility`; the authorable-surface rows read `[RETIRED]`. +- **The fold is gone.** The three shapes are plain closed objects again (no `.transform` pipe): a section's group-reference rule no longer lists `visibleOn` (the tombstone answers it first), and a slotted page node's `type` refusal now arrives at its own path instead of nested in an `invalid_union`. +- **The D3 entry `ui-visibility-alias-keys-retired`**, one for both keys. +- **No deprecation window**, per the project's startup-stage posture. + +`@objectstack/lint`: the visibility-predicate rules (`visibility-root-mislayered`, `visibility-bare-identifier`, `visibility-predicate-syntax`) and the predicate-path rules read `visibleWhen` only. `os validate`, `os build`, `os verify`, `defineStack` and the metadata save door parse first and refuse either retired key by name, so a body they judge cannot carry one. `os lint` is the one door that hands these rules an unparsed stack: there a predicate written under a retired key is not judged, by design — the parsing doors refuse the key itself, and `os lint` reports no refused shape. + +`@objectstack/metadata-core`: `detectUnboundFormViewPredicateRoots` reads `visibleWhen` only. Its caller scans an artifact after `applyArtifactForwardConversions`, which renames a legacy `visibleOn` first. + +Not part of it: `normalizeVisibleWhen` and `VISIBILITY_ALIAS_KEYS` stay exported (no schema here calls them any more); objectui's renderer reads of the old keys leave on the objectui side. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 16c11e876cf..b032149ab1a 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -421,8 +421,8 @@ sections: **There is no section-level `visible` key — and no permission test belongs here.** - `FormSectionSchema` is `.strict()` and declares `visibleWhen` (plus the `@deprecated` - alias `visibleOn`); `visible` is refused *by name*, before any value shape is examined, + `FormSectionSchema` is `.strict()` and declares `visibleWhen` (the retired `visibleOn` + is refused by name since `@objectstack/spec` 18); `visible` is refused *by name*, before any value shape is examined, and the rejection points you back at `visibleWhen`. Neither a `{ field, value }` rule object nor a `{ permission }` name is a value `visibleWhen` accepts either — it takes a CEL predicate string, normalized to a `{ dialect: 'cel', source }` envelope at parse. @@ -945,9 +945,11 @@ only.** Nothing on the write path evaluates a form-view field or section `visibleWhen`, and that is the whole list — so a `current_user.positions` test written here hides controls and protects no data. -The legacy spellings `visibleOn` (view) and `visibility` (page) are `@deprecated` -aliases: still accepted and folded into `visibleWhen` at parse time, so existing -metadata keeps working. Author new metadata with `visibleWhen`. +The legacy spellings `visibleOn` (view) and `visibility` (page) were removed in +`@objectstack/spec` 18: the parse refuses each by name with the rename to +`visibleWhen`, so rename the key (the CEL value is unchanged). Stored view and +page rows written with the old key keep loading — rehydration renames it before +the parse. Since `@objectstack/spec` 15 (ADR-0089 D3a) the form field/section and page component schemas are **strict**: a key they do not declare — a `visibleWhen` diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index ca92bf328f6..dfc76954661 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -302,7 +302,7 @@ View filter rule | **className** | `string` | optional | CSS class names | | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record`. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | -| **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | +| **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. | | **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. 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. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -442,7 +442,7 @@ Print declaration: paper size, orientation, margins, the running header and foot | **className** | `string` | optional | CSS class names | | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record`. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | -| **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | +| **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. | | **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. 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. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 19a3240c7cb..f07cf57cb58 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -227,7 +227,7 @@ Column footer summary configuration | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | | **dependsOn** | `string` | optional | Parent field name for cascading | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | -| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | +| **visibleOn** | `never` | optional | [REMOVED] `view.form.sections[].fields[].visibleOn` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the form view's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a field writes both, `visibleWhen` is the one that was in effect: delete `visibleOn`. | | **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). | | **fields** | `[FormField](#formfield)[]` | optional | Sub-fields for composite/repeater/record types | @@ -321,10 +321,10 @@ Form-view select option — the object-field option shape minus the per-option ` | **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away. The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | | **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false`, so writing `collapsed: true` alone is a correct way to say "collapsed by default". The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: unbound on the standalone form routes, where the predicate would fault open. | -| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | +| **visibleOn** | `never` | optional | [REMOVED] `view.form.sections[].visibleOn` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the form view's deprecated spelling of a section's conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate, which hides the whole section when false) is unchanged. Where a section writes both, `visibleWhen` is the one that was in effect: delete `visibleOn`. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional | Fields per row, 1–4 ('1'–'4' parse to the number). Declared: the author's ceiling — at most that many per row where the container is wide enough; a renderer collapses it on narrow containers. Undeclared: the renderer's choice by available width — the parse adds no default, so declare `columns: 1` to keep a section single-column. | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | -| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | | **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | | ### Nested Shape: `FormSection.fields[number]` @@ -357,7 +357,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | | **dependsOn** | `string` | optional | Parent field name for cascading | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | -| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | +| **visibleOn** | `never` | optional | [REMOVED] `view.form.sections[].fields[].visibleOn` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the form view's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a field writes both, `visibleWhen` is the one that was in effect: delete `visibleOn`. | | **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). | | **fields** | `{ field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … }[]` | optional | Sub-fields for composite/repeater/record types | @@ -445,10 +445,10 @@ Form-view select option — the object-field option shape minus the per-option ` | **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away. The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | | **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false`, so writing `collapsed: true` alone is a correct way to say "collapsed by default". The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: unbound on the standalone form routes, where the predicate would fault open. | -| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | +| **visibleOn** | `never` | optional | [REMOVED] `view.form.sections[].visibleOn` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the form view's deprecated spelling of a section's conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate, which hides the whole section when false) is unchanged. Where a section writes both, `visibleWhen` is the one that was in effect: delete `visibleOn`. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional | Fields per row, 1–4 ('1'–'4' parse to the number). Declared: the author's ceiling — at most that many per row where the container is wide enough; a renderer collapses it on narrow containers. Undeclared: the renderer's choice by available width — the parse adds no default, so declare `columns: 1` to keep a section single-column. | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | -| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | | **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | | ### Nested Shape: `FormView.groups[number]` @@ -461,10 +461,10 @@ Form-view select option — the object-field option shape minus the per-option ` | **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away. The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | | **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false`, so writing `collapsed: true` alone is a correct way to say "collapsed by default". The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: unbound on the standalone form routes, where the predicate would fault open. | -| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | +| **visibleOn** | `never` | optional | [REMOVED] `view.form.sections[].visibleOn` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the form view's deprecated spelling of a section's conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate, which hides the whole section when false) is unchanged. Where a section writes both, `visibleWhen` is the one that was in effect: delete `visibleOn`. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional | Fields per row, 1–4 ('1'–'4' parse to the number). Declared: the author's ceiling — at most that many per row where the container is wide enough; a renderer collapses it on narrow containers. Undeclared: the renderer's choice by available width — the parse adds no default, so declare `columns: 1` to keep a section single-column. | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | -| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | | **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | | ### Nested Shape: `FormView.subforms[number]` diff --git a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts index ce5663f7c2b..625b3ad72c5 100644 --- a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts +++ b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts @@ -57,15 +57,18 @@ * the original divergence for exactly these configs with all of them green. * * The two fixtures below are a MINIMAL PAIR from one template, differing in a - * single key on one page-header component: `visibility`, the alias - * `page-component-visibility-to-visibleWhen` rewrites at load, against - * `visibleWhen`, the canonical spelling that converts nothing. (The pair used - * to differ in the header's `description` against `subtitle`; that conversion, - * `page-header-subtitle-alias`, retired from the load path at protocol 18 and - * the fixture was re-pointed here, as the paragraph below prescribes.) So the pin discriminates on the - * CONVERSION and not on "a page is present" — measured on the pair before it - * was written: `visibility` → text `--strict` 1, `--json --strict` 1, `--json` - * 0, `warnings: []`, one notice; `visibleWhen` → 0 on every face, no notices. + * single value on one flow node: `type: 'http_request'`, the retired callout + * name `flow-node-http-callout-rename` rewrites at load, against `type: 'http'`, + * the canonical spelling that converts nothing. (The pair used to differ in + * the header's `description` against `subtitle`, then in a page component's + * `visibility` against `visibleWhen`; each of those conversions retired from + * the load path at protocol 18 — `page-header-subtitle-alias`, then + * `page-component-visibility-to-visibleWhen` with its key, #23543 F4 — and the + * fixture was re-pointed, as the paragraph below prescribes.) So the pin + * discriminates on the CONVERSION and not on "a flow is present". The cells + * below are the same as they were measured on the earlier pairs: the + * converting member → text `--strict` 1, `--json --strict` 1, `--json` 0, + * `warnings: []`, one notice; the canonical member → 0 on every face, no notices. * * Under the one-authoring-shape ruling (#20367) every accepted config is * `defineStack` output, and `defineStack` applies the conversion itself at @@ -74,13 +77,13 @@ * reachable again (#20476) because the producer RECORDS the conversions it * applied on the stack it returns (`stackConversionsOf`) and the door folds * that record into the list `--strict` gates on and into `conversions`. The - * cell below is back to the measurement above: `visibility` → both `--strict` - * faces 1, `--json` alone 0, `warnings: []`, the one notice. + * cell below is back to the measurement above: the converting member → both + * `--strict` faces 1, `--json` alone 0, `warnings: []`, the one notice. * * The live-notice assertion (the payload entry, and the producer's one * stderr line) is the anti-vacuity guard, and it is load-bearing rather than - * decorative. `page-component-visibility-to-visibleWhen` is a LIVE - * window; the day it retires from the load path, + * decorative. `flow-node-http-callout-rename` is a LIVE + * window (the last one); the day it retires from the load path, * this fixture raises nothing and, without that assertion, the file would keep * passing while pinning an empty cell — precisely the failure this test exists * to remove. It goes red instead, and whoever retires the entry re-points the @@ -175,15 +178,13 @@ export default defineStack({ /** * The conversions-only minimal pair (#11301) — `CLEAN_SOURCE`'s shape plus one - * `page:header` component, whose visibility predicate is authored under the - * key named. + * flow whose callout node is authored under the node type named. * - * Nothing else here raises an advisory: the unknown-key lints run on the - * POST-conversion `normalized`, so what they see is the canonical `visibleWhen` - * either way, and both members are pinned to `warnings: []` below rather than - * assumed to be. + * Nothing else here raises an advisory: the lints run on the POST-conversion + * `normalized`, so what they see is the canonical `http` either way, and both + * members are pinned to `warnings: []` below rather than assumed to be. */ -const headerPageSource = (headerVisibilityKey: 'visibility' | 'visibleWhen'): string => ` +const calloutFlowSource = (calloutNodeType: 'http_request' | 'http'): string => ` import { defineStack } from '@objectstack/spec'; export default defineStack({ @@ -195,12 +196,16 @@ export default defineStack({ fields: { title: { type: 'text', label: 'Title' } }, }], apps: [{ name: 'strictexit_app', label: 'Strict Exit App' }], - pages: [{ - name: 'strictexit_home', - label: 'Home', - regions: [{ name: 'main', components: [ - { type: 'page:header', properties: { title: 'Tickets' }, ${headerVisibilityKey}: "page.mode != 'edit'" }, - ] }], + flows: [{ + name: 'strictexit_notify', + label: 'Notify', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', type: '${calloutNodeType}', 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' }], }], }, { strict: false }); `; @@ -241,10 +246,10 @@ beforeAll(() => { writeFileSync(join(cleanDir, 'objectstack.config.ts'), CLEAN_SOURCE); linkSpec(cleanDir); conversionsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-')); - writeFileSync(join(conversionsDir, 'objectstack.config.ts'), headerPageSource('visibility')); + writeFileSync(join(conversionsDir, 'objectstack.config.ts'), calloutFlowSource('http_request')); linkSpec(conversionsDir); conversionsCanonDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-canon-')); - writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), headerPageSource('visibleWhen')); + writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), calloutFlowSource('http')); linkSpec(conversionsCanonDir); }); @@ -330,25 +335,25 @@ describe('#11174 — --strict reaches the same exit status on both faces', () => 'the producer-applied conversion reaches the payload, once', ).toEqual([ { - conversionId: 'page-component-visibility-to-visibleWhen', - path: 'pages[0].regions[0].components[0].visibleWhen', - from: 'visibility', - to: 'visibleWhen', + conversionId: 'flow-node-http-callout-rename', + path: 'flows[0].nodes[1].type', + from: 'http_request', + to: 'http', }, ]); expect(typeof payload.conversions?.[0]?.retiresIn, 'the expiry rides the entry').toBe('number'); // The text face names what failed it, in its `⚠` block. - expect(text.stdout).toContain("conversion 'page-component-visibility-to-visibleWhen'"); + expect(text.stdout).toContain("conversion 'flow-node-http-callout-rename'"); // Anti-vacuity AND the one-stderr-line property: the conversion is LIVE — // it fired, in the producer, once per run — and no door-side line repeats it - // on stderr. The day `page-component-visibility-to-visibleWhen` retires this goes red; - // re-point `headerPageSource` at a live entry in + // on stderr. The day `flow-node-http-callout-rename` retires this goes red; + // re-point `calloutFlowSource` at a live entry in // `packages/spec/src/conversions/registry.ts`. for (const run of [text, json]) { expect( - run.stderr.split("conversion 'page-component-visibility-to-visibleWhen'").length - 1, + run.stderr.split("conversion 'flow-node-http-callout-rename'").length - 1, 'defineStack reported the conversion at load, on exactly one stderr line', ).toBe(1); } @@ -360,12 +365,12 @@ describe('#11174 — --strict reaches the same exit status on both faces', () => const json = await runCli(['validate', '--json'], conversionsDir); expect(json.code, `--json without --strict must stay 0:\n${json.stdout}\n${json.stderr}`).toBe(0); const payload = JSON.parse(json.stdout) as { conversions?: Array<{ conversionId?: unknown }> }; - expect((payload.conversions ?? []).map((n) => n.conversionId)).toEqual(['page-component-visibility-to-visibleWhen']); + expect((payload.conversions ?? []).map((n) => n.conversionId)).toEqual(['flow-node-http-callout-rename']); }, 120_000); - it('control: the same page under the CANONICAL key converts nothing and exits 0 on both faces', async () => { + it('control: the same flow under the CANONICAL node type converts nothing and exits 0 on both faces', async () => { // The discriminator. Byte-identical to the fixture above but for one key, - // so a pin that passed here too would be pinning the presence of a page. + // so a pin that passed here too would be pinning the presence of a flow. const text = await runCli(['validate', '--strict'], conversionsCanonDir); expect(text.code, `text --strict:\n${text.stdout}\n${text.stderr}`).toBe(0); @@ -375,8 +380,8 @@ describe('#11174 — --strict reaches the same exit status on both faces', () => const payload = JSON.parse(json.stdout) as { warnings?: unknown; conversions?: unknown }; expect(payload.warnings).toEqual([]); expect(payload.conversions).toEqual([]); - // The discriminator's other half: the canonical key raises no notice anywhere. - expect(json.stderr).not.toContain("conversion 'page-component-visibility-to-visibleWhen'"); + // The discriminator's other half: the canonical type raises no notice anywhere. + expect(json.stderr).not.toContain("conversion 'flow-node-http-callout-rename'"); }, 120_000); it('control: without --strict, the same advisory-raising config still exits 0 under --json', async () => { diff --git a/packages/cli/test/stack-conversion-record-door.test.ts b/packages/cli/test/stack-conversion-record-door.test.ts index db5ecb79d4c..655d197d467 100644 --- a/packages/cli/test/stack-conversion-record-door.test.ts +++ b/packages/cli/test/stack-conversion-record-door.test.ts @@ -23,7 +23,7 @@ * | | BEFORE the spread drops it | * | `composeStacks([built-with-it, built-without])` | the one notice, from the | * | | input that applied it | - * | canonical `defineStack` + a NAMED export `pages` carrying | the one notice — the door's | + * | canonical `defineStack` + a NAMED export `flows` carrying | the one notice — the door's | * | the retiring spelling (merged after the producer ran) | own pass, which stays | * | the canonical spelling (control) | `[]` | * @@ -59,10 +59,12 @@ * needed no conversion answers `[]`, so an exit that reports the notice without * reading it off the refusal it caught is red there. * - * `page-component-visibility-to-visibleWhen` is the live conversion driven here - * (`visibility` on a `page:header` component, canonical `visibleWhen`; it was - * the header's `description` → `subtitle` until that entry retired from the - * load path at protocol 18). The day it retires from + * `flow-node-http-callout-rename` is the live conversion driven here (a flow + * node typed `http_request`, canonical `http`; it was the header's + * `description` → `subtitle`, then a page component's `visibility` → + * `visibleWhen`, until each entry retired from the load path at protocol 18 — + * the second with its key, #23543 F4). It is the last live entry. The day it + * retires from * the load path the non-empty rows go red; re-point the fixture at a live entry * in `packages/spec/src/conversions/registry.ts`. * @@ -124,10 +126,10 @@ function payloadOf(run: Run, label: string): Payload { /** The one entry every non-empty row must carry, by identity, site and direction. */ const THE_NOTICE = { code: 'OS_METADATA_CONVERTED', - conversionId: 'page-component-visibility-to-visibleWhen', - path: 'pages[0].regions[0].components[0].visibleWhen', - from: 'visibility', - to: 'visibleWhen', + conversionId: 'flow-node-http-callout-rename', + path: 'flows[0].nodes[1].type', + from: 'http_request', + to: 'http', }; function expectExactly(payload: Payload, expected: Array, label: string): void { @@ -143,50 +145,54 @@ function expectExactly(payload: Payload, expected: Array, lab const manifest = (ns: string) => `{ id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }`; -const page = (ns: string, headerKey: 'visibility' | 'visibleWhen') => `{ - name: '${ns}_home', - label: 'Home', - regions: [{ name: 'main', components: [ - { type: 'page:header', properties: { title: 'Things' }, ${headerKey}: "page.mode != 'edit'" }, - ] }], +const flow = (ns: string, nodeType: 'http_request' | 'http') => `{ + 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' }], }`; -const stackBody = (ns: string, headerKey: 'visibility' | 'visibleWhen' | null, requires?: string) => `{ +const stackBody = (ns: string, nodeType: 'http_request' | 'http' | null, requires?: string) => `{ manifest: ${manifest(ns)}, objects: [{ name: '${ns}_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }], - apps: [{ name: '${ns}_app', label: 'App' }],${headerKey ? `\n pages: [${page(ns, headerKey)}],` : ''}${requires ? `\n requires: ['${requires}'],` : ''} + apps: [{ name: '${ns}_app', label: 'App' }],${nodeType ? `\n flows: [${flow(ns, nodeType)}],` : ''}${requires ? `\n requires: ['${requires}'],` : ''} }`; const IMPORT = `import { composeStacks, defineStack } from '@objectstack/spec';\n\n`; const FIXTURES: Record = { // The plain case: one `defineStack` default export, no named export. - plain: IMPORT + `export default defineStack(${stackBody('pln', 'visibility')});\n`, + plain: IMPORT + `export default defineStack(${stackBody('pln', 'http_request')});\n`, // The record, across `loadConfig`'s named-export spread. recordAcrossSpread: IMPORT + `export const onEnable = async () => {};\n\n` + - `export default defineStack(${stackBody('rec', 'visibility')});\n`, + `export default defineStack(${stackBody('rec', 'http_request')});\n`, // The record of a composed stack: its inputs' records. composed: IMPORT + - `const withIt = defineStack(${stackBody('cmpa', 'visibility')});\n` + + `const withIt = defineStack(${stackBody('cmpa', 'http_request')});\n` + `const without = defineStack({ manifest: ${manifest('cmpb')}, objects: [{ name: 'cmpb_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }] });\n\n` + `export default composeStacks([withIt, without]);\n`, // A key merged after the producer ran: only the door's own pass sees it. namedExportPass: IMPORT + - `export const pages = [${page('nep', 'visibility')}];\n\n` + + `export const flows = [${flow('nep', 'http_request')}];\n\n` + `export default defineStack(${stackBody('nep', null)});\n`, // The control: the canonical spelling. - canonical: IMPORT + `export default defineStack(${stackBody('can', 'visibleWhen')});\n`, + canonical: IMPORT + `export default defineStack(${stackBody('can', 'http')});\n`, // Convert, then refuse: the strict (default) `defineStack` converts the // retiring spelling, then refuses an unknown capability token. convertThenRefuse: - IMPORT + `export default defineStack(${stackBody('ctr', 'visibility', 'no-such-capability')});\n`, + IMPORT + `export default defineStack(${stackBody('ctr', 'http_request', 'no-such-capability')});\n`, // Its control: the same refusal from a source that needed no conversion. canonicalThenRefuse: - IMPORT + `export default defineStack(${stackBody('ctc', 'visibleWhen', 'no-such-capability')});\n`, + IMPORT + `export default defineStack(${stackBody('ctc', 'http', 'no-such-capability')});\n`, }; let root = ''; @@ -234,7 +240,7 @@ for (const command of ['validate', 'build'] as const) { expect(run.code, run.stdout + run.stderr).toBe(0); expectExactly(p, [THE_NOTICE], `${command} namedExportPass`); // The producer never saw that key, so it printed nothing for it. - expect(run.stderr).not.toContain("conversion 'page-component-visibility-to-visibleWhen'"); + expect(run.stderr).not.toContain("conversion 'flow-node-http-callout-rename'"); }, 180_000); it('control: the canonical spelling converts nothing — `[]`', async () => { @@ -253,7 +259,7 @@ describe("os lint --json — the producer's conversion record reaches `conversio // and still converts through the pass (`lint-conversion-notices.e2e.test.ts` // pins that half on a plain object-literal export). const lint = (label: string) => runCli(['lint', '--json'], dirs[label]); - const producerLine = "conversion 'page-component-visibility-to-visibleWhen'"; + const producerLine = "conversion 'flow-node-http-callout-rename'"; it('the plain case: one `defineStack` default export carrying the retiring spelling', async () => { const run = await lint('plain'); @@ -335,7 +341,7 @@ describe('os validate --strict — a conversion the producer applied fails it, o const json = await runCli(['validate', '--json', '--strict'], dirs.recordAcrossSpread); expect(text.code, text.stdout + text.stderr).toBe(1); expect(text.stdout).toContain('Strict mode: warnings treated as errors'); - expect(text.stdout).toContain("conversion 'page-component-visibility-to-visibleWhen'"); + expect(text.stdout).toContain("conversion 'flow-node-http-callout-rename'"); expect(json.code, json.stdout + json.stderr).toBe(1); const p = payloadOf(json, 'validate --json --strict'); expect(p.valid).toBe(true); diff --git a/packages/lint/src/authoring-rule-input-tier.test.ts b/packages/lint/src/authoring-rule-input-tier.test.ts index d1a86e246e9..6820bda5c15 100644 --- a/packages/lint/src/authoring-rule-input-tier.test.ts +++ b/packages/lint/src/authoring-rule-input-tier.test.ts @@ -223,31 +223,26 @@ describe('premise 2 (FALSE): "the parse strips `userFilters`/`quickFilters` on a // ─────────────────────────────────────────────────────────────────────────── describe('premise 3 (FALSE): "the `visibleOn` alias survives until the parse"', () => { - // The fold is an ADR-0087 D2 conversion inside `normalizeStackInput` — one - // layer BEFORE this tier — not a parse-time `.transform()`. So the alias is - // gone from the tier's own input on every door, `os lint` included. + // Measured at #6073: the fold was an ADR-0087 D2 conversion inside + // `normalizeStackInput` — one layer BEFORE this tier — not a parse-time + // `.transform()`, and #6318 retired the alias-KEY rule on that measurement. // - // #6318 acted on that measurement: the alias-KEY rule this premise was - // written to justify (`visibility-alias-deprecated`) has been RETIRED, so the - // assertions below no longer count its findings. They pin the mechanism that - // outlives it, which is what makes the retirement safe to keep: + // #23543 F4 then retired the aliases themselves at 18: each is a spec + // tombstone, both D2 conversions left the load path, and the lint family + // stopped reading the alias VALUE. What these pins hold now: // - // * the KEY does not cross the fold — nothing downstream can judge it; - // * the VALUE does cross it intact — which is why the three surviving rules - // work on this tier and were not swept in; - // * the author is not silent — the D2 conversion notice fires in - // `defineStack`, and that notice IS the recorded guard for this surface. - // - // Read `toHaveLength(0)` on the raw leg as "the alias key is nobody's verdict - // any more"; the fold measurement itself now lives in the VALUE assertions, - // which are non-empty and can actually fail. - /** - * The three spec-valid alias sites, each carrying `predicate` under its - * DEPRECATED key. Parameterised on the predicate so the same three shapes can - * be measured twice: once with a clean value (nothing to find but the retired - * key) and once with a bare-identifier value (a VALUE defect the surviving - * gate must still reach through the fold). - */ + // * the authoring funnel no longer renames the key — `normalizeStackInput` + // passes it through, so a rule fed this tier would see it; + // * no rule reads it either way — the family reads `visibleWhen` only; + // * the author is not silent — the doors that parse first (`defineStack`, + // `os validate`, `os build`, `os verify`, the metadata save door) refuse + // the key by name with the rename. That refusal IS the recorded guard for + // this surface now; + // * `os lint` is the one door that hands the rules an UNPARSED stack + // (`runAuthoringRules('lint', …)` over the normalized tier). There a + // predicate under a retired key is nobody's verdict, by design — pinned + // below as that door's reading, beside a canonical control. + /** The three alias sites, each carrying `predicate` under its RETIRED key. */ const aliasSitesWith = (predicate: string): Array<[string, AnyRec]> => [ ['views[].form.sections[]', { manifest, @@ -275,57 +270,52 @@ describe('premise 3 (FALSE): "the `visibleOn` alias survives until the parse"', }], ]; - /** Clean, canonically-rooted predicate: the only thing wrong is the key spelling. */ - const aliasSites = aliasSitesWith('record.a == 1'); - /** Same three sites, predicate rooted nowhere (#5149 Repro 1) — a VALUE defect. */ + /** The same three sites, predicate rooted nowhere (#5149 Repro 1) — a VALUE defect. */ const aliasSitesBadValue = aliasSitesWith('approved'); - it.each(aliasSites)('%s: no rule judges the alias KEY any longer (#6318 retirement)', (_site, stack) => { - // Both doors report nothing, and for TWO DIFFERENT reasons that must not be - // conflated. Raw: the rule that would have judged the key is retired. - // Normalized: the key is not even there — the D2 fold renamed it one layer - // up. The `normalized` leg is a VACUOUS green after the retirement (it is - // empty because no rule exists, not because of the fold), so it is labelled - // as such and carries no weight on its own; the leg below is the one that - // measures the fold. - expect(validateVisibilityPredicates(structuredClone(stack))).toHaveLength(0); - expect(validateVisibilityPredicates(normalizeStackInput(structuredClone(stack)) as AnyRec)).toEqual([]); - }); - it.each(aliasSitesBadValue)( - '%s: the KEY does not cross the fold but the VALUE does — measured on a NON-EMPTY finding set', + '%s: the funnel no longer renames the retired key, and no rule reads it (#23543 F4)', (_site, stack) => { - // The replacement for the vacuous green above, and the assertion that - // actually measures the fold. Same three sites, but the predicate is now - // a bare identifier — a VALUE defect. If the fold dropped the predicate - // instead of renaming its key, or if the surviving gate stopped reaching - // it, this set would be EMPTY and the assertion would fail. It cannot - // pass by producing nothing, which is exactly what the leg above can do. const normalized = normalizeStackInput(structuredClone(stack)) as AnyRec; - // The KEY is gone from the tier's own input … - expect(JSON.stringify(normalized)).not.toContain('visibleOn'); - expect(JSON.stringify(normalized)).not.toContain('"visibility"'); - // … and the VALUE arrived under the canonical key, where the surviving - // gate reads it. Exactly one finding, named. - expect(validateVisibilityPredicates(normalized).map((f) => f.rule)).toEqual([ - 'visibility-bare-identifier', - ]); + // The key passes through: the D2 conversion is retired from the load path. + expect(JSON.stringify(normalized)).toMatch(/"visibleOn"|"visibility"/); + // A VALUE defect under it is nobody's verdict here, raw or normalized — + // the parsing doors refuse the key itself (next case). + expect(validateVisibilityPredicates(structuredClone(stack))).toEqual([]); + expect(validateVisibilityPredicates(normalized)).toEqual([]); }, ); - it('the author is NOT left silent — the D2 conversion notice names the site and its retirement', () => { - // This notice is the RECORDED GUARD for the alias surface: it is why #6318 - // could retire the lint rule instead of re-anchoring it, and why no working - // app lost a signal. If this test ever goes red, the retirement's premise is - // gone and the surface is genuinely unguarded — re-open #6318, do not delete - // this assertion. - const { error, warnings } = quietly(() => defineStack(structuredClone(aliasSites[0][1]) as never)); - expect(error).toBeUndefined(); - expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain("'visibleOn' → 'visibleWhen'"); - expect(warnings[0]).toContain('views[0].form.sections[0].visibleWhen'); - expect(warnings[0]).toContain('retires in protocol 16'); - }); + it.each(aliasSitesWith('record.a == 1'))( + '%s: the author is NOT left silent — defineStack refuses the retired key by name with the rename', + (_site, stack) => { + // The recorded guard for the alias surface. If this goes red, the surface + // is genuinely unguarded — do not delete this assertion. + const { error } = quietly(() => defineStack(structuredClone(stack) as never)); + expect(error).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = (error as unknown as { issues: { path: PropertyKey[]; message: string }[] }).issues; + const flat = JSON.stringify(issues); + expect(flat).toMatch(/(visibleOn|visibility)` was removed in @objectstack\/spec 18/); + expect(flat).toContain('Use `visibleWhen`'); + }, + ); + + it.each(aliasSitesBadValue)( + '%s: `os lint`\'s reading — the unparsed stack it hands the registry leaves a retired key\'s predicate unjudged, by design', + (_site, stack) => { + // What `os lint` runs (`commands/lint.ts`): the `'lint'` registry door over + // the NORMALIZED stack, never parsed. The retired key reaches it unrenamed + // and no visibility rule reads it. + const visibilityRules = (s: AnyRec) => runAuthoringRules('lint', { normalized: normalizeStackInput(structuredClone(s)) as AnyRec }) + .filter((f) => f.rule.startsWith('visibility-')) + .map((f) => f.rule); + expect(visibilityRules(stack)).toEqual([]); + // CONTROL — the same predicate under the canonical key is the finding at + // the same door, so the empty reading above is the posture, not a dead rule. + const canonical = JSON.parse(JSON.stringify(stack).replace(/"(visibleOn|visibility)":/g, '"visibleWhen":')) as AnyRec; + expect(visibilityRules(canonical)).toEqual(['visibility-bare-identifier']); + }, + ); it('the predicate-VALUE rules in the same file are unaffected — do not connect them', () => { // The value moves into `visibleWhen` intact, so these still report on the diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index f6dd4de95ff..78373b7603b 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -246,7 +246,7 @@ export type AuthoringRuleTier = 'gating' | 'advisory'; * * This tier used to be justified as "the rules that need it check keys the * parse strips — a flat list view in `views: []`, `userFilters` on an object - * list view, a `visibleOn` alias — by the time `result.data` exists the + * list view, a `visibleOn` alias (retired at 18) — by the time `result.data` exists the * evidence is gone". **All three of those examples were measured false**, each * for its own reason, and the pins live in `authoring-rule-input-tier.test.ts`: * @@ -271,7 +271,10 @@ export type AuthoringRuleTier = 'gating' | 'advisory'; * (`visibility-alias-deprecated`) was retired, since the D2 conversion * notice already covers its whole evidence surface with better wording and * a stated retirement window. The alias-KEY half of premise 3 is therefore - * no longer merely false — there is nothing left reading it. + * no longer merely false — there is nothing left reading it. At 18 the + * aliases retired as spec tombstones and the D2 conversions left the load + * path (#23543 F4): the parse now refuses the alias by name before any rule + * runs. * * ## What it does buy, and why the tier stays * @@ -1520,8 +1523,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // gate and (#6253) the syntax gate. This entry used to read "pre-parse: the // schema folds `visibleOn`/`visibility` into `visibleWhen` during parse, so // the alias the author wrote is gone from `result.data`". Measured false at - // #6073: the ADR-0087 D2 conversions do that fold INSIDE - // `normalizeStackInput`, one layer BEFORE this tier, so on every spec-valid + // #6073: the ADR-0087 D2 conversions did that fold INSIDE + // `normalizeStackInput` (until both aliases retired at 18, #23543 F4), one + // layer BEFORE this tier, so on every spec-valid // alias site the alias-KEY rule reported zero here too. // // #6318 closed that: `visibility-alias-deprecated` was RETIRED rather than @@ -1575,10 +1579,10 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // when nobody measured, which is the #4409/#4463 defect one layer in. // // Runtime input tier: the gate hands the rules the body as persisted, without - // `normalizeStackInput`, so the ADR-0087 D2 alias fold does NOT run at this - // door. That costs the family nothing — `validateVisibilityPredicates` reads - // `visibleWhen ?? visibleOn ?? visibility` itself, canonical-first, precisely - // so a caller handing it a raw authored object still gets a verdict. + // `normalizeStackInput`. That costs the family nothing: the door parses the + // body against the spec first (a 422 on refusal), and since the ADR-0089 + // aliases retired at 18 (#23543 F4) an accepted body spells the predicate + // `visibleWhen` only, the one key the family reads. { name: 'validateVisibilityPredicates', tier: 'gating', diff --git a/packages/lint/src/page-envelope-audit.test.ts b/packages/lint/src/page-envelope-audit.test.ts index bf46d41cb14..1f23a259685 100644 --- a/packages/lint/src/page-envelope-audit.test.ts +++ b/packages/lint/src/page-envelope-audit.test.ts @@ -158,7 +158,7 @@ describe('negative control — the detector fires, and every door earns its plac expect(doorsOneTwo).toEqual([]); }); - it('catches a bare predicate at the DEPRECATED `visibility` key and names the canonical one', () => { + it('a page still writing the RETIRED `visibility` key is a parse refusal on the precondition channel, not a finding (#23543 F4)', () => { const page = { name: 'nc_alias', label: 'Negative control', @@ -170,9 +170,21 @@ describe('negative control — the detector fires, and every door earns its plac slots: { alerts: [{ type: 'record:alert', visibility: BARE, properties: { severity: 'warning' } }] }, }; const audit = auditPageExpressionEnvelopes(page, 'nc (nc_alias)'); - expect(audit.findings.map(f => f.path)).toEqual(['slots.alerts[0].visibility']); - expect(audit.findings[0]!.normalizedTo).toBe('visibleWhen'); - expect(renderBareExpressionFindings(audit.findings)).toContain('deprecated key'); + // Until 18 the parse re-homed the value under `visibleWhen` and the audit + // reported both names; the key is refused now, so the door that parses the + // page says so instead of going silent. + expect(audit.findings).toEqual([]); + expect(audit.pageParseError).toBeDefined(); + // The component door names the key and its prescription. + expect(audit.componentParseErrors.map(e => e.path)).toEqual(['slots.alerts[0]']); + expect(audit.componentParseErrors[0]!.issues).toMatch(/^visibility: `page\.components\[\]\.visibility` was removed in @objectstack\/spec 18/); + // CONTROL — the same bare predicate under the canonical key is the finding. + const canonical = auditPageExpressionEnvelopes( + { ...page, slots: { alerts: [{ type: 'record:alert', visibleWhen: BARE, properties: { severity: 'warning' } }] } }, + 'nc (nc_canonical)', + ); + expect(canonical.findings.map(f => f.path)).toContain('slots.alerts[0].visibleWhen'); + expect(renderBareExpressionFindings(canonical.findings)).not.toContain('deprecated key'); }); }); diff --git a/packages/lint/src/page-envelope-audit.ts b/packages/lint/src/page-envelope-audit.ts index c7ce1297c41..44b776629cd 100644 --- a/packages/lint/src/page-envelope-audit.ts +++ b/packages/lint/src/page-envelope-audit.ts @@ -79,7 +79,10 @@ * MATERIALIZES (defaults) are absent from the authored side and so are never * flagged. The deprecated-alias case is caught too: when the parse consumed * an authored key and re-homed its value under the canonical name, the - * finding reports both. + * finding reports both. (The one such alias on a page, the page component's + * `visibility` → `visibleWhen`, retired at 18 as a refused key, #23543 F4: a + * page that still writes it fails the parse, which this audit reports on its + * precondition channels below, not as a finding.) * * ## Preconditions are REPORTED, never assumed * @@ -238,7 +241,8 @@ export function collectBare( const counterpart = parsed[key]; // Deprecated-alias case: the parse consumed this key and re-homed its - // value under the canonical name (`visibility` -> `visibleWhen`). A + // value under the canonical name (the page component's `visibility` -> + // `visibleWhen` was one, until that key retired at 18). A // key-parallel walk alone would see `undefined` on the parsed side and // move on, so the value is looked up by its own source text instead. if (counterpart === undefined && typeof value === 'string' && value.length > 0) { diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 8f76e4d0ea9..1fc6b155055 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -2188,7 +2188,7 @@ const SEARCHABLE_FIELD_UNPROVISIONED_EXPLANATION: RuleExplanation = { /** Which predicates the three ids judge, and the oracle they ask (all three). */ const PREDICATE_FORM_SCOPE = - 'Judged: the `visibleWhen` (or `visibleOn`) predicates on a metadata-editing form — a `views[]` ' + + 'Judged: the `visibleWhen` predicates on a metadata-editing form — a `views[]` ' + 'form, its default `form` or a `formViews` entry whose data source is `{ provider: \'schema\', ' + 'schemaId }` — on its sections and fields, nested repeater rows included, where `data` is rebound ' + 'to the ROW. A path is resolved against the Zod schema of the metadata type the form edits ' + diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 7ca9f2f5df6..8078eacde9a 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -271,8 +271,10 @@ describe('validatePredicatePathRefs — traversal reach', () => { ]); }); - it('reads the deprecated `visibleOn` alias VALUE on a raw authored object', () => { - const findings = run(form([{ fields: [{ field: 'x', visibleOn: "data.tpye == 'a'" }] }])); + it('does not read the retired `visibleOn` key; the same value under `visibleWhen` is judged (#23543 F4)', () => { + // The spec refuses the key by name before any door runs this rule. + expect(run(form([{ fields: [{ field: 'x', visibleOn: "data.tpye == 'a'" }] }]))).toEqual([]); + const findings = run(form([{ fields: [{ field: 'x', visibleWhen: "data.tpye == 'a'" }] }])); expect(findings.map((f) => f.rule)).toEqual([PREDICATE_PATH_UNRESOLVED]); }); @@ -545,7 +547,7 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { } if (!node || typeof node !== 'object') return; const rec = node as Record; - for (const key of ['visibleWhen', 'visibleOn']) { + for (const key of ['visibleWhen']) { const value = rec[key]; if (typeof value === 'string') { rec[key] = 'data.__no_such_key__'; @@ -725,7 +727,7 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { } if (!node || typeof node !== 'object') return; const rec = node as Record; - for (const key of ['visibleWhen', 'visibleOn']) { + for (const key of ['visibleWhen']) { const value = rec[key]; const source = typeof value === 'string' ? value : value && typeof value === 'object' diff --git a/packages/lint/src/validate-predicate-path-refs.ts b/packages/lint/src/validate-predicate-path-refs.ts index 395acc8ab5b..b1a534df145 100644 --- a/packages/lint/src/validate-predicate-path-refs.ts +++ b/packages/lint/src/validate-predicate-path-refs.ts @@ -112,7 +112,8 @@ * * A sub-field of a `type: 'record'` / `repeater` / `composite` entry is rendered * with its own activation: objectui's metadata SchemaForm evaluates it as - * `evaluatePredicate(spec.visibleOn, { data: row })`. So inside `object.form.ts`'s + * `evaluatePredicate(spec.visibleOn, { data: row })` (its own runtime slot, + * which the form fills from the spec's `visibleWhen`). So inside `object.form.ts`'s * `fields` repeater, `data.type` means *this row's* `type` — `FieldSchema.type`, * not `ObjectSchema.type` (which does not exist). `view.zod.ts:1647-1657` states * both halves: the root is still spelled `data` at every depth, and the object it @@ -298,16 +299,13 @@ export interface PredicatePathOptions { type AnyRec = Record; /** - * The predicate keys a form field / section can carry. Canonical first - * (ADR-0089): `visibleOn` is the deprecated view-side alias, folded into - * `visibleWhen` by the ADR-0087 D2 conversion one layer above the `normalized` - * tier — so on the three CLI commands the alias limb is already unreachable. It - * stays for the reason `validate-visibility-predicates.ts` states about its own - * limbs: this is a PUBLISHED export, and a caller handing it a raw authored - * object must have the alias-spelled predicate judged rather than skipped. - * Canonical-first ordering means the limb can only ever add coverage. + * The predicate keys a form field / section can carry (ADR-0089): `visibleWhen` + * alone. The view-side `visibleOn` retired at 18 as a spec tombstone (#23543 + * F4). The doors that parse first refuse it by name; `os lint`, which hands + * this rule an unparsed stack, leaves a predicate under it unjudged by design — + * the posture `validate-visibility-predicates.ts` states at its own `CANONICAL`. */ -const PREDICATE_KEYS = ['visibleWhen', 'visibleOn'] as const; +const PREDICATE_KEYS = ['visibleWhen'] as const; /** The binding root this rule resolves. Metadata-editing forms only — see the module note. */ const ROOT = 'data'; diff --git a/packages/lint/src/validate-visibility-predicates.test.ts b/packages/lint/src/validate-visibility-predicates.test.ts index bf5e4ea7095..3f73d5a3928 100644 --- a/packages/lint/src/validate-visibility-predicates.test.ts +++ b/packages/lint/src/validate-visibility-predicates.test.ts @@ -65,88 +65,37 @@ describe('validateVisibilityPredicates (ADR-0089 D3b)', () => { expect(validateVisibilityPredicates(stack)).toEqual([]); }); - // ── #6318: the alias-KEY rule is RETIRED; the alias-VALUE read is not ── + // ── #23543 F4: the retired alias keys are not read at all ── // - // Three tests here used to assert that a `visibleOn` / `visibility` KEY - // produced a `visibility-alias-deprecated` finding, one per carrier. That - // rule never reached a real input: through all three CLI commands the stack - // arrives at the `normalized` tier, and the ADR-0087 D2 conversions rename the - // key INSIDE `normalizeStackInput`, one layer above. Measured at retirement, - // per site: raw object 1 finding, `normalized` tier 0. The only shape that - // still fired is the one those three fixtures used — a view CONTAINER with - // top-level `sections` — which strict `ViewSchema` refuses outright - // ("Unrecognized key(s) on this view container: `sections`"). Green unit test, - // impossible fixture: #4984 / #6251. - // - // What SURVIVES those three assertions, and is what the replacements below - // pin instead: the traversal reaches all three carriers, and `checkElement` - // still reads the predicate VALUE through the deprecated key, so a value - // defect written under an alias is judged rather than skipped. Each asserts - // the EXACT finding set — non-empty and named, so it cannot pass by producing - // nothing, and restoring the retired rule would fail it on set inequality. - // - // The surface itself is not unguarded: the same D2 conversion emits a - // `warnConversionNotice` from `defineStack` naming the site, the conversion - // and the protocol-16 retirement window. `authoring-rule-input-tier.test.ts` - // (premise 3) pins that notice as the recorded guard. - describe('the deprecated alias keys (#6318 — key rule retired, value read kept)', () => { - it('a `visibleOn` KEY with a clean value is clean — no rule judges the spelling', () => { - const stack = { - views: [ - { name: 'task_form', sections: [{ label: 'S', visibleOn: "record.a == 1", fields: [] }] }, - ], - }; - // Deliberately labelled: this green is VACUOUS with respect to the fold — - // it is empty because the rule is gone, not because anything normalized. - // It earns its place only as the retirement's direct statement, and the - // three assertions below are what carry the real evidence. - expect(validateVisibilityPredicates(stack)).toEqual([]); - }); - - it('a form SECTION predicate is still read through `visibleOn` (value verdict survives)', () => { - const stack = { - views: [ - { name: 'task_form', sections: [{ label: 'S', visibleOn: "data.a == 1", fields: [] }] }, - ], - }; - const findings = validateVisibilityPredicates(stack); - expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]); - expect(findings[0].path).toBe('views[0].sections[0]'); - }); - - it('a form FIELD predicate is still read through `visibleOn`', () => { - const stack = { - views: [ - { name: 'task_form', sections: [{ fields: [{ field: 'notes', visibleOn: "data.a == 1" }] }] }, - ], - }; - const findings = validateVisibilityPredicates(stack); - expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]); - expect(findings[0].path).toBe('views[0].sections[0].fields[0]'); - }); - - it('a PAGE COMPONENT predicate is still read through the page-side `visibility` key', () => { - const stack = { - pages: [ - { name: 'p', regions: [{ components: [{ type: 'element:text', visibility: "data.x != ''" }] }] }, - ], - }; - const findings = validateVisibilityPredicates(stack); - expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]); - expect(findings[0].path).toBe('pages[0].regions[0].components[0]'); - }); - - it('the canonical key still wins over an alias on the same element (ADR-0089 ordering)', () => { - // Why the surviving alias limbs can only add coverage and never change a - // verdict: `visibleWhen` is read first, so a stale alias beside it is - // ignored rather than allowed to overrule the canonical spelling. - const stack = { - views: [ - { name: 'f', sections: [{ visibleWhen: "record.ok == 1", visibleOn: "data.stale == 1", fields: [] }] }, - ], - }; - expect(validateVisibilityPredicates(stack)).toEqual([]); - }); + // #6318 retired the alias-KEY rule and kept an alias-VALUE read + // (`visibleWhen ?? visibleOn ?? visibility`) for raw callers. Both aliases + // retired at 18 as spec tombstones, and the doors that parse first (`os + // validate`, `os build`, `os verify`, `defineStack`, the metadata save door) + // refuse either key by name. So the limbs went too: a defect written under a + // retired key produces no finding HERE. At `os lint`, the one door that hands + // this rule an unparsed stack, that is the reading by design (`CANONICAL`'s + // docblock; pinned as that door's reading in `authoring-rule-input-tier.test.ts`). Each row pairs the alias spelling with + // the same value under `visibleWhen`, which must still report, so an empty + // alias reading cannot pass by the rule having stopped working. + describe('the retired alias keys are not read (#23543 F4)', () => { + const CARRIERS = [ + ['a form section `visibleOn`', 'visibleOn', (key: string, v: string) => ({ views: [{ name: 'f', sections: [{ label: 'S', [key]: v, fields: [] }] }] })], + ['a form field `visibleOn`', 'visibleOn', (key: string, v: string) => ({ views: [{ name: 'f', sections: [{ fields: [{ field: 'n', [key]: v }] }] }] })], + ['a page component `visibility`', 'visibility', (key: string, v: string) => ({ pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', [key]: v }] }] }] })], + ] as const; + const DEFECTS = [ + ['mis-layered root', "data.a == 1", VISIBILITY_ROOT_MISLAYERED], + ['bare identifier', "status == 'x'", VISIBILITY_BARE_IDENTIFIER], + ['not CEL', 'status === "x"', VISIBILITY_PREDICATE_SYNTAX], + ] as const; + for (const [carrier, alias, build] of CARRIERS) { + for (const [defect, value, rule] of DEFECTS) { + it(`${carrier} carrying a ${defect} is not judged; the same value under \`visibleWhen\` is`, () => { + expect(validateVisibilityPredicates(build(alias, value))).toEqual([]); + expect(validateVisibilityPredicates(build('visibleWhen', value)).map((f) => f.rule)).toEqual([rule]); + }); + } + } }); it('flags a `data.`-rooted predicate in a runtime view as mis-layered', () => { @@ -160,18 +109,6 @@ describe('validateVisibilityPredicates (ADR-0089 D3b)', () => { expect(findings[0].severity).toBe('warning'); }); - it('a `data.`-rooted predicate under `visibleOn` reports the mis-layer ALONE (#6318)', () => { - // Was: "reports BOTH alias + mis-layer". The alias half is retired; the - // surviving half — the mis-layer verdict is reached THROUGH the deprecated - // key — is what this now pins, as an exact one-element set. - const stack = { - views: [ - { name: 'task_form', sections: [{ visibleOn: "data.status == 'x'", fields: [] }] }, - ], - }; - expect(validateVisibilityPredicates(stack).map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]); - }); - it('does not confuse a field literally named `data` (e.g. `record.data`) for a data root', () => { const stack = { views: [ @@ -198,12 +135,11 @@ describe('validateVisibilityPredicates (ADR-0089 D3b)', () => { // The `groups` bucket has no other test in this file, so the half-fact this // used to carry — the traversal descends `groups` at all — had to survive // #6318's retirement of the alias-KEY rule that used to demonstrate it. It - // is re-demonstrated with a rule that still exists, and with the alias key - // kept on the fixture so the group walk and the alias-value read are pinned - // together exactly as before. + // is re-demonstrated with a rule that still exists (the section's key is the + // canonical one since the `visibleOn` alias retired, #23543 F4). const stack = { views: [ - { name: 'f', groups: [{ visibleOn: "data.a == 1", fields: [{ field: 'x', visibleWhen: 'approved' }] }] }, + { name: 'f', groups: [{ visibleWhen: "data.a == 1", fields: [{ field: 'x', visibleWhen: 'approved' }] }] }, ], }; const findings = validateVisibilityPredicates(stack); @@ -250,20 +186,16 @@ describe('validateVisibilityPredicates (ADR-0089 D3b)', () => { expect(validateVisibilityPredicates(stack, { layer: 'metadata' })).toEqual([]); }); - it('reads the value through a deprecated alias key on the metadata layer too (#6318)', () => { - // Was: "still flags a deprecated alias key in the metadata layer (alias - // check is layer-agnostic)". The alias-KEY rule is retired, so the - // layer-agnostic claim about it is gone. What survives — and is the half - // that ever mattered — is that the alias-VALUE read is layer-agnostic: - // the same key feeds the layer-directional root verdict in BOTH - // directions. Pinned bidirectionally on one fixture, exact sets both ways. + it('the layer-directional root verdict holds in BOTH directions on one predicate', () => { + // Was pinned through the deprecated `visibleOn` key (#6318), which retired + // at 18 (#23543 F4); the bidirectional property is the canonical key's. const stack = { - views: [{ name: 'f', sections: [{ visibleOn: "record.a == 1", fields: [] }] }], + views: [{ name: 'f', sections: [{ visibleWhen: "record.a == 1", fields: [] }] }], }; - // metadata layer forbids the runtime `record.` root → reported through the alias key… + // metadata layer forbids the runtime `record.` root… expect(validateVisibilityPredicates(stack, { layer: 'metadata' }).map((f) => f.rule)) .toEqual([VISIBILITY_ROOT_MISLAYERED]); - // …and the same alias-spelled predicate is correct on the runtime layer. + // …and the same predicate is correct on the runtime layer. expect(validateVisibilityPredicates(stack)).toEqual([]); }); @@ -476,22 +408,6 @@ describe('visibility-bare-identifier (#6128 / #5149 requirement 3)', () => { expect(bareFindings(stack).map((f) => f.path)).toEqual(['pages[0].regions[0].components[0]']); }); - it('reads the value through the deprecated `visibleOn` alias too (bare ALONE since #6318)', () => { - // Was an "alias + bare, both reported" pair. The alias half is retired; - // the half this test existed for — the gate reaches a bare identifier - // written under the deprecated key — is unchanged and is now the whole - // expected set. - const stack = { views: [{ name: 'f', sections: [{ visibleOn: "status == 'x'", fields: [] }] }] }; - expect(validateVisibilityPredicates(stack).map((f) => f.rule)).toEqual([VISIBILITY_BARE_IDENTIFIER]); - }); - - it('reads the value through the deprecated page-side `visibility` alias too', () => { - const stack = { - pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', visibility: 'shown' }] }] }], - }; - expect(bareFindings(stack)).toHaveLength(1); - }); - it('resolves a `{ dialect, source }` envelope the same as a bare string', () => { expect(bareFindings(formStack({ dialect: 'cel', source: "status == 'active'" }))).toHaveLength(1); }); @@ -1213,23 +1129,6 @@ describe('visibility-predicate-syntax (#6253)', () => { expect(findings[0].where).toBe('page "p"'); }); - it('reads the value through the deprecated `visibleOn` alias (syntax ALONE since #6318)', () => { - // Was an "alias + syntax, both reported" pair, whose point was that two - // INDEPENDENT defects on one element both report. The alias half is - // retired, so what is left is the syntax verdict reached through the - // deprecated key — and the independence claim it was making now belongs - // to the syntax/bare-ref exclusivity pin, which states it directly. - const stack = { views: [{ name: 'f', sections: [{ visibleOn: 'status === "x"', fields: [] }] }] }; - expect(validateVisibilityPredicates(stack).map((f) => f.rule)).toEqual([VISIBILITY_PREDICATE_SYNTAX]); - }); - - it('reads the value through the deprecated page-side `visibility` alias', () => { - const stack = { - pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', visibility: 'shown === true' }] }] }], - }; - expect(syntaxFindings(stack)).toHaveLength(1); - }); - it('resolves a `{ dialect, source }` envelope the same as a bare string', () => { expect(syntaxFindings(formStack({ dialect: 'cel', source: 'country === "USA"' }))).toHaveLength(1); }); @@ -1420,9 +1319,10 @@ describe('visibility-predicate-unknown-function (#13594)', () => { const page = { pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', visibleWhen: 'bogusFn(1)' }] }] }] }; expect(unknownFnFindings(page).map((f) => f.path)).toEqual(['pages[0].regions[0].components[0]']); - // The deprecated alias VALUE is still read (#6318 retired the KEY rule only). + // The retired alias is not read at all since 18 (#23543 F4): the parse + // refuses it before any door runs this rule. const alias = { views: [{ name: 'f', sections: [{ visibleOn: 'bogusFn(1)', fields: [] }] }] }; - expect(unknownFnFindings(alias)).toHaveLength(1); + expect(unknownFnFindings(alias)).toHaveLength(0); }); it('the verdict is layer-agnostic — the environment registers the same names on both', () => { diff --git a/packages/lint/src/validate-visibility-predicates.ts b/packages/lint/src/validate-visibility-predicates.ts index 51eec8dd5ff..6e44aeaad59 100644 --- a/packages/lint/src/validate-visibility-predicates.ts +++ b/packages/lint/src/validate-visibility-predicates.ts @@ -6,8 +6,9 @@ * ADR-0089 unifies the conditional-visibility predicate under the single * canonical key **`visibleWhen`** across data fields, view form sections/fields, * and page components. The deprecated spellings — `visibleOn` (view form) and - * `visibility` (page component) — stay accepted and are folded into `visibleWhen` - * before this file ever sees them. + * `visibility` (page component) — retired at 18 (#23543 F4): each is a spec + * tombstone, refused by name with the prescription naming `visibleWhen`, so no + * input this file is handed carries them (measured below, under "Input"). * * ## Why there is no alias-KEY rule here any more (#6318, retired) * @@ -31,18 +32,14 @@ * "Unrecognized key(s) on this view container: `sections`". A green unit test * over a fixture production can never send — the #4984 / #6251 signature. * - * **The surface is still guarded, and better.** The same D2 conversion shouts - * through `warnConversionNotice` in `defineStack`, with wording this rule never - * had — it names the site, the conversion, and the retirement window: - * - * ``` - * defineStack: views[0].form.sections[0].visibleWhen: 'visibleOn' -> 'visibleWhen' - * (converted at load; conversion 'view-visibleOn-to-visibleWhen', retires in protocol 16). - * Update the source to the canonical shape — the conversion stops running then. - * ``` + * **The surface is still guarded, and better.** Until 18 the same D2 + * conversion shouted through `warnConversionNotice` in `defineStack`, naming the + * site and the conversion; since the keys retired at 18 the parse itself refuses + * each by name, at its path, with the rename to `visibleWhen` (the spec + * tombstones on `FormFieldSchema`, `FormSectionSchema` and `PageComponentSchema`). * * So no working app lost a signal when the rule went: authors were never hearing - * this rule, and they do hear the conversion notice. Retired under ADR-0049 + * this rule, and they heard the conversion notice, now the refusal. Retired under ADR-0049 * (declared ≠ enforced) rather than re-anchored, because re-anchoring would have * had to change `runAuthoringRules`' external input contract — a `packages/lint` * public-API question that is the maintainer's to settle, not this file's. @@ -104,11 +101,9 @@ * `validate-expressions` and is not re-checked here. * * The predicate family is read off the schema, not guessed: `visibleWhen` is the - * canonical key on all three carriers (`FormFieldBaseSchema` `view.zod.ts:1416`, - * `FormSectionSchema` `view.zod.ts:1510`, `PageComponentSchema` - * `page.zod.ts:143`), `visibleOn` is the view-side deprecated alias - * (`view.zod.ts:1418` / `:1512`) and `visibility` the page-side one - * (`page.zod.ts:145`). There is no fourth spelling on this surface. + * canonical key on all three carriers (`FormFieldBaseSchema`, `FormSectionSchema` + * and `PageComponentSchema`), and the only one: the view-side `visibleOn` and the + * page-side `visibility` are retired tombstones since 18 (#23543 F4). * * ## Syntax — the one surface where "don't judge syntax" meant nobody judged it * @@ -547,10 +542,22 @@ export interface VisibilityFinding { type AnyRec = Record; /** - * The canonical predicate key (ADR-0089). The two deprecated spellings - * (`visibleOn` / `visibility`) are read inline in `checkElement`, canonical-first - * — the `ALIASES` list that used to sit here existed only to iterate the retired - * alias-KEY rule, and went with it (#6318). + * The canonical predicate key (ADR-0089), and the one read. + * + * ## Input — why the retired spellings are not read (#23543 F4) + * + * `checkElement` used to read `visibleWhen ?? visibleOn ?? visibility`, for a + * caller handing this export a raw authored object. Both aliases retired at 18 + * as spec tombstones. The doors that PARSE first — `os validate`, `os build`, + * `os verify` (`judgeAuthorTimeRules`), `defineStack`, and the metadata save + * door, which parses the `view` body (a 422 on refusal) before its runtime rule + * pass — refuse either key by name, so a body they hand this rule cannot carry + * one. `os lint` is the one door that hands this rule an UNPARSED stack (its + * `'lint'` run reads the normalized stack on both tiers): there a predicate + * written under a retired key is nobody's verdict, by design — the parsing + * doors refuse the key itself, and `os lint` reports no refused shape. Reading + * the limbs would only judge the value of a key the spec refuses — the + * consumer-side alias tolerance the retirement ends. */ const CANONICAL = 'visibleWhen'; @@ -1022,17 +1029,9 @@ function checkElement( // (1) mis-layered binding root — check the effective predicate (canonical wins) // against the root expected for this layer. // - // The two alias limbs STAY, and are not the thing #6318 retired. That issue is - // about the alias KEY as a verdict; this is the predicate VALUE, and the value - // is what all three surviving rules judge. Through the three CLI commands the - // limbs are already unreachable (the D2 fold renamed the key one layer up), but - // `validateVisibilityPredicates` is a PUBLISHED export that a caller — this - // package's own unit tests included — may hand a raw authored object, and on - // that door an alias-spelled predicate must still be judged rather than skipped - // silently. Ordering is canonical-first per ADR-0089, so an alias can never - // override a `visibleWhen` that is present: the limbs can only ever add - // coverage, never change a verdict. - const raw = el[CANONICAL] ?? el.visibleOn ?? el.visibility; + // Canonical key only: the alias limbs went with the retired keys (#23543 F4, + // see `CANONICAL`). + const raw = el[CANONICAL]; const source = predicateSource(raw); const rule = MISLAYER_BY_LAYER[layer]; if (source && usesRoot(source, rule.forbiddenRoot)) { diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index b476e28c58d..9746e660190 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -441,23 +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-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.) + * own window. `flow-node-http-callout-rename` 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, then + * `page-component-visibility-to-visibleWhen` until that key retired too, + * #23543 F4. The callout rename is the last live entry, and the family that + * retires it inherits this control.) */ 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', regions: [{ name: 'main', components: [header] }] }], + flows: [{ name: 'notify', nodes: [{ id: 'n1', type: 'http_request', config: { url: 'https://example.com' } }] }], }; const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); expect(result.verdict).toBe('converted-forward'); - 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); + expect(result.notices.map((n) => n.conversionId)).toContain('flow-node-http-callout-rename'); + const converted = (result.definition as { flows: { nodes: Record[] }[] }).flows[0]!.nodes[0]!; + expect(converted.type).toBe('http'); // …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-core/src/form-predicate-root-policy.test.ts b/packages/metadata-core/src/form-predicate-root-policy.test.ts index ef592f0df9a..3a772544dc8 100644 --- a/packages/metadata-core/src/form-predicate-root-policy.test.ts +++ b/packages/metadata-core/src/form-predicate-root-policy.test.ts @@ -19,6 +19,7 @@ import { detectUnboundFormViewPredicateRoots, unboundRootsInCelSource, } from './form-predicate-root-policy.js'; +import { applyArtifactForwardConversions } from './artifact-forward-conversion.js'; /** The repro artifact's shape: one form view, one section, one gated field. */ function definitionWithFieldPredicate(predicate: unknown, object = 'crm_lead'): unknown { @@ -79,14 +80,15 @@ const CURRENT_USER_FAMILY_ROOTS = ['current_user', 'user', 'ctx', 'os'] as const * The `visibleWhen` contract sentence for one form-view surface, read LIVE out * of `@objectstack/spec` instead of copied into a comment in this file. * - * `FormFieldSchema` / `FormSectionSchema` are `strictObject(...).transform(...)` - * pipes (ADR-0089 D3a), so the authored `.describe()` text hangs off the pipe's - * INPUT shape, reached through zod 4's public `.in`. The guard is the point of - * the helper: if that accessor path ever moves, this file must go RED rather - * than hand every assertion below an `undefined` that quietly matches nothing. + * `FormFieldSchema` / `FormSectionSchema` are plain closed objects since their + * `visibleOn` fold (a `.transform` pipe) retired with the key (#23543 F4), so + * the authored `.describe()` text hangs off their own `.shape`. The guard is + * the point of the helper: if that accessor path ever moves, this file must go + * RED rather than hand every assertion below an `undefined` that quietly + * matches nothing. */ function visibleWhenContract(schema: unknown, label: string): string { - const shape = (schema as { in?: { shape?: Record } }).in?.shape; + const shape = (schema as { shape?: Record }).shape; const slot = shape?.visibleWhen as { description?: unknown } | undefined; const prose = slot?.description; expect( @@ -343,19 +345,26 @@ describe('detectUnboundFormViewPredicateRoots — traversal', () => { ).toEqual([]); }); - it('reads the bare-string shorthand and the deprecated `visibleOn` alias', () => { - // Both reach this scan because it runs BEFORE the parse that normalizes them. + it('reads the bare-string shorthand', () => { + // It reaches this scan because the scan runs BEFORE the parse that normalizes it. expect(detectUnboundFormViewPredicateRoots( definitionWithFieldPredicate('status == "unqualified"'), )).toHaveLength(1); + }); + it('a legacy artifact\'s retired `visibleOn` is renamed by the forward conversions before this scan reads it (#23543 F4)', () => { const withAlias: any = definitionWithFieldPredicate(CEL('record.x')); const field = withAlias.views[0].form.sections[0].fields[1]; delete field.visibleWhen; field.visibleOn = CEL('status == "unqualified"'); - const findings = detectUnboundFormViewPredicateRoots(withAlias); - expect(findings).toHaveLength(1); - expect(findings[0]!.path).toBe('views[0].form.sections[0].fields[1].visibleOn'); + // The caller's order: forward-convert first (the 17-era window opens and + // replays the retired `view-visibleOn-to-visibleWhen`), then detect. + const forwarded = applyArtifactForwardConversions(withAlias, { runtimeSpecVersion: '18.0.0' }); + expect(forwarded.notices.map((n) => n.conversionId)).toContain('view-visibleOn-to-visibleWhen'); + const findings = detectUnboundFormViewPredicateRoots(forwarded.definition); + expect(findings.map((f) => f.path)).toEqual(['views[0].form.sections[0].fields[1].visibleWhen']); + // CONTROL — the scan itself reads only the canonical key now. + expect(detectUnboundFormViewPredicateRoots(withAlias)).toEqual([]); }); it('passes an opaque predicate: AST-only, and any non-CEL dialect', () => { diff --git a/packages/metadata-core/src/form-predicate-root-policy.ts b/packages/metadata-core/src/form-predicate-root-policy.ts index ce90e5aab5f..f437755e366 100644 --- a/packages/metadata-core/src/form-predicate-root-policy.ts +++ b/packages/metadata-core/src/form-predicate-root-policy.ts @@ -249,10 +249,16 @@ const CEL_RESERVED_WORDS: ReadonlySet = new Set([ 'let', 'loop', 'package', 'namespace', 'return', 'var', 'void', 'while', ]); -/** The predicate keys a form view carries. `visibleOn` is the ADR-0089 alias, - * folded onto `visibleWhen` AT PARSE — so a raw artifact, which reaches this - * scan before any parse, can still spell either. */ -const FORM_PREDICATE_KEYS: readonly string[] = ['visibleWhen', 'visibleOn']; +/** The predicate keys a form view carries. + * + * The retired ADR-0089 alias `visibleOn` is not read (#23543 F4). The one + * caller runs this scan on the artifact's definition AFTER + * `applyArtifactForwardConversions`, and only inside a window that call + * opened — a replay of the whole chain, retired entries included, so a + * legacy artifact's `visibleOn` has already been renamed by + * `view-visibleOn-to-visibleWhen` when it arrives here; an artifact outside + * every window answers to the strict parse, which refuses the key. */ +const FORM_PREDICATE_KEYS: readonly string[] = ['visibleWhen']; /** * Root identifiers named by one CEL source, minus every bound root, reserved @@ -383,8 +389,8 @@ function scanFormSection( /** * One form view. Sections live under `sections`, or under the legacy `groups` - * alias that the parse folds onto `sections` — both are read here for the same - * reason `visibleOn` is: this scan runs BEFORE the parse. + * alias that the parse folds onto `sections` — both are read here because this + * scan runs BEFORE the parse (the forward conversions leave `groups` alone). */ function scanFormView( formView: unknown, diff --git a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts index 609ae72fef4..51c3ec92e86 100644 --- a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts +++ b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts @@ -616,16 +616,17 @@ describe('[#20051] stage (iv): a saved view stores the parsed value of every key expect(stored.filter).toEqual([{ field: 'status', operator: 'not_equals', value: 'done' }]); }); - it('a key moved inside a moved key is stored under its canonical spelling, with no default beside it', async () => { - // Two levels of key move: `groups` → `sections`, and within each section - // and field `visibleOn` → `visibleWhen`. Neither graft covered the - // second; the projection finds both by re-parsing, so the stored row - // keeps the predicates and drops the section / field defaults. + it('a key normalised inside a moved key is stored with its parsed value, with no default beside it', async () => { + // `groups` → `sections` moves the bucket, and inside it each section and + // field predicate normalises to its envelope. (Until 18 the inner level + // was a key move too — `visibleOn` → `visibleWhen` — which retired with + // the key, #23543 F4.) The stored row keeps the parsed predicates and + // drops the section / field defaults. const stored = await storedViewBody('contact_us', flatForm({ groups: [{ label: 'About you', - visibleOn: 'record.stage == "open"', - fields: ['name', { field: 'email', visibleOn: 'record.opted_in == true' }], + visibleWhen: 'record.stage == "open"', + fields: ['name', { field: 'email', visibleWhen: 'record.opted_in == true' }], }], })); expect(stored).not.toHaveProperty('groups'); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index aa4b0557dee..923ed0bb77a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1873,8 +1873,9 @@ export function graftFoldedFormSections(authored: unknown, parsed: unknown): unk /** * How many verify-and-restore rounds {@link projectStorableViewBody} may take. * One round per level of key-moved nesting is what convergence costs: a - * `groups` → `sections` fold whose sections carry a `visibleOn` → - * `visibleWhen` fold is two levels. A `view` body is a bounded document, so a + * `groups` → `sections` fold whose sections carried a moved key of their own + * (the `visibleOn` → `visibleWhen` fold, until that alias retired at 18) is + * two levels. A `view` body is a bounded document, so a * body still diverging after this many rounds is not converging at all. */ const STORABLE_VIEW_MAX_ROUNDS = 8; @@ -1937,7 +1938,7 @@ function soleMovedFrom(dropped: readonly string[], authored: Record { /** * The family closed at 18 (#23487): every entry whose window ran out * before 18 has a verdict. Seven left the load path (with #23421's two, - * nine of the family); the eight below stay, each with the reason written - * into its own docblock and what would have to change for it to retire. + * nine of the family); eight stayed, each with the reason written into its + * own docblock and what would have to change for it to retire. The + * #23543 rulings retired seven of those eight (F1–F4, listed below); the + * one still kept retires with F5-http. * A new entry below 18 that is neither retired nor listed here is an * entry nobody judged. */ @@ -402,8 +404,6 @@ describe('conversion layer (ADR-0087 D2)', () => { const kept = ALL_CONVERSIONS.filter((c) => c.toMajor < 18 && !c.retiredFromLoadPath).map((c) => c.id).sort(); expect(kept).toEqual([ 'flow-node-http-callout-rename', - 'page-component-visibility-to-visibleWhen', - 'view-visibleOn-to-visibleWhen', ]); for (const id of [ 'flow-node-crud-filter-alias', @@ -424,6 +424,10 @@ describe('conversion layer (ADR-0087 D2)', () => { // #23543 F2: retired once the job parse refused a `retryPolicy` block // whose `maxRetries` is omitted or 0. 'retry-policy-converged', + // #23543 F4: retired once the view form's `visibleOn` and the page + // component's `visibility` were themselves retired as keys. + 'view-visibleOn-to-visibleWhen', + 'page-component-visibility-to-visibleWhen', ]) { expect(ALL_CONVERSIONS.find((c) => c.id === id), id).toMatchObject({ retiredFromLoadPath: true, retiredAfter: '17.7.0' }); } @@ -455,20 +459,16 @@ describe('conversion layer (ADR-0087 D2)', () => { expect(issues[0]!.message).toContain('Use `objectName`'); }); - it('a live-window conversion still applies at load (protocol-15 aliases)', () => { + // Re-pointed by #23543 F4, whose two protocol-15 entries were this case's + // subject: the callout rename is the one entry still live, and F5-http, + // which retires it next, inherits this case with its two siblings above. + it('a live-window conversion still applies at load (the protocol-11 callout rename)', () => { const { stack, notices } = collectConversionNotices({ - pages: [ - { - name: 'p', - regions: [{ name: 'main', components: [{ type: 'record:list', visibility: 'true' }] }], - }, - ], + flows: [{ name: 'f', nodes: [{ id: 'n1', type: 'http_request', config: { url: 'https://example.com' } }] }], }); - const component = (stack.pages as any[])[0].regions[0].components[0]; - expect(component.visibleWhen).toBe('true'); - expect(component.visibility).toBeUndefined(); + expect((stack.flows as any[])[0].nodes[0].type).toBe('http'); expect(notices).toHaveLength(1); - expect(notices[0]!.conversionId).toBe('page-component-visibility-to-visibleWhen'); + expect(notices[0]!.conversionId).toBe('flow-node-http-callout-rename'); }); }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index bdae19d849f..7ab536dc825 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -507,21 +507,31 @@ function renameVisibilityAlias( * `visibleWhen` across all layers. Applies to form sections and (recursively * nested) form fields in every FORM payload {@link mapViewPayloads} reaches — * `views[].form` / `views[].formViews.*`, a ViewItem record's `config`, and a - * flattened form overlay's top level (commit b799ac553). **Live window**: the protocol-15 loader accepts the deprecated - * key (the zod schemas also normalize it at parse — this entry makes the - * acceptance *declared, loud, and expiring* per ADR-0087 D2, and will - * graduate into the step-16 chain when the alias is removed). - * - * **Stays on the load path at 18 — a recorded verdict (#23487).** `visibleOn` is - * still a declared `[DEPRECATED]` key on the form field and section schemas - * (`view.zod.ts`), which fold it into `visibleWhen` at parse, so retiring this - * entry alone changes nothing an author can observe: the old spelling would - * still be accepted without a word. It retires with the alias itself, once - * those schemas tombstone `visibleOn` (`retiredKey`, naming `visibleWhen`). + * flattened form overlay's top level (commit b799ac553). + * + * ## Retired from the load path at 18 (#23543, ruling F4 A) + * + * Through 17 it stayed live because `visibleOn` was still a declared + * `[DEPRECATED]` key on the form field and section schemas, which folded it + * into `visibleWhen` at parse, so retired alone it would have left the old + * spelling silently accepted (the verdict #23487 recorded). The ruling retired + * the key: both shapes carry a `retiredKey()` tombstone naming `visibleWhen` + * (`FORM_FIELD_VISIBLE_ON_RETIRED` / `FORM_SECTION_VISIBLE_ON_RETIRED`, + * `ui/view.zod.ts`), and the fold went with it. So the authoring funnel + * (`defineStack`, `os validate` / `os build` / `os lint`) no longer replays + * this entry, and an author is taught the canonical key instead of being + * rewritten. Stored form views are the `includeRetired` seams: stored-row + * rehydration (`applyConversionsToStoredItem`) still renames a `sys_metadata` + * view row's `visibleOn` before anything parses it, and so does the + * artifact-ingestion door inside its window. `os migrate meta` does not reach + * it: protocol 15 is below `MIGRATION_SUPPORT_FLOOR`. The family's D3 entry, + * `ui-visibility-alias-keys-retired`, states the edit for authored sources. */ const viewVisibleOnToVisibleWhen: MetadataConversion = { id: 'view-visibleOn-to-visibleWhen', toMajor: 15, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'view.form.visibleOn', summary: "view form section/field key 'visibleOn' → 'visibleWhen' (ADR-0089)", apply(stack, emit) { @@ -615,19 +625,24 @@ const viewVisibleOnToVisibleWhen: MetadataConversion = { * The page-component spelling of the same predicate. Applies to every page * component {@link mapPageComponents} reaches — `regions[].components[]`, * `slots.`, and the containers a component nests under its `properties` - * (#6776, #6775). **Live window**, same terms as - * {@link viewVisibleOnToVisibleWhen}. (An AI agent's `visibility` property is - * a different, unrelated surface and is not touched.) - * - * **Stays on the load path at 18 — a recorded verdict (#23487).** `visibility` is - * still a declared `[DEPRECATED]` key on `PageComponentSchema` (`page.zod.ts`), - * folded into `visibleWhen` at parse, so retiring this entry alone would leave - * the old spelling silently accepted. It retires with the alias itself, once - * that schema tombstones `visibility` (`retiredKey`, naming `visibleWhen`). + * (#6776, #6775). (An AI agent's `visibility` property is a different, + * unrelated surface and is not touched.) + * + * ## Retired from the load path at 18 (#23543, ruling F4 A) + * + * Same terms as {@link viewVisibleOnToVisibleWhen}, retired in the same change: + * `PageComponentSchema.visibility` is a `retiredKey()` tombstone naming + * `visibleWhen` (`ui/page.zod.ts`) and its parse-time fold is gone, so the + * authoring funnel no longer replays this entry. Stored pages are the + * `includeRetired` seams (stored-row rehydration, the artifact-ingestion door + * inside its window); `os migrate meta` does not reach it, protocol 15 being + * below `MIGRATION_SUPPORT_FLOOR`. */ const pageComponentVisibilityToVisibleWhen: MetadataConversion = { id: 'page-component-visibility-to-visibleWhen', toMajor: 15, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'page.component.visibility', summary: "page component key 'visibility' → 'visibleWhen' (ADR-0089)", apply(stack, emit) { diff --git a/packages/spec/src/conversions/view-spelling-walk.test.ts b/packages/spec/src/conversions/view-spelling-walk.test.ts index 11cbb8844b2..92a1d4bf19a 100644 --- a/packages/spec/src/conversions/view-spelling-walk.test.ts +++ b/packages/spec/src/conversions/view-spelling-walk.test.ts @@ -29,6 +29,7 @@ import { describe, expect, it } from 'vitest'; +import { ALL_CONVERSIONS } from './registry.js'; import { applyConversionsToStoredItem } from './stored.js'; import type { ConversionNotice } from './types.js'; @@ -71,6 +72,15 @@ const formSpellings = (payload: Dict) => ({ }); describe('view conversions reach the ViewItem-record spelling ({ viewKind, config })', () => { + // #23543 F4: `view-visibleOn-to-visibleWhen` left the load path at 18 (the + // key is a tombstone now). This seam replays retired entries, so the + // `visibleOn` cases below are what keep a stored row written before 18 + // loading — in every spelling. + it('the `visibleOn` rename is retired from the load path, and this seam still replays it', () => { + expect(ALL_CONVERSIONS.find((c) => c.id === 'view-visibleOn-to-visibleWhen')) + .toMatchObject({ retiredFromLoadPath: true, retiredAfter: '17.7.0' }); + }); + it('renames `visibleOn` inside a record `config` exactly as inside a container `form`', () => { const payload = { type: 'simple', diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__FormField__visibleOn.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormField__visibleOn.ts new file mode 100644 index 00000000000..b3b2adf6b4d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormField__visibleOn.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #23543 F4 — ADR-0089 D2, by the maintainer's ruling F4 A on #23543 (record +// 6182001882). `visibleOn` was the form view's first spelling of the +// conditional-visibility predicate; ADR-0089 unified it under `visibleWhen`, +// and through 17 the field schema declared it as a deprecated alias that a +// `.transform` folded onto `visibleWhen` at parse, so the alias was accepted +// without a word. The key retires: a `retiredKey()` tombstone naming +// `visibleWhen`, the fold gone with it. Zero producers measured in this repo's +// examples and apps before the ruling. +// +// Stored rows keep loading through the retired D2 conversion +// `view-visibleOn-to-visibleWhen` (protocol 15), which every stored-row +// rehydration seam replays; the D3 record is `ui-visibility-alias-keys-retired`. +export const entry = 'ui/FormField:visibleOn'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__FormSection__visibleOn.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormSection__visibleOn.ts new file mode 100644 index 00000000000..a1455701fe2 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormSection__visibleOn.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #23543 F4 — the section half of `ui/FormField:visibleOn`, retired in the same +// change for the same reason: the form section's deprecated spelling of +// `visibleWhen` (ADR-0089 D2), folded at parse through 17, now a `retiredKey()` +// tombstone naming `visibleWhen`. Stored rows: `view-visibleOn-to-visibleWhen`; +// D3: `ui-visibility-alias-keys-retired`. +export const entry = 'ui/FormSection:visibleOn'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__PageComponent__visibility.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__PageComponent__visibility.ts new file mode 100644 index 00000000000..b1ff3c168c2 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__PageComponent__visibility.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #23543 F4 — ADR-0089 D2, by the maintainer's ruling F4 A on #23543 (record +// 6182001882), retired together with the view form's `visibleOn`. `visibility` +// was the page component's deprecated spelling of `visibleWhen`, folded onto +// it at parse through 17; it is a `retiredKey()` tombstone naming `visibleWhen` +// now. (An AI agent's `visibility`, retired earlier, is a different surface.) +// Stored rows keep loading through the retired D2 conversion +// `page-component-visibility-to-visibleWhen` (protocol 15); D3: +// `ui-visibility-alias-keys-retired`. +export const entry = 'ui/PageComponent:visibility'; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-visibility-alias-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.ui-visibility-alias-keys-retired.ts new file mode 100644 index 00000000000..cd9b49ce828 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-visibility-alias-keys-retired.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #23543 F4 — the key retirement's one D3 entry, for both keys (ruling F4 A: +// they retire in the same change). The D2 halves are +// `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` +// (protocol 15), now retired from the load path. It carries no `conversionIds`: +// protocol 15 is below MIGRATION_SUPPORT_FLOOR, so no step of the chain replays +// either conversion, and `os migrate meta` lists this entry as the hand edit it is. +export const entry: SemanticMigration = { + id: 'ui-visibility-alias-keys-retired', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + 'view form visibleOn (view.form.sections[].visibleOn, view.form.sections[].fields[].visibleOn) ' + + 'and page component visibility (page.components[].visibility)', + replacement: + '`visibleWhen` — the one conditional-visibility key (ADR-0089) on a form field, a form section and a ' + + 'page component; the value, a CEL predicate, is unchanged', + reason: + 'A maintainer ruling retired the two deprecated spellings of the conditional-visibility predicate ' + + 'at 18. Through 17 the form field and form section schemas declared `visibleOn`, and the page ' + + 'component schema declared `visibility`, and each parse folded the old key onto `visibleWhen` ' + + 'without a word. At 18 each is a tombstone: `tsc` refuses a typed authoring site, and the parse ' + + 'refuses the key by name with the prescription naming `visibleWhen` at `objectstack validate`, ' + + '`objectstack build`, the metadata save door (a 422) and `defineStack`. Where a node wrote both ' + + 'keys, `visibleWhen` was the one in effect: delete the old key. No tool makes the rename for an ' + + 'authored source: both D2 conversions belong to protocol 15, below the migration floor, so ' + + '`objectstack migrate meta --from 17` applies no edit for either key and `--write` writes none; ' + + 'it prints each written key among the refusals the migrated stack still has, and lists this entry. ' + + 'Rename each key by hand. Stored form views and pages need no edit: every stored-row rehydration ' + + 'seam replays the retired conversions, so a `sys_metadata` row written with the old key keeps ' + + 'loading with `visibleWhen`. A script or integration that saves a view or a page through the ' + + 'metadata API with the old key is refused and needs the same rename.', + acceptanceCriteria: + '`objectstack validate` reports no refusal at a `visibleOn` path in a form view or a `visibility` ' + + 'path on a page component, no view or page source in the project, and no script that writes views ' + + 'or pages through the metadata API, writes either key, and each renamed predicate shows and hides ' + + 'the same field, section or component as before.', +}; diff --git a/packages/spec/src/shared/editability-boundary.test.ts b/packages/spec/src/shared/editability-boundary.test.ts index 0e0d293055e..608840d8c3a 100644 --- a/packages/spec/src/shared/editability-boundary.test.ts +++ b/packages/spec/src/shared/editability-boundary.test.ts @@ -235,18 +235,18 @@ describe('no acceptance change', () => { collapsible: true, collapsed: false, columns: 2, - visibleOn: 'record.type == "customer"', + visibleWhen: 'record.type == "customer"', fields: ['amount', { field: 'currency', readonly: true }], }; const r = FormSectionSchema.safeParse(authored); expect(r.success).toBe(true); - // The ADR-0089 fold still runs, and the field-level `readonly` inside is - // still the accepted way to say what `disabled` on the section cannot. - // `ExpressionInputSchema` normalizes the authored string to a - // `{ dialect, source }` pair; the fold is about WHICH KEY carries it. + // The field-level `readonly` inside is still the accepted way to say what + // `disabled` on the section cannot. `ExpressionInputSchema` normalizes the + // authored string to a `{ dialect, source }` pair. (Written with the + // canonical key since the `visibleOn` alias retired, #23543 F4.) expect((r.data as { visibleWhen?: { source?: string } }).visibleWhen?.source) .toBe('record.type == "customer"'); - expect((r.data as { visibleOn?: unknown }).visibleOn).toBeUndefined(); + expect(r.data).not.toHaveProperty('visibleOn'); }); it('a representative page component that parsed before still parses, unchanged in output', () => { @@ -256,13 +256,13 @@ describe('no acceptance change', () => { label: 'Details', properties: { columns: 2 }, className: 'p-4', - visibility: 'current_user.is_admin', + visibleWhen: 'current_user.is_admin', }; const r = PageComponentSchema.safeParse(authored); expect(r.success).toBe(true); expect((r.data as { visibleWhen?: { source?: string } }).visibleWhen?.source) .toBe('current_user.is_admin'); - expect((r.data as { visibility?: unknown }).visibility).toBeUndefined(); + expect(r.data).not.toHaveProperty('visibility'); }); it.each(VISIBILITY_ONLY)('%s keeps its bare message for a key in no family at all', (_n, schema, base) => { diff --git a/packages/spec/src/shared/evaluated-slot-population.test.ts b/packages/spec/src/shared/evaluated-slot-population.test.ts index eac0aa424cc..af98ad42cf3 100644 --- a/packages/spec/src/shared/evaluated-slot-population.test.ts +++ b/packages/spec/src/shared/evaluated-slot-population.test.ts @@ -191,9 +191,9 @@ function element(schema: unknown, ...path: string[]): z.ZodType { } /** - * The 34 declaring positions, keyed the way the ADR-0058 D7 conformance ledger + * The 31 declaring positions, keyed the way the ADR-0058 D7 conformance ledger * keys them (`file:Schema.field`). Re-derived by identity on this tree, ⛔ not - * inherited from the card: 32 declaring source lines, two of which are + * inherited from the card: 29 declaring source lines, two of which are * file-local alias consts mounting two slots each. * * [#18118] It was 36 over 34 lines. Two union members left with the CEL arms @@ -202,6 +202,12 @@ function element(schema: unknown, ...path: string[]): z.ZodType { * under ADR-0049 enforce-or-remove because nothing evaluated either. They are * named here rather than silently absent: a position that leaves this table * with no record is the #17630 failure in another costume. + * + * [#23543 F4] It was 34 over 32 lines. Three alias positions left with the + * ADR-0089 aliases they declared — `ui/view.zod.ts:FormFieldBaseSchema.visibleOn`, + * `ui/view.zod.ts:FormSectionSchema.visibleOn` and + * `ui/page.zod.ts:PageComponentSchema.visibility` — retired at 18 as + * `retiredKey()` tombstones naming `visibleWhen`, whose own positions stay. */ const POSITIONS: ReadonlyArray z.ZodType]> = [ ['data/field.zod.ts:FieldSchema.expression', () => slot(FieldSchema, 'expression')], @@ -231,7 +237,6 @@ const POSITIONS: ReadonlyArray z.ZodType]> = [ ['ui/component.zod.ts:PageTabsProps.visibleWhen', () => slot(element(PageTabsProps, 'items'), 'visibleWhen')], ['ui/component.zod.ts:RecordAlertProps.visible', () => slot(RecordAlertProps, 'visible')], ['ui/page.zod.ts:PageComponentSchema.visibleWhen', () => slot(PageComponentSchema, 'visibleWhen')], - ['ui/page.zod.ts:PageComponentSchema.visibility', () => slot(PageComponentSchema, 'visibility')], // [#22228] The conditional formatting rule element moved out of // `ListViewSchema` into its own module, so the field definition can mount the // same element (`FieldSchema.conditionalFormatting`). One declaring position, @@ -240,19 +245,17 @@ const POSITIONS: ReadonlyArray z.ZodType]> = [ // (`data/field-conditional-formatting.test.ts`). ['shared/conditional-formatting.zod.ts:ConditionalFormattingRuleSchema.condition', () => slot(element(ListViewSchema, 'conditionalFormatting'), 'condition')], ['ui/view.zod.ts:FormFieldBaseSchema.visibleWhen', () => slot(FormFieldSchema, 'visibleWhen')], - ['ui/view.zod.ts:FormFieldBaseSchema.visibleOn', () => slot(FormFieldSchema, 'visibleOn')], ['ui/view.zod.ts:FormSectionSchema.visibleWhen', () => slot(FormSectionSchema, 'visibleWhen')], - ['ui/view.zod.ts:FormSectionSchema.visibleOn', () => slot(FormSectionSchema, 'visibleOn')], // The two the file-local alias `SettingsVisibilityInputSchema` mounts. ['system/settings-manifest.zod.ts:SpecifierSchema.visible', () => slot(SpecifierSchema, 'visible')], ['system/settings-manifest.zod.ts:SettingsManifestSchema.visible', () => slot(SettingsManifestSchema, 'visible')], ]; describe('every evaluated slot refuses the two shapes no engine can run', () => { - it('reaches exactly the 34 declaring positions the census enumerated', () => { + it('reaches exactly the 31 declaring positions the census enumerated', () => { // A position that stops being reachable must red here rather than fall out // of the table: that silent drop is the #17630 failure in another costume. - expect(POSITIONS.length).toBe(34); + expect(POSITIONS.length).toBe(31); for (const [key, get] of POSITIONS) { expect(() => get(), `unreachable: ${key}`).not.toThrow(); } @@ -293,7 +296,7 @@ describe('every evaluated slot refuses the two shapes no engine can run', () => }); it('CONTROL — the persistence contract is NOT narrowed and still accepts both shapes', () => { - // This is what makes the 34 `false`s above a reading. Ruling item 2: + // This is what makes the 31 `false`s above a reading. Ruling item 2: // `ExpressionSchema` / `ExpressionInputSchema` keep `source` OR `ast`. for (const schema of [ExpressionSchema, ExpressionInputSchema, PredicateInputSchema]) { expect(schema.safeParse(AST_ONLY).success).toBe(true); @@ -301,7 +304,7 @@ describe('every evaluated slot refuses the two shapes no engine can run', () => } }); - it('CONTROL — a healthy predicate still parses at every one of the 34 positions', () => { + it('CONTROL — a healthy predicate still parses at every one of the 31 positions', () => { // The narrowing removes accepted shapes and adds none. The settings-manifest // pair speaks its own closed non-CEL grammar (#7169), so it gets the // predicate that grammar accepts; every other slot gets CEL. 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 064dfb14742..68acb129805 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,7 +89,8 @@ 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 { PageComponentSchema, PageSchema } from '../ui/page.zod'; +import { FormFieldSchema, FormSectionSchema } from '../ui/view.zod'; import { FlowSchema } from '../automation/flow.zod'; import { JobSchema } from '../system/job.zod'; import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; @@ -372,6 +373,13 @@ const JOIN: Readonly> = { 'retryPolicy', ), ], + // #23543 F4: two protocol-15 renames retired with their keys — the three + // tombstones naming `visibleWhen`, at each declared position. + 'view-visibleOn-to-visibleWhen': [ + tombstone(FormFieldSchema, 'view.form.sections[].fields[]', 'visibleOn'), + tombstone(FormSectionSchema, 'view.form.sections[]', 'visibleOn'), + ], + 'page-component-visibility-to-visibleWhen': [tombstone(PageComponentSchema, 'page.components[]', 'visibility')], // 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/shared/visibility-strict-options.ts b/packages/spec/src/shared/visibility-strict-options.ts index 5072b1d3297..6ccf375d9d9 100644 --- a/packages/spec/src/shared/visibility-strict-options.ts +++ b/packages/spec/src/shared/visibility-strict-options.ts @@ -40,8 +40,8 @@ const VISIBILITY_KEY_PATTERN = /vis|conceal|hidden|show.?when/i; * The shared `strictObject` options for every `.strict()` view/page schema that * carries a conditional-visibility predicate (ADR-0089 D3a). * - * With the shape closed, a key these schemas do not declare — a stale - * `visibleOn` past removal, a `visibleWhen` typo, or a wrong-layer paste — is a + * With the shape closed, a key these schemas do not declare — a `visibility` + * pasted onto a form field, a `visibleWhen` typo, or a wrong-layer paste — is a * **loud parse error** instead of a silent strip (ADR-0049 enforce-or-remove, * ADR-0078 no-silently-inert). The rejection is *fixable*: it names the * offending key(s), points a visibility-shaped key at the canonical @@ -50,7 +50,6 @@ const VISIBILITY_KEY_PATTERN = /vis|conceal|hidden|show.?when/i; * * ```ts * strictObject(VISIBILITY_STRICT_OPTIONS, { ..., visibleWhen: Expr.optional() }) - * .transform(normalizeVisibleWhen) * ``` * * ## Folded out of a hand-written `$ZodErrorMap` (#6619, #6416 direction 2) @@ -104,10 +103,12 @@ export const VISIBILITY_STRICT_OPTIONS: StrictObjectOptions = { // matches and that none is a key the shape declares, which is the // answerable half of the dead-entry claim for an open family. examples: ['visibleWhenn', 'visibleIf', 'hiddenWhen', 'conceal', 'showWhen'], + // The deprecated `visibleOn` (view form) and `visibility` (page + // component) are no longer named here as accepted: both retired at 18 + // (#23543 F4) and their own tombstones answer them where they were declared. prescription: 'If this is the conditional-visibility predicate, the canonical key is ' - + '`visibleWhen` (ADR-0089) — `visibleOn` (view form) and `visibility` (page ' - + 'component) are still accepted as deprecated aliases.', + + '`visibleWhen` (ADR-0089).', }, ], }; diff --git a/packages/spec/src/shared/visibility.ts b/packages/spec/src/shared/visibility.ts index dd89ba74370..451ea16085a 100644 --- a/packages/spec/src/shared/visibility.ts +++ b/packages/spec/src/shared/visibility.ts @@ -14,17 +14,22 @@ * * ADR-0089 makes **`visibleWhen`** the single canonical key across all layers * (aligning with the `readonlyWhen` / `requiredWhen` family and the resolved - * `conditionalRequired → requiredWhen` precedent). `visibleOn` and `visibility` - * stay accepted as `@deprecated` aliases and are folded into `visibleWhen` - * **once, at the schema boundary** (a zod `.transform()`), so no renderer or - * validator has to re-implement the fallback. + * `conditionalRequired → requiredWhen` precedent). Through 17, `visibleOn` and + * `visibility` stayed accepted as `@deprecated` aliases, folded into + * `visibleWhen` at the schema boundary by {@link normalizeVisibleWhen}. At 18 + * both RETIRED (#23543 F4): each is a `retiredKey()` tombstone naming + * `visibleWhen`, and no schema in this package calls the fold any more. A + * stored pre-18 document is renamed by the retired ADR-0087 D2 conversions + * (`view-visibleOn-to-visibleWhen`, `page-component-visibility-to-visibleWhen`) + * that every stored-row rehydration seam replays. The two exports below remain + * published, with no caller in this repository. * * The *binding root* is still determined by the layer — runtime record surfaces * bind `record` + `current_user`; metadata-editing forms bind `data` — this * unifies the *name*, not the environment. */ -/** Deprecated alias keys folded into the canonical `visibleWhen`. */ +/** The retired alias keys of the canonical `visibleWhen` (refused by the schemas since 18). */ export const VISIBILITY_ALIAS_KEYS = ['visibleOn', 'visibility'] as const; /** Object carrying the canonical key and/or its deprecated aliases. */ @@ -39,13 +44,8 @@ type WithVisibilityAliases = { * `visibleWhen` and drop the aliases from the output (ADR-0089 D2). The * canonical key wins when present; otherwise the first defined alias is used. * - * Designed to be used as a zod `.transform()` on any object schema that carries - * a conditional-visibility predicate: - * - * ```ts - * strictObject(VISIBILITY_STRICT_OPTIONS, { ..., visibleWhen: Expr.optional() }) - * .transform(normalizeVisibleWhen) - * ``` + * No schema here uses it since the aliases retired at 18 (#23543 F4): the + * view form and page component shapes refuse both keys with a prescription. */ export function normalizeVisibleWhen( input: T, diff --git a/packages/spec/src/shared/visible-when-alias-guidance.test.ts b/packages/spec/src/shared/visible-when-alias-guidance.test.ts index 7a17b33e682..747a87a32ab 100644 --- a/packages/spec/src/shared/visible-when-alias-guidance.test.ts +++ b/packages/spec/src/shared/visible-when-alias-guidance.test.ts @@ -294,7 +294,7 @@ describe('the two `visibleWhen` shapes the first sweep never enumerated', () => // The judgment call: `visibility` / `visibleOn` are the ADR-0089 spellings // this surface's own docblock says are deliberately NOT folded in (the // key is new; there is no legacy convention to carry forward here, unlike - // the view/page shapes that fold them via `normalizeVisibleWhen`). That + // the view/page shapes that folded them until both retired at 18). That // sentence is about ACCEPTANCE and an alias row does not disturb it — both // stay rejected below. But an author who used the ADR-0089 spelling // correctly on a page component or view form and reaches for the same diff --git a/packages/spec/src/stack-conversions-record.test.ts b/packages/spec/src/stack-conversions-record.test.ts index 5f1e2be6481..73afae47192 100644 --- a/packages/spec/src/stack-conversions-record.test.ts +++ b/packages/spec/src/stack-conversions-record.test.ts @@ -38,7 +38,44 @@ import { ObjectStackDefinitionSchema, type ObjectStackDefinition, } from './stack.zod'; -import { CONVERSION_NOTICE_CODE, type ConversionNotice } from './conversions/types'; +import { CONVERSION_NOTICE_CODE, type ConversionNotice, type MetadataConversion } from './conversions/types'; + +/** + * The subject is the record the load-time pass leaves, not any one entry, so + * the page vehicle is a conversion this file owns: a live copy of the page + * component `visibility` → `visibleWhen` rename, appended to the table the pass + * reads. It was the real entry `page-component-visibility-to-visibleWhen` until + * #23543 F4 retired it with its key, and the one entry still live on the load + * path, the callout rename below, retires in the same card. A probe nobody + * retires keeps this file about the record, not about whichever entry happens to + * be live. (`toMajor: 15`, the real entry's, keeps every pinned `retiresIn: 16`.) + * + * `vi.mock` replaces the registry module for the whole file, so `apply.ts` + * (which `defineStack` calls) reads the extended table; nothing outside this + * file sees the probe. + */ +const PROBE = 'stack-conversions-record-probe'; + +vi.mock('./conversions/registry', async (importOriginal) => { + const original = await importOriginal(); + const { mapPageComponents, renameKey } = await import('./conversions/walk'); + const probe: MetadataConversion = { + id: 'stack-conversions-record-probe', + toMajor: 15, + surface: 'page.component.visibility (test probe)', + summary: 'test-only probe: a live load-path rename for the conversion-record pins', + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + const renamed = renameKey(component, 'visibility', 'visibleWhen'); + if (renamed === null) return component; + emit({ from: 'visibility', to: 'visibleWhen', path: `${path}.visibleWhen` }); + return renamed; + }); + }, + fixture: { before: {}, after: {}, expectedNotices: 0 }, + }; + return { ...original, ALL_CONVERSIONS: [...original.ALL_CONVERSIONS, probe] }; +}); afterEach(() => { vi.restoreAllMocks(); @@ -46,9 +83,9 @@ afterEach(() => { /** * A stack with one `page:header` component whose visibility predicate is - * authored under `headerKey` — `visibility` is the alias the live conversion - * `page-component-visibility-to-visibleWhen` rewrites to `visibleWhen`; - * `visibleWhen` is canonical. (The vehicle used to be the header's + * authored under `headerKey` — `visibility` is the spelling the live + * {@link PROBE} rewrites to `visibleWhen` (its tombstone would refuse it + * otherwise); `visibleWhen` is canonical. (The vehicle used to be the header's * `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. @@ -90,7 +127,7 @@ const substance = (n: ConversionNotice) => ({ const HEADER_NOTICE = { code: CONVERSION_NOTICE_CODE, - conversionId: 'page-component-visibility-to-visibleWhen', + conversionId: PROBE, path: HEADER_PATH, from: 'visibility', to: 'visibleWhen', @@ -150,7 +187,7 @@ describe('defineStack records the conversions it applied', () => { expect(hasStackProvenance(built), '`[]` here must not be the unmarked answer').toBe(true); expect(stackConversionsOf(built)).toEqual([]); } - expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("conversion 'page-component-visibility-to-visibleWhen'")); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining(`conversion '${PROBE}'`)); }); it('is not subject to the stderr warn-once: a second build of the same source carries the record too', () => { @@ -160,7 +197,7 @@ describe('defineStack records the conversions it applied', () => { const second = defineStack(source(ns, 'visibility') as never); const lines = warn.mock.calls .map((c) => String(c[0])) - .filter((l) => l.includes("conversion 'page-component-visibility-to-visibleWhen'") && l.includes(HEADER_PATH)); + .filter((l) => l.includes(`conversion '${PROBE}'`) && l.includes(HEADER_PATH)); // stderr says it at most once per process (an earlier test may have said it already)… expect(lines.length).toBeLessThanOrEqual(1); // …while each built stack keeps its own record. @@ -204,7 +241,7 @@ describe('the record is invisible to every data reader, and frozen', () => { it('never reaches JSON, and the strict stack schema neither sees nor refuses it', () => { const stack = built(); expect(stackConversionsOf(stack), 'anti-vacuity: there IS a record to leak').toHaveLength(1); - expect(JSON.stringify(stack)).not.toContain('page-component-visibility-to-visibleWhen'); + expect(JSON.stringify(stack)).not.toContain(PROBE); expect(ObjectStackDefinitionSchema.safeParse(stack).success).toBe(true); }); }); @@ -306,7 +343,7 @@ function thrown(run: () => unknown): Error & { code?: string; status?: number; i const ownRecord = (value: object) => Object.getOwnPropertyDescriptor(value, Symbol.for('objectstack.stack.conversions')); describe('a refusing producer carries the conversions it applied on its refusal', () => { - it("⭐ triage pin: a convert-then-refuse call's error answers page-component-visibility-to-visibleWhen", () => { + it("⭐ triage pin: a convert-then-refuse call's error answers the page probe", () => { quiet(); const error = thrown(() => defineStack({ ...source('rf', 'visibility'), requires: ['no-such-capability'] } as never), @@ -406,7 +443,7 @@ describe('a refusing producer carries the conversions it applied on its refusal' } }; const headerLines = (lines: string[]) => - lines.filter((l) => l.includes("conversion 'page-component-visibility-to-visibleWhen'") && l.includes(HEADER_PATH)); + lines.filter((l) => l.includes(`conversion '${PROBE}'`) && l.includes(HEADER_PATH)); // A and B author the same notice path, so their warn-once key is one key. const error = thrown(() => diff --git a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts index 9dc8682b7ba..90744739fa1 100644 --- a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts +++ b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts @@ -398,7 +398,7 @@ describe('§2 off-shape values are refused with the code and the path', () => { expect(firstMessage(parse('object-form', { sections: [{ fields: ['a'], columns: '2' }] }))) .toMatch(/write `2`, not `'2'`/); expect(messages(parse('object-form', { sections: [{ fields: [{ field: 'a', visibleOn: 'record.b == 1' }] }] }))) - .toMatch(/takes the\s+canonical spelling only: write `visibleWhen`/); + .toMatch(/retired spelling of `visibleWhen`.*Write `visibleWhen`; the value \(a CEL predicate\) is unchanged/s); expect(messages(parse('object-form', { sections: [{ fields: [{ field: 'a', name: 'a' }] }] }))) .toMatch(/keyed by `field`, or an inline form field keyed by `name` — not both/); }); @@ -444,15 +444,16 @@ describe('§3 the declared members, and one shape for both rows', () => { expect(Object.keys(runtimeField().shape).filter((k) => /_/.test(k))).toEqual([]); }); - it('a section declares exactly the form view\'s section keys, without the deprecated `visibleOn`', () => { - // `FormSectionSchema` is its object piped into the `visibleOn` fold; read the object half. - const viewKeys = Object.keys((FormSectionSchema as unknown as { in: { shape: Record } }).in.shape); + it('a section declares exactly the form view\'s section keys, without the retired `visibleOn`', () => { + // The view's section keeps `visibleOn` as a tombstone (#23543 F4); the page + // block answers the key with its own refusal instead. + const viewKeys = Object.keys((FormSectionSchema as unknown as { shape: Record }).shape); expect(viewKeys).toContain('visibleOn'); expect(Object.keys(section().shape).sort()).toEqual(viewKeys.filter((k) => k !== 'visibleOn').sort()); }); it('the `{ field }` arm\'s members are the form view\'s entry\'s own, def by def, but for the four the page block reads differently', () => { - const view = (FormFieldSchema as unknown as { in: { shape: Record } }).in.shape; + const view = (FormFieldSchema as unknown as { shape: Record }).shape; const arm = (entryArms()[1] as { shape: Record }).shape; const own = ['label', 'placeholder', 'helpText', 'span', 'fields']; expect(Object.keys(arm).sort()).toEqual(Object.keys(view).filter((k) => k !== 'visibleOn').sort()); diff --git a/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts b/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts index 4ba64bc1e58..7e0de245642 100644 --- a/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts +++ b/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts @@ -78,11 +78,11 @@ describe('A — the parent binding parses with the data-source shape', () => { expect(zodDef(key.unwrap())).toBe(zodDef(ElementDataSourceSchema)); // The node side of the identity, read off the published node schema rather - // than assumed: `PageComponentSchema` is a pipe (strict object → the - // `visibleWhen` transform), and `dataSource` is an optional union whose - // object arm is that same def. - const node = PageComponentSchema as unknown as { _zod: { def: { in?: { shape?: Record } } } }; - const dataSource = node._zod.def.in?.shape?.dataSource as { unwrap: () => { _zod: { def: { options: unknown[] } } } }; + // than assumed: `PageComponentSchema` is a closed object (its `visibility` + // fold, a `.transform` pipe, retired at 18, #23543 F4), and `dataSource` is + // an optional union whose object arm is that same def. + const node = PageComponentSchema as unknown as { shape?: Record }; + const dataSource = node.shape?.dataSource as { unwrap: () => { _zod: { def: { options: unknown[] } } } }; expect(dataSource).toBeDefined(); const arms = dataSource.unwrap()._zod.def.options; expect(zodDef(arms[0])).toBe(zodDef(ElementDataSourceSchema)); diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 34e330ae04a..72bb498b81c 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -1292,14 +1292,15 @@ describe('ComponentPropsMap', () => { expect(located[0]!.message).toMatch(FIRST_SENTENCE); // A slot-mounted node goes through the same node schema. The slot is a - // `z.union([PageComponentSchema, z.array(PageComponentSchema)])`, and zod 4 - // reports a union whose every arm failed as ONE `invalid_union` at the - // slot's path with the arms' issues nested under `errors`, their paths - // RELATIVE to the union — a property of the slot union, not of this - // retirement (an unknown key on a slotted node arrives the same way; - // `formatZodIssue` renders the nested line with the absolute path, #4971 - // / #5341, and `describeIssue` in `@objectstack/lint` unpacks it, #5583). - // The located prescription must still be inside it, at the node's `type`. + // `z.union([PageComponentSchema, z.array(PageComponentSchema)])`. Until 18 + // the node schema was a `.transform` pipe (its `visibility` fold) and zod 4 + // reported a union whose every arm failed as ONE `invalid_union` at the + // slot's path, the arms' issues nested under `errors`. The fold retired + // with the key (#23543 F4), so the node arm is a plain object again, and + // a `type` refusal — a non-aborting refinement issue — leaves that arm the + // one zod did not abort: the union reports its issue directly, at the + // absolute path. (A shape error such as an unknown key still arrives as + // the `invalid_union`; `describeIssue` in `@objectstack/lint` reads both.) const slotted = PageSchema.safeParse({ name: 'home', label: 'Home', @@ -1308,15 +1309,10 @@ describe('ComponentPropsMap', () => { }); expect(slotted.success).toBe(false); if (!slotted.success) { - type Nested = { code: string; path: PropertyKey[]; message: string; errors?: Nested[][] }; - const outer = slotted.error.issues.filter((i) => i.code === 'invalid_union'); - expect(outer).toHaveLength(1); - expect(outer[0]!.path).toEqual(['slots', 'header']); - const flatten = (issues: Nested[]): Nested[] => - issues.flatMap((i) => [i, ...(i.errors ?? []).flatMap(flatten)]); - const atSlot = flatten(slotted.error.issues as Nested[]).filter((i) => i.code === 'custom'); + expect(slotted.error.issues.filter((i) => i.code === 'invalid_union')).toHaveLength(0); + const atSlot = slotted.error.issues.filter((i) => i.code === 'custom'); expect(atSlot).toHaveLength(1); - expect(atSlot[0]!.path).toEqual(['type']); + expect(atSlot[0]!.path).toEqual(['slots', 'header', 'type']); expect(atSlot[0]!.message).toMatch(FIRST_SENTENCE); } }); @@ -2875,8 +2871,8 @@ describe('ComponentPropsMap record:chatter', () => { */ describe('批 17 — the carrier stays an open bag; the gate is on the lint side', () => { it('the carrier is still an OPEN bag — direction B (a typed `properties`) was declined, so this stays green', () => { - // `PageComponentSchema` is `.strict().transform(…)`, so unwrap the pipe to - // reach the object shape. + // `PageComponentSchema` was a `.strict().transform(…)` pipe until its + // `visibility` fold retired (#23543 F4); unwrap one if it ever returns. const def = (PageComponentSchema as any)._zod.def; const shape = def.type === 'pipe' ? def.in._zod.def.shape : def.shape; // `properties` is `z.record(z.string(), z.unknown()).optional().default({})`. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 6f4c10af784..0be4166f059 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -955,9 +955,9 @@ export const PageTabsProps = strictObject({ * row `regions`). * * Canonical `*When` name per ADR-0089 — this key is new, so the deprecated - * `visibility` / `visibleOn` aliases are NOT ACCEPTED on tab items: unlike - * the view/page surfaces that fold them into `visibleWhen` via - * `normalizeVisibleWhen`, none of `visible` / `showWhen` / `visibility` / + * `visibility` / `visibleOn` aliases were never accepted on tab items (the + * view/page surfaces that folded them into `visibleWhen` retired both at + * 18, #23543 F4): none of `visible` / `showWhen` / `visibility` / * `visibleOn` parses here — all four are rejected. #8382 gave the * rejection a pointer at this key for all four spellings (message only: * being pointed AT `visibleWhen` is not the same as being accepted). @@ -6701,16 +6701,17 @@ type ObjectFormSectionFieldEntry = * schema's, by reference (pinned def by def), with three differences, each * from how the page block reaches the form: * - * - **canonical spellings only.** A form view folds the deprecated `visibleOn` - * into `visibleWhen` at parse; a page block's `properties` is never parsed on - * the way to the form, so no fold runs, and the deprecated spelling is - * refused with the canonical one (ADR-0089). + * - **canonical spellings only.** The form view's entry retired `visibleOn` + * at 18 (#23543 F4) and answers it with a tombstone whose prescription names + * the form view's path; this arm leaves that member out and answers the key + * with its own refusal naming `visibleWhen` (ADR-0089), worded for a page + * block. * - **`label`, `placeholder` and `helpText` are plain strings.** The form * copies each onto the field it draws as it stands, and the renderer * draws a label as a React child, so an inline locale map would * throw. - * - **no parse-time fill.** The arm is the view entry's object half, without - * its `visibleOn` fold, and `span` drops the default the form view fills (the + * - **no parse-time fill.** The arm is the view entry's shape, and `span` + * drops the default the form view fills (the * form draws an absent `span` as `'auto'` anyway); the one parse-time change * left is the evaluated predicate's own envelope for a bare CEL string. * @@ -6719,12 +6720,13 @@ type ObjectFormSectionFieldEntry = * holds at every depth. */ function buildObjectFormSectionFieldEntry(): z.ZodType { - // The object half of the form view's entry: `FormFieldSchema` is that object - // piped into its `visibleOn` fold, and its declared type is the pipe's, so - // the half is read off the pipe here, in one place. - const viewShape = (FormFieldSchema as unknown as z.ZodPipe, z.ZodType>).in.shape; + // The form view's entry shape. `FormFieldSchema` is a plain closed object + // since its `visibleOn` fold retired (#23543 F4), but its declared type is the + // hand-written `ZodType`, so the shape is read + // through a cast here, in one place. + const viewShape = (FormFieldSchema as unknown as z.ZodObject).shape; const { - visibleOn: _foldedAtParse, label: _label, placeholder: _placeholder, helpText: _helpText, span, fields: _viewSubFields, ...shape + visibleOn: _retiredTombstone, label: _label, placeholder: _placeholder, helpText: _helpText, span, fields: _viewSubFields, ...shape } = viewShape; return strictObject({ surface: 'this form section\'s `{ field }` entry', @@ -6734,9 +6736,8 @@ function buildObjectFormSectionFieldEntry(): z.ZodType._sections..label`'), diff --git a/packages/spec/src/ui/expression-scope-app-root.pin.test.ts b/packages/spec/src/ui/expression-scope-app-root.pin.test.ts index d6976f6e12f..afb32eb9697 100644 --- a/packages/spec/src/ui/expression-scope-app-root.pin.test.ts +++ b/packages/spec/src/ui/expression-scope-app-root.pin.test.ts @@ -84,14 +84,11 @@ const SURVIVING_ROOTS = ['features', 'os.user'] as const; describe('no UI prose face advertises `app` as an expression-scope root — the renderer no longer mounts it', () => { describe('published faces (read by authoring tools and republished into the reference docs)', () => { it('`PageComponentSchema.visibleWhen`.describe() does not name `app` among the mounted roots', () => { - // ⚠️ NOT `.shape` — ADR-0089 D3a made this schema a `.strict().transform(…)` - // pipe (see `lazySchema`'s docblock), so it is a ZodPipe and the object - // with the property descriptions is its INPUT side. Reaching for `.shape` - // here yields `undefined` and every assertion below would then throw - // rather than measure. + // Read off `.shape`: the schema is a plain closed object since its + // `visibility` fold (a `.transform` pipe) retired with the key (#23543 F4). const shape = (PageComponentSchema as unknown as { - def: { in: { shape: Record } }; - }).def.in.shape; + shape: Record; + }).shape; const description = shape.visibleWhen.description; expect(description, 'the describe() must exist — this pin is about its content').toBeTruthy(); diff --git a/packages/spec/src/ui/expression-scope-data-root.pin.test.ts b/packages/spec/src/ui/expression-scope-data-root.pin.test.ts index b2966ff7d11..8eac727889b 100644 --- a/packages/spec/src/ui/expression-scope-data-root.pin.test.ts +++ b/packages/spec/src/ui/expression-scope-data-root.pin.test.ts @@ -51,13 +51,13 @@ const componentSource = fs.readFileSync(path.resolve(HERE, 'component.zod.ts'), /** * The published description of `PageComponentSchema.visibleWhen`. * - * ⚠️ NOT `.shape` — ADR-0089 D3a made this schema a `.strict().transform(…)` - * pipe, so the object carrying the property descriptions is its INPUT side. + * Read off `.shape`: the schema is a plain closed object since its + * `visibility` fold (a `.transform` pipe) retired with the key (#23543 F4). */ const visibleWhenDescription = (): string => { const shape = (PageComponentSchema as unknown as { - def: { in: { shape: Record } }; - }).def.in.shape; + shape: Record; + }).shape; const d = shape.visibleWhen.description; expect(d, 'the describe() must exist — this pin is about its content').toBeTruthy(); return d!; diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index a8532470dcc..1c81dffa974 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -1200,27 +1200,29 @@ describe('PageSchema - no cross-field requirements', () => { }); }); -describe('ADR-0089 — visibleWhen unification (page component)', () => { - it('normalizes a deprecated `visibility` alias to `visibleWhen`', () => { - const parsed = PageComponentSchema.parse({ - type: 'element:text', - visibility: "page.selectedId != ''", - }); - expect(parsed.visibleWhen).toBeDefined(); - expect((parsed as Record).visibility).toBeUndefined(); +// #23543 F4 — `visibility` retired at 18: a tombstone, the fold gone with it. +describe('ADR-0089 — page component `visibility` is retired (#23543 F4)', () => { + const PRESCRIPTION = + /`page\.components\[\]\.visibility` was removed in @objectstack\/spec 18.*Use `visibleWhen`\. Rename the key/s; + + it('refuses `visibility` at the key, naming `visibleWhen` — not a bare unrecognized-key report', () => { + const res = PageComponentSchema.safeParse({ type: 'element:text', visibility: "page.selectedId != ''" }); + expect(res.success).toBe(false); + expect(res.error!.issues.map((i) => i.path.join('.'))).toEqual(['visibility']); + expect(res.error!.issues[0]!.code).not.toBe('unrecognized_keys'); + expect(res.error!.issues[0]!.message).toMatch(PRESCRIPTION); }); - it('keeps the canonical `visibleWhen` when both are present (canonical wins)', () => { - const parsed = PageComponentSchema.parse({ - type: 'element:text', - visibleWhen: "page.a == 1", - visibility: "page.b == 2", - }); - const src = typeof parsed.visibleWhen === 'string' - ? parsed.visibleWhen - : (parsed.visibleWhen as { source?: string }).source; - expect(src).toBe('page.a == 1'); - expect((parsed as Record).visibility).toBeUndefined(); + it('refuses it beside `visibleWhen` too — no canonical-wins fold remains', () => { + const res = PageComponentSchema.safeParse({ type: 'element:text', visibleWhen: 'page.a == 1', visibility: 'page.b == 2' }); + expect(res.success).toBe(false); + expect(res.error!.issues[0]!.message).toContain('delete `visibility`'); + }); + + it('CONTROL — the canonical `visibleWhen` parses, and the output carries no `visibility`', () => { + const parsed = PageComponentSchema.parse({ type: 'element:text', visibleWhen: "page.selectedId != ''" }); + expect(parsed.visibleWhen).toBeDefined(); + expect(parsed).not.toHaveProperty('visibility'); }); }); @@ -1241,10 +1243,6 @@ describe('ADR-0089 D3a — strict page component schema (loud mis-layered keys)' } }); - it('the deprecated `visibility` alias is still accepted under strict (declared key)', () => { - expect(() => PageComponentSchema.parse({ type: 'element:text', visibility: "page.a == 1" })).not.toThrow(); - }); - it('rejects a stale `visibleOn` key on a page component (that is the view-form alias, not a page one)', () => { const res = PageComponentSchema.safeParse({ type: 'element:text', visibleOn: "page.a == 1" }); expect(res.success).toBe(false); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index e8a47b21d00..b8bce09a981 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; -import { normalizeVisibleWhen } from '../shared/visibility'; import { VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; import { SortItemSchema } from '../shared/enums.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; @@ -682,8 +681,19 @@ export const PageComponentSchema = lazySchema(() => strictObject({ * `component.zod.ts` rather than assuming either environment carries over. */ visibleWhen: EvaluatedExpressionInputSchema.optional().describe("Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record`. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. \"page.selectedProjectId != ''\""), - /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ - visibility: EvaluatedExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), + // `visibility` REMOVED at 18 (#23543 F4, ADR-0089 D2): through 17 it was the + // page component's deprecated spelling of `visibleWhen`, folded onto it at + // parse by a `.transform` that went with the key. The tombstone carries the + // prescription; stored pages keep loading because every stored-row + // rehydration seam replays the retired `page-component-visibility-to-visibleWhen` + // conversion. No `os migrate meta` sentence: that rename belongs to protocol + // 15, below `MIGRATION_SUPPORT_FLOOR`, so the tool lists no edit for it. + visibility: retiredKey( + '`page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page ' + + 'component\'s deprecated spelling of the conditional-visibility predicate, which the parse folded into ' + + '`visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is ' + + 'unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`.', + ), /** * Per-element data binding — one of two shapes: @@ -730,14 +740,14 @@ export const PageComponentSchema = lazySchema(() => strictObject({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), -}).transform(normalizeVisibleWhen)); +})); /** * Page Variable Schema * Local, in-memory page state. Runtime-live (ADR-0049): the renderer mounts the * declared variables, exposes them to expressions as `page.`, and lets an * interactive element write one via `source`. A write re-evaluates dependent - * `visibility` / binding predicates immediately — the master/detail and + * `visibleWhen` / binding predicates immediately — the master/detail and * filtered-dashboard pattern with no custom code. * * Binding direction: a variable names the **writer** component, not the other @@ -1826,7 +1836,7 @@ export const PageSchema = lazySchema(() => strictObject({ * declared variables, exposes each to expressions as `page.`, and lets an * interactive element write one via its `source` binding (e.g. * `element:record_picker` → `source`). A write re-evaluates dependent - * `visibility` / binding predicates immediately — the master/detail and + * `visibleWhen` / binding predicates immediately — the master/detail and * filtered-dashboard pattern, with no custom code. See {@link PageVariableSchema}. */ variables: z.array(PageVariableSchema).optional() diff --git a/packages/spec/src/ui/section-group-reference.test.ts b/packages/spec/src/ui/section-group-reference.test.ts index 3a8c0300d76..117b50e4eb9 100644 --- a/packages/spec/src/ui/section-group-reference.test.ts +++ b/packages/spec/src/ui/section-group-reference.test.ts @@ -229,7 +229,6 @@ describe('form.sections — the field-group reference form, members derived from ['label', 'Contact'], ['description', 'How to reach them'], ['visibleWhen', "record.type == 'person'"], - ['visibleOn', "record.type == 'person'"], ['collapsible', true], ['collapsed', true], ])('refuses `%s` beside `group` — the group owns it', (key, value) => { @@ -240,12 +239,14 @@ describe('form.sections — the field-group reference form, members derived from expect(issue.message).toContain('`fieldGroups` entry for `contact_info`'); }); - it('refuses `visibleOn` beside `group` at ITS OWN path, before the fold renames it', () => { - // The deprecated spelling folds onto `visibleWhen` in a `.transform` that - // runs AFTER this refinement. Naming only the canonical key would leave - // `visibleOn` as the one way to smuggle a section predicate past the rule. - const paths = formIssues(formWith([{ group: 'contact_info', visibleOn: 'true' }])).map(i => i.path); - expect(paths).toContainEqual(['sections', 0, 'visibleOn']); + it('a retired `visibleOn` beside `group` meets its tombstone, once, at its own path (#23543 F4)', () => { + // Until 18 the deprecated spelling folded onto `visibleWhen` in a + // `.transform` that ran AFTER this refinement, so the rule had to name it. + // The tombstone now refuses it at the key and the refinement never runs on + // the section: one issue, the retirement's. + const issues = formIssues(formWith([{ group: 'contact_info', visibleOn: 'true' }])); + expect(issues.map(i => i.path)).toEqual([['sections', 0, 'visibleOn']]); + expect(issues[0]!.message).toMatch(/was removed in @objectstack\/spec 18.*Use `visibleWhen`/s); }); it('accepts an authored `collapsible: false` beside `group` — indistinguishable from the default', () => { diff --git a/packages/spec/src/ui/view-form-features-root.test.ts b/packages/spec/src/ui/view-form-features-root.test.ts index 2b685b95f84..3adee20648e 100644 --- a/packages/spec/src/ui/view-form-features-root.test.ts +++ b/packages/spec/src/ui/view-form-features-root.test.ts @@ -102,15 +102,24 @@ describe('form-view predicates reject the features.* root', () => { expectFeaturesRefusal(issues, ['sections', 0, 'visibleWhen']); }); - it('refuses the deprecated visibleOn alias — the fold cannot smuggle the root past the scan', () => { - // ADR-0089 D2 folds `visibleOn` into `visibleWhen` at the field parse, - // BEFORE this refinement runs — so the alias spelling arrives at the - // scanner as the canonical key and is refused at that key's path. + it('the retired visibleOn spelling cannot carry the root past the scan: its tombstone refuses it first (#23543 F4)', () => { + // Until 18 ADR-0089 D2 folded `visibleOn` into `visibleWhen` at the field + // parse, so the scanner met it as the canonical key. The key is retired + // now: the field refuses it at its own path, whatever the predicate says. const issues = reject({ type: 'simple', sections: [{ fields: [{ field: 'phone', visibleOn: 'features.phoneNumber == true' }] }], }); - expectFeaturesRefusal(issues, ['sections', 0, 'fields', 0, 'visibleWhen']); + // A section's `fields` is `z.union([z.string(), FormFieldSchema])`, so the + // field's refusal arrives as the union's, the arm's issues nested inside it. + type Nested = { code: string; path: PropertyKey[]; message: string; errors?: Nested[][] }; + const flatten = (list: Nested[]): Nested[] => list.flatMap((i) => [i, ...(i.errors ?? []).flatMap(flatten)]); + expect(issues.map((i) => i.path)).toEqual([['sections', 0, 'fields', 0]]); + const tombstone = flatten(issues as Nested[]).filter((i) => i.path.join('.') === 'visibleOn'); + expect(tombstone).toHaveLength(1); + expect(tombstone[0]!.message).toMatch(/was removed in @objectstack\/spec 18/); + // …and nothing reports a `features.*` refusal: the scanner never met it. + expect(flatten(issues as Nested[]).some((i) => /features/.test(i.message))).toBe(false); }); it('refuses the root inside the legacy `groups` bucket (pre-fold)', () => { diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 6878fd207d2..d087a0ab56b 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -904,16 +904,17 @@ describe('FormViewSchema', () => { expect(issue?.message).not.toMatch(/#\d+/); }); - it('refuses the deprecated `visibleOn` spelling the same way (folded to `visibleWhen` pre-refine)', () => { + it('refuses the retired `visibleOn` spelling at its own key, with the tombstone prescription (#23543 F4)', () => { const r = FormViewSchema.safeParse({ type: 'wizard', sections: [{ label: 'Later', visibleOn: 'record.stage == "won"', fields: ['amount'] }, STEP], }); expect(r.success).toBe(false); - // The fold runs at SECTION parse, so the reported path names the - // canonical key — the message says so out loud. - const issue = issueAt(r, 'sections.0.visibleWhen'); - expect(issue?.message).toMatch(/`visibleOn` alias folds into `visibleWhen`/); + // No fold runs any more, so the section refuses the key where it was + // written — and the wizard rule, which reads `visibleWhen`, adds nothing. + expect(issueAt(r, 'sections.0.visibleOn')?.message) + .toMatch(/`view\.form\.sections\[\]\.visibleOn` was removed in @objectstack\/spec 18.*Use `visibleWhen`/s); + expect(issueAt(r, 'sections.0.visibleWhen')).toBeUndefined(); }); it.each(['collapsible', 'collapsed'] as const)('refuses `%s: true` on a wizard step', (key) => { @@ -4183,34 +4184,48 @@ describe('`HttpMethod` is not exported from ./ui', () => { }); }); -describe('ADR-0089 — visibleWhen unification (view form)', () => { - it('normalizes a deprecated `visibleOn` alias to `visibleWhen` on a form field', () => { - const parsed = FormFieldSchema.parse({ field: 'state', visibleOn: "record.country == 'US'" }); - expect(parsed.visibleWhen).toBeDefined(); - expect(parsed.visibleOn).toBeUndefined(); +// #23543 F4 — `visibleOn` retired at 18: a tombstone on both shapes, the +// fold onto `visibleWhen` gone with it. +describe('ADR-0089 — `visibleOn` is retired on the view form (#23543 F4)', () => { + const refusedAt = (res: { success: boolean; error?: { issues: { code: string; path: PropertyKey[]; message: string }[] } }) => { + expect(res.success).toBe(false); + const issues = res.error!.issues; + expect(issues.map((i) => i.path.join('.'))).toEqual(['visibleOn']); + // The tombstone's own prescription, not a bare unrecognized-key report. + expect(issues[0]!.code).not.toBe('unrecognized_keys'); + return issues[0]!.message; + }; + + it('refuses `visibleOn` on a form field at the key, naming `visibleWhen`', () => { + const m = refusedAt(FormFieldSchema.safeParse({ field: 'state', visibleOn: "record.country == 'US'" })); + expect(m).toMatch(/`view\.form\.sections\[\]\.fields\[\]\.visibleOn` was removed in @objectstack\/spec 18.*Use `visibleWhen`\. Rename the key/s); }); - it('normalizes a deprecated `visibleOn` alias to `visibleWhen` on a form section', () => { - const parsed = FormSectionSchema.parse({ + it('refuses `visibleOn` on a form section at the key, naming `visibleWhen`', () => { + const m = refusedAt(FormSectionSchema.safeParse({ label: 'Shipping', - visibleOn: "record.needs_shipping == true", + visibleOn: 'record.needs_shipping == true', fields: ['address'], - }); - expect(parsed.visibleWhen).toBeDefined(); - expect((parsed as Record).visibleOn).toBeUndefined(); + })); + expect(m).toMatch(/`view\.form\.sections\[\]\.visibleOn` was removed in @objectstack\/spec 18.*Use `visibleWhen`\. Rename the key/s); }); - it('keeps the canonical `visibleWhen` when both are present (canonical wins)', () => { - const parsed = FormFieldSchema.parse({ + it('refuses it beside `visibleWhen` too — no canonical-wins fold remains', () => { + const m = refusedAt(FormFieldSchema.safeParse({ field: 'state', - visibleWhen: "record.a == 1", - visibleOn: "record.b == 2", - }); - const src = typeof parsed.visibleWhen === 'string' - ? parsed.visibleWhen - : (parsed.visibleWhen as { source?: string }).source; - expect(src).toBe('record.a == 1'); - expect(parsed.visibleOn).toBeUndefined(); + visibleWhen: 'record.a == 1', + visibleOn: 'record.b == 2', + })); + expect(m).toContain('delete `visibleOn`'); + }); + + it('CONTROL — the canonical `visibleWhen` parses on both shapes, and no output carries `visibleOn`', () => { + const field = FormFieldSchema.parse({ field: 'state', visibleWhen: "record.country == 'US'" }); + expect(field.visibleWhen).toBeDefined(); + expect(field).not.toHaveProperty('visibleOn'); + const section = FormSectionSchema.parse({ label: 'Shipping', visibleWhen: 'record.needs_shipping == true', fields: ['address'] }); + expect(section.visibleWhen).toBeDefined(); + expect(section).not.toHaveProperty('visibleOn'); }); }); @@ -4231,10 +4246,6 @@ describe('ADR-0089 D3a — strict view form schemas (loud mis-layered keys)', () } }); - it('the deprecated `visibleOn` alias is still accepted under strict (declared key)', () => { - expect(() => FormFieldSchema.parse({ field: 'state', visibleOn: "record.a == 1" })).not.toThrow(); - }); - it('rejects a stale `visibility` key on a form field (that is the page-component alias, not a view one)', () => { const res = FormFieldSchema.safeParse({ field: 'state', visibility: "record.a == 1" }); expect(res.success).toBe(false); @@ -4249,9 +4260,8 @@ describe('ADR-0089 D3a — strict view form schemas (loud mis-layered keys)', () } }); - it('a strict form section still accepts canonical + alias keys', () => { + it('a strict form section still accepts the canonical key', () => { expect(() => FormSectionSchema.parse({ label: 'S', visibleWhen: 'record.a == 1', fields: [] })).not.toThrow(); - expect(() => FormSectionSchema.parse({ label: 'S', visibleOn: 'record.a == 1', fields: [] })).not.toThrow(); }); }); @@ -4309,12 +4319,13 @@ describe('visibility unknown-key message order — fix before history', () => { expect(m.endsWith(` ${HISTORY}`)).toBe(true); }); - it('still emits the whole alias table after the reorder — nothing was trimmed', () => { - // The prescription names all three spellings; a reorder that quietly lost - // one of them would still satisfy the order assertions above. + it('names the canonical key and no longer offers a retired alias as accepted (#23543 F4)', () => { + // Until 18 the prescription also named `visibleOn` (view form) and + // `visibility` (page component) as accepted deprecated aliases; both + // retired, so the sentence that promised them is gone. const m = messageFor({ visibleWhenn: 'record.a == 1' }); - expect(m).toContain('`visibleOn` (view form)'); - expect(m).toContain('`visibility` (page component) are still accepted as deprecated aliases.'); + expect(m).toContain('the canonical key is `visibleWhen` (ADR-0089).'); + expect(m).not.toMatch(/still accepted|deprecated alias/); }); it('is unchanged in SHAPE when there is no visibility hint to offer', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 6247a6df825..dacc9d58296 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -55,7 +55,6 @@ import { closedObject, strictObject, strictObjectError } from '../shared/strict- import { SnakeCaseIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { ConditionalFormattingRuleSchema } from '../shared/conditional-formatting.zod'; -import { normalizeVisibleWhen } from '../shared/visibility'; import { VISIBILITY_STRICT_OPTIONS } from '../shared/visibility-strict-options'; import { SELECT_OPTION_EDITABILITY_GUIDANCE, VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; // [#13855] The section → field-group reference form, shared with the @@ -3299,6 +3298,38 @@ const FORM_FIELD_PUBLIC_PICKER_RETIRED = + 'to let them pick an existing record, put the form behind sign-in. ' + '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.'; +/** + * [#23543 F4] Prescriptions for the retired `visibleOn` key on a form field and + * on a form section (ADR-0089 D2, retired at 18 by the maintainer's ruling F4 A). + * + * `visibleOn` was the form view's first spelling of the conditional-visibility + * predicate. ADR-0089 unified the predicate under `visibleWhen`, and through 17 + * both shapes declared `visibleOn` as a deprecated alias that a `.transform` + * folded onto `visibleWhen` at parse — so the old spelling was accepted without + * a word, and the D2 conversion `view-visibleOn-to-visibleWhen` (protocol 15) + * that should have expired it could not retire alone. The ruling retired the + * key: both shapes carry a {@link retiredKey} tombstone, `tsc` types it `never`, + * the parse refuses it with this text, and the fold is gone. Stored form views + * keep loading: every stored-row rehydration seam replays the retired + * conversion, so a `sys_metadata` row written with `visibleOn` reaches this + * schema as `visibleWhen`. No `os migrate meta` sentence, on purpose: the + * rename belongs to protocol 15, below `MIGRATION_SUPPORT_FLOOR`, so the tool + * lists no edit for it. + */ +const FORM_FIELD_VISIBLE_ON_RETIRED = + '`view.form.sections[].fields[].visibleOn` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was ' + + 'the form view\'s deprecated spelling of the conditional-visibility predicate, which the parse folded ' + + 'into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) ' + + 'is unchanged. Where a field writes both, `visibleWhen` is the one that was in effect: delete `visibleOn`.'; + +/** {@link FORM_FIELD_VISIBLE_ON_RETIRED}'s section counterpart. */ +const FORM_SECTION_VISIBLE_ON_RETIRED = + '`view.form.sections[].visibleOn` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the form ' + + 'view\'s deprecated spelling of a section\'s conditional-visibility predicate, which the parse folded ' + + 'into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate, ' + + 'which hides the whole section when false) is unchanged. Where a section writes both, `visibleWhen` is ' + + 'the one that was in effect: delete `visibleOn`.'; + const FormFieldBaseSchema = lazySchema(() => { const shape = { /** Field name (snake_case) */ @@ -3508,8 +3539,9 @@ const FormFieldBaseSchema = lazySchema(() => { * the ruling and the scanner. */ visibleWhen: EvaluatedExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'`"), - /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ - visibleOn: EvaluatedExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), + // `visibleOn` REMOVED at 18 (#23543 F4, ADR-0089 D2): the tombstone carries + // the prescription; the fold onto `visibleWhen` went with it. + visibleOn: retiredKey(FORM_FIELD_VISIBLE_ON_RETIRED), disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'), }; const base = z.object(shape, { @@ -3569,9 +3601,10 @@ const FormFieldBaseSchema = lazySchema(() => { /** * A parsed form field — the TYPE half of {@link FormFieldSchema}. * - * `visibleOn` is absent by construction: ADR-0089 D2 folds it into - * `visibleWhen` at the schema boundary, so it is accepted on input and never - * present on output. + * `visibleOn` is absent by construction: the key is a {@link retiredKey} + * tombstone (#23543 F4), so no parse ever outputs it, and a reader of parsed + * output that still reaches for it fails `tsc` here rather than reading an + * always-absent value. */ export type FormField = Omit, 'visibleOn'> & { /** @@ -3592,8 +3625,8 @@ export type FormField = Omit, 'visibleOn'> & * * Differs from {@link FormField} in both directions, which is exactly why it * has to be declared: `span` is `.default('auto')` so it is optional here and - * required there, and the deprecated `visibleOn` is accepted here and stripped - * by the ADR-0089 D2 transform before it reaches the output. + * required there, and the retired `visibleOn` is a tombstone here (typed so + * that no value is assignable) and absent there. * * Without this, `z.input` was `unknown` — so a form * section's `fields` were unchecked at every authoring site. @@ -3624,7 +3657,7 @@ export const FormFieldSchema: z.ZodType = lazySchema( FormFieldBaseSchema.extend({ fields: z.array(z.lazy(() => FormFieldSchema)).optional() .describe('Sub-fields for composite/repeater/record types'), - }).strict().transform(normalizeVisibleWhen)); + }).strict()); /** * Form Layout Section @@ -3634,8 +3667,9 @@ export const FormFieldSchema: z.ZodType = lazySchema( * converting it meant re-expressing `strictVisibilityError` as `guidance`, and * `guidance` could not express a family of keys; #6619 gave the channel a * set-keyed form and the conversion became the same one call every other closed - * shape in this package makes. The `.transform()` that folds - * `visibleOn` → `visibleWhen` is unchanged and still runs after the parse. + * shape in this package makes. The `.transform()` that folded the deprecated + * `visibleOn` onto `visibleWhen` went with the key at 18 (#23543 F4): `visibleOn` + * is a {@link retiredKey} tombstone. * * ## A section gates VISIBILITY, not editability (#7887 — boundary, not gap) * @@ -3790,8 +3824,9 @@ export const FormSectionSchema = lazySchema(() => strictObject({ * {@link checkFormViewPredicateFeaturesRoot}). */ visibleWhen: EvaluatedExpressionInputSchema.optional().describe('Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: unbound on the standalone form routes, where the predicate would fault open.'), - /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ - visibleOn: EvaluatedExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse.'), + // `visibleOn` REMOVED at 18 (#23543 F4, ADR-0089 D2) — see + // FORM_SECTION_VISIBLE_ON_RETIRED. + visibleOn: retiredKey(FORM_SECTION_VISIBLE_ON_RETIRED), /** * Section density, two meanings and no default: * @@ -3852,7 +3887,7 @@ export const FormSectionSchema = lazySchema(() => strictObject({ * `visibleWhen` and `collapse`, and a wizard step has no slot for either. */ group: SectionGroupKeySchema.optional().describe( - 'Field group key (snake_case) whose members and presentation this section inherits, from the bound object\'s `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics.', + 'Field group key (snake_case) whose members and presentation this section inherits, from the bound object\'s `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics.', ), /** * The section's members, enumerated. @@ -3873,22 +3908,23 @@ export const FormSectionSchema = lazySchema(() => strictObject({ // in the list because the derived section's `key` IS the group key, which is // already the object's i18n anchor for that heading // (`objects.._sections.`, `deriveFieldGroupLayout`'s `key`) - // — a second name would give one section two lookup identities. `visibleOn` - // rides along because this refinement runs BEFORE the `.transform` that folds - // it onto `visibleWhen`, so the deprecated spelling must be named here or it - // would be the one way to smuggle a section-level predicate past the rule. + // — a second name would give one section two lookup identities. The retired + // `visibleOn` is not listed: until 18 a `.transform` folded it onto + // `visibleWhen` AFTER this refinement, so it had to be named here; now its + // tombstone refuses it at the key, and this refinement never runs on a + // section that wrote it (#23543 F4, measured: one issue, at `visibleOn`). // // NOT in the list, deliberately: `columns` and `pane`. Those are how THIS // form lays the section out and the group declares nothing about them, so // there is no second source to create. - derivedKeys: ['name', 'label', 'description', 'visibleWhen', 'visibleOn'], + derivedKeys: ['name', 'label', 'description', 'visibleWhen'], // `collapsible` / `collapsed` carry `.default(false)`, so an authored `false` // is already indistinguishable from the default by the time this runs — the // same asymmetry #13704 records for the wizard step keys, and it costs // nothing: `false` declares exactly what a group with `collapse: 'none'` // delivers. Only `true` declares presentation the group owns. trueOnlyDerivedKeys: ['collapsible', 'collapsed'], -})).transform(normalizeVisibleWhen)); +}))); /** * A single form action button (submit / cancel / reset): visibility + label. @@ -4382,7 +4418,7 @@ export const FormViewSchema = lazySchema(() => strictObject({ * is the only authorable progress surface — no progress object, no * percentages, no custom step-state text. * - **Wizard steps carry no predicate slot and do not collapse (D2).** - * `visibleWhen` (and its deprecated `visibleOn` alias) and + * `visibleWhen` and * `collapsible` / `collapsed: true` on a wizard section are refused at * parse — see the superRefine below; it is the spec-door upgrade of * objectui#6237's ruled `FormSectionConfig` split ("WizardForm steps = @@ -4662,11 +4698,10 @@ export const FormViewSchema = lazySchema(() => strictObject({ // ruled 2026-08-31). Breadth is ruled NARROW: exactly this step-key // family — no per-type presentation-key matrix. // - // Post-parse observability decides the spellings checked here: - // `FormSectionSchema` folds `visibleOn` → `visibleWhen` (its - // `.transform(normalizeVisibleWhen)`) before this refinement runs, so - // one check answers both spellings (the message says so, since the - // reported path can only name the canonical key). `collapsible` / + // Post-parse observability decides the spellings checked here: the + // retired `visibleOn` never reaches this refinement (its tombstone on + // `FormSectionSchema` refuses it at the section, #23543 F4), so the + // canonical key is the one spelling to check. `collapsible` / // `collapsed` carry `.default(false)`, so an authored `false` is // indistinguishable from the default here — and needs no refusal: it // declares exactly the behavior a wizard delivers. Only `true` declares @@ -4680,8 +4715,7 @@ export const FormViewSchema = lazySchema(() => strictObject({ '`visibleWhen` on a wizard step is refused: wizard steps carry no predicate slot ' + '— steps are entered in array order behind the step gate, ' + 'never conditionally. Put the predicate on the fields inside the step, or use a ' - + "`simple`/`tabbed` form for section-level visibility. (The deprecated `visibleOn` " - + 'alias folds into `visibleWhen` and is refused the same way.)', + + '`simple`/`tabbed` form for section-level visibility.', }); } for (const collapseKey of ['collapsible', 'collapsed'] as const) { diff --git a/packages/spec/src/ui/visibility-alias-keys-retirement.test.ts b/packages/spec/src/ui/visibility-alias-keys-retirement.test.ts new file mode 100644 index 00000000000..fcb9e726557 --- /dev/null +++ b/packages/spec/src/ui/visibility-alias-keys-retirement.test.ts @@ -0,0 +1,326 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ADR-0089 visibility aliases RETIRED as keys at 18 (#23543 F4, ruling + * record 6182001882, letter A): the view form's `visibleOn` (on a form field + * and on a form section) and the page component's `visibility`. Through 17 + * each was a declared `[DEPRECATED]` key that a `.transform` folded onto + * `visibleWhen` at parse, so the D2 conversions that should have expired them + * (`view-visibleOn-to-visibleWhen`, `page-component-visibility-to-visibleWhen`, + * protocol 15) could not retire alone: the old spelling would still have been + * accepted without a word. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone at each of the three declared positions — the + * refusal carries the prescription naming `visibleWhen`, and the key's + * input type is `never`. The fold is gone; the per-shape refusal pins live + * beside each schema (`view.test.ts`, `page.test.ts`). + * 2. Both D2 conversions retired from the load path. Stored rows keep loading: + * stored-row rehydration replays them before the parse. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries the three keys, and the family's one + * D3 entry is `ui-visibility-alias-keys-retired`, with no `conversionIds` + * (protocol 15 is below the migration floor). + * + * A schema refusal raises a `ZodError` (no ADR-0112 `status`); the door-level + * envelope is pinned through `defineStack`, which answers + * `STACK_SCHEMA_INVALID` / 422. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack } from '../stack.zod'; +import { PageComponentSchema, PageSchema, type PageComponent } from './page.zod'; +import { FormFieldSchema, FormSectionSchema, ViewSchema, type FormFieldInput, type FormSection } from './view.zod'; + +const VIEW_CONVERSION = 'view-visibleOn-to-visibleWhen'; +const PAGE_CONVERSION = 'page-component-visibility-to-visibleWhen'; +const D3_ID = 'ui-visibility-alias-keys-retired'; +const REGISTERED_KEYS = ['ui/FormField:visibleOn', 'ui/FormSection:visibleOn', 'ui/PageComponent:visibility']; + +/** A form view written before 18: a section and a field gated through `visibleOn`. */ +const legacyView = () => ({ + object: 'crm_lead', + form: { + type: 'simple', + sections: [{ + label: 'Details', + visibleOn: "record.status == 'open'", + fields: ['name', { field: 'priority', visibleOn: "record.priority != ''" }], + }], + }, +}); + +/** A page written before 18: one component gated through `visibility`. */ +const legacyPage = () => ({ + name: 'crm_home', + label: 'Home', + regions: [{ name: 'main', components: [{ type: 'record:list', visibility: "page.selectedId != ''" }] }], +}); + +type Issue = { code: string; path: PropertyKey[]; message: string; errors?: Issue[][] }; +const flatten = (issues: Issue[]): Issue[] => issues.flatMap((i) => [i, ...(i.errors ?? []).flatMap(flatten)]); + +describe('visibility alias keys retirement — the type channel', () => { + it('fails tsc at every authoring site: each retired key\'s input type is `never`', () => { + const field: FormFieldInput = { + field: 'priority', + // @ts-expect-error — `visibleOn` is a retiredKey() tombstone: its input type is `never`. + visibleOn: "record.priority != ''", + }; + const section: FormSection = { + label: 'Details', + // @ts-expect-error — `visibleOn` is a retiredKey() tombstone: its input type is `never`. + visibleOn: "record.status == 'open'", + fields: ['name'], + }; + const component: PageComponent = { + type: 'record:list', + // @ts-expect-error — `visibility` is a retiredKey() tombstone: its input type is `never`. + visibility: "page.selectedId != ''", + }; + // The parse channel agrees with the type channel on the same literals. + expect(FormFieldSchema.safeParse(field).success).toBe(false); + expect(FormSectionSchema.safeParse(section).success).toBe(false); + expect(PageComponentSchema.safeParse(component).success).toBe(false); + }); + + it('the walked shapes keep each key — the authorable-surface `[RETIRED]` rows stay reachable', () => { + const keysOf = (schema: unknown) => Object.keys((schema as { shape: Record }).shape); + expect(keysOf(FormFieldSchema)).toContain('visibleOn'); + expect(keysOf(FormSectionSchema)).toContain('visibleOn'); + expect(keysOf(PageComponentSchema)).toContain('visibility'); + // CONTROL: the canonical neighbour on each. + for (const schema of [FormFieldSchema, FormSectionSchema, PageComponentSchema]) expect(keysOf(schema)).toContain('visibleWhen'); + }); +}); + +describe('visibility alias keys retirement — the authoring funnel refuses, the stored seam converts', () => { + it('an authored legacy view reaches defineStack as written and is refused at both keys', () => { + let refusal: unknown; + try { + defineStack({ views: [legacyView()] } as never); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = flatten((refusal as { issues: Issue[] }).issues); + const retired = issues.filter((i) => /visibleOn` was removed in @objectstack\/spec 18/.test(i.message)); + // The section's own key, and the field's (nested inside the `fields` union). + expect(retired.map((i) => i.message.slice(0, 50))).toEqual([ + '`view.form.sections[].visibleOn` was removed in @o', + '`view.form.sections[].fields[].visibleOn` was remo', + ]); + }); + + it('an authored legacy page is refused at the component key', () => { + let refusal: unknown; + try { + defineStack({ pages: [legacyPage()] } as never); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = (refusal as { issues: Issue[] }).issues; + expect(issues.map((i) => i.path.join('.'))).toEqual(['pages.0.regions.0.components.0.visibility']); + expect(issues[0]!.message).toMatch(/^`page\.components\[\]\.visibility` was removed in @objectstack\/spec 18/); + }); + + it('is retired from the load path — the funnel rewrites neither key', () => { + const input = { views: [legacyView()], pages: [legacyPage()] }; + const { stack, notices } = collectConversionNotices(structuredClone(input)); + expect(notices.filter((n) => n.conversionId === VIEW_CONVERSION || n.conversionId === PAGE_CONVERSION)).toEqual([]); + expect(stack).toEqual(input); + }); + + it('a STORED view row written with `visibleOn` loads, renamed, and parses', () => { + const notices: string[] = []; + const row = applyConversionsToStoredItem('view', legacyView(), { onNotice: (n) => notices.push(`${n.conversionId} ${n.path}`) }); + expect(notices).toEqual([ + `${VIEW_CONVERSION} views[0].form.sections[0].visibleWhen`, + `${VIEW_CONVERSION} views[0].form.sections[0].fields[1].visibleWhen`, + ]); + expect(JSON.stringify(row)).not.toContain('visibleOn'); + expect(ViewSchema.safeParse(row).success).toBe(true); + }); + + it('a STORED page row written with `visibility` loads, renamed, and parses', () => { + const notices: string[] = []; + const row = applyConversionsToStoredItem('page', legacyPage(), { onNotice: (n) => notices.push(`${n.conversionId} ${n.path}`) }); + expect(notices).toEqual([`${PAGE_CONVERSION} pages[0].regions[0].components[0].visibleWhen`]); + expect(JSON.stringify(row)).not.toContain('"visibility"'); + expect(PageSchema.safeParse(row).success).toBe(true); + }); +}); + +describe('visibility alias keys retirement — the ADR-0087 ledger rows', () => { + it('declares the three keys under major 18', () => { + for (const key of REGISTERED_KEYS) expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(key); + }); + + it('retires both D2 conversions from the load path, stamped, and wires neither into a chain step', () => { + for (const id of [VIEW_CONVERSION, PAGE_CONVERSION]) { + const conversion = ALL_CONVERSIONS.find((c) => c.id === id); + expect(conversion, id).toMatchObject({ toMajor: 15, retiredFromLoadPath: true, retiredAfter: '17.7.0' }); + // Protocol 15 is below the migration floor: no step replays it. + for (const step of Object.values(MIGRATIONS_BY_MAJOR)) expect(step!.conversionIds ?? []).not.toContain(id); + } + }); + + it('carries ONE D3 entry for the family, with no conversion link', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries).toHaveLength(1); + expect(entries[0]!.conversionIds).toBeUndefined(); + expect(entries[0]!.replacement).toContain('`visibleWhen`'); + expect(entries[0]!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps only TYPED authoring sites; a JSON / YAML / markdown example, an +// untyped object literal and a page block's `properties` are outside it. This +// walk covers every text file under the five repo roots +// `scripts/cross-package-test-inputs.mjs` declares for `@objectstack/spec#test` +// (mirrored in `turbo.json`), plus the example apps' own `src/` trees — the +// `page-header-show-star-retirement.test.ts` radius, unchanged. +// +// The matcher judges the AUTHORING SHAPE, never a mention: `visibleOn` in key +// position with a predicate value (a string literal that is not prescription +// prose, or a `{ dialect | source }` envelope), and `visibility` in key +// position with a predicate rooted at `record.` / `page.` / `current_user` / +// `data.` / `user.`, or an envelope — so the many unrelated `visibility` keys +// (package, view switcher, app nav) are never judged. In markdown, inline code +// is prose and is stripped first; in source it is not (a prescription string's +// own backticks are what tell it apart from a predicate). The bound, stated: a value held in a variable, and `docs/**`, +// `.claude/**`, `.github/**` and the repo-root files are outside this walk. +describe('tree-scoped absence: nothing inside the declared radius still authors a retired visibility alias', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + // `(?!visibleWhen["'])`: an alias-pointer map entry (`visibleOn: 'visibleWhen'`) names the fix, not a predicate. + const VISIBLE_ON = /(^|[^\w.$])["']?visibleOn["']?[ \t]*:\s*(["'](?!visibleWhen["'])[^`"'\n]|\{\s*["']?(dialect|source)\b|CEL\()/m; + const VISIBILITY = /(^|[^\w.$])["']?visibility["']?[ \t]*:\s*(["'](record|page|current_user|data|user)\.|\{\s*["']?(dialect|source)\b)/m; + + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string, markdown = false): RegExpExecArray | null => { + const t = markdown ? stripInlineCode(text) : text; + return VISIBLE_ON.exec(t) ?? VISIBILITY.exec(t); + }; + + /** + * Structural exclusions — each file's JOB is to spell a retired key: a + * refusal pin, a "not read" pin, the conversion fixtures, or the stored-row + * replay pins. ⛔ NOT an allowlist file (`spec-property-retirement` §4). + */ + const EXCLUDED = new Set([ + THIS_FILE, + // Per-shape refusal pins, beside each schema. + 'packages/spec/src/ui/view.test.ts', + 'packages/spec/src/ui/page.test.ts', + 'packages/spec/src/ui/section-group-reference.test.ts', + 'packages/spec/src/ui/view-form-features-root.test.ts', + // The page block's own `visibleOn` refusal (its sections and entries). + 'packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts', + // The lint family's "the retired key is not read" pins. + 'packages/lint/src/validate-visibility-predicates.test.ts', + 'packages/lint/src/validate-predicate-path-refs.test.ts', + 'packages/lint/src/authoring-rule-input-tier.test.ts', + // A legacy artifact's `visibleOn`, forward-converted before the detector. + 'packages/metadata-core/src/form-predicate-root-policy.test.ts', + // Refusal pins on surfaces that never declared the alias (an object field + // group; a `page:tabs` item, whose pointer at `visibleWhen` they pin). + 'packages/spec/src/data/object-strictness-batch20.test.ts', + 'packages/spec/src/shared/visible-when-alias-guidance.test.ts', + 'packages/spec/src/ui/component.test.ts', + // The view write door's generic moved-key projection, modelled on the old fold. + 'packages/metadata-protocol/src/protocol.project-storable-view-body.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversions' fixtures and the stored-row replay pins author the pre-18 shapes on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output, reached only because this is a FILESYSTEM walk. + 'packages/spec/json-schema/', + ]; + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores prose, prescriptions and the unrelated `visibility` keys (anti-vacuity)', () => { + // Offenders — the retired shapes, in each syntax the walk reads. + expect(judge("sections: [{ label: 'S', visibleOn: \"record.a == 1\" }]")).not.toBeNull(); + expect(judge(" fields: [{\n field: 'x',\n visibleOn:\n 'record.b != 1',\n }]")).not.toBeNull(); + expect(judge('{ "field": "x", "visibleOn": { "dialect": "cel", "source": "record.a" } }')).not.toBeNull(); + expect(judge("{ type: 'record:list', visibility: \"page.selectedId != ''\" }")).not.toBeNull(); + expect(judge(" visibility: 'record.stage == won'\n")).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge('a section that wrote `visibleOn: "record.a"` is refused', true)).toBeNull(); + expect(judge("visibleOn:\n '`visibleOn` is the retired spelling of `visibleWhen`'")).toBeNull(); + expect(judge('visibleOn: retiredKey(FORM_FIELD_VISIBLE_ON_RETIRED),')).toBeNull(); + expect(judge(" visibleOn: 'visibleWhen',")).toBeNull(); + expect(judge("visibility: 'private',")).toBeNull(); + expect(judge("visibility: PackageVisibilitySchema.default('org'),")).toBeNull(); + expect(judge("visibleWhen: \"record.a == 1\"")).toBeNull(); + }); + + it('no retired visibility-alias authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text, ext === '.md' || ext === '.mdx'); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree and the example apps' sources. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'a retired visibility-alias authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json index 95013d585ac..366d85d68a7 100644 --- a/packages/spec/test-typecheck-debt.json +++ b/packages/spec/test-typecheck-debt.json @@ -234,8 +234,7 @@ "src/ui/view.test.ts": { "TS2322: Type 'string' is not assignable to type '…'.": 1, "TS2322: Type '…' is not assignable to type 'FormField'.": 4, - "TS2339: Property 'description' does not exist on type '…'.": 1, - "TS2339: Property 'visibleOn' does not exist on type 'FormField'.": 2 + "TS2339: Property 'description' does not exist on type '…'.": 1 }, "src/ui/widget.test.ts": { "TS18046: 'result.options.editor' is of type 'unknown'.": 1 diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index fed389123fc..9c6c92742bf 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -61,5 +61,6 @@ "src/ui/page-header-show-star-retirement.test.ts", "src/ui/view-data-api-provider-retirement.test.ts", "src/ui/view-item-owner-hidden-retirement.test.ts", - "src/ui/view-list-tabs-retirement.test.ts" + "src/ui/view-list-tabs-retirement.test.ts", + "src/ui/visibility-alias-keys-retirement.test.ts" ] diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 5ab49265fa6..f36cbf4fa7c 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -48,7 +48,7 @@ Server-connected create/edit/view form for one object. Config props come from th | `drawerWidth` | `string` | data | | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | | `modalSize` | `'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'` | data | | Modal size (modal forms) | | `splitDirection` | `'horizontal' \| 'vertical'` | data | | Split orientation (split forms) | -| `sections` | `any[]` | data | | | +| `sections` | `object[]` | data | | | | `subforms` | `object[]` | data | | Inline master-detail child collections | | `submitBehavior` | `object` | data | | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. |