Skip to content

Commit 02bec6c

Browse files
fix(spec): a page component's visibleWhen names no data root — the shipping renderer mounts none (#23146)
Fixes #23036 Clause-②: no The `visibleWhen` describe on `PageComponentSchema` and the 「Ambient roots」 docblock above it now name only what the shipping renderer mounts. They say that no `data` root is mounted on this surface, as objectstack-ai/objectui#9308 ruling B left it. ⛔ No `data` root is bound or refused in code, no accept set moves, and there is no renderer change. Text only, plus pins, the regenerated reference page, and a patch changeset. Spelling note: the schema text writes page state as `page.` followed by a variable name in angle brackets. That fragment does not survive a GitHub body, so it is written `page.VAR` below. ## What the renderer mounts (the readings the new text states) All readings are at objectui `origin/main` `f228d710`. The four evaluator files (`SchemaRenderer.tsx`, `ExpressionProvider.tsx`, `hooks/useExpression.ts`, `containers.tsx`) are byte-identical at the `.objectui-sha` pin `1fb1ced36243` (`diff` of each file at the two commits: identical). - **The page component's evaluator.** `packages/react/src/SchemaRenderer.tsx:665-676` (`configEvaluationScope`) builds `{ ...predicateScope, current_user: predicateScope.user, record (only when a row is bound), page }`. The memo builds this surface's evaluator from it (`:1228-1229`), and the `visibleWhen` leg calls `evaluator.evaluateCondition` on that evaluator (`:1319`, the production branch). No `data` key. The decision is written at `:1194`: "⛔ `data` is NOT here, and the absence is the decision (objectui#9308, maintainer ruling 2026-09-13 option B)". - **The ambient scope, which is the predicate-scope channel.** `predicateScope` is `usePredicateScope()`, fed through `PredicateScopeProvider`. In the console its only production feeder is app-shell's `ExpressionProvider`: `git grep PredicateScopeProvider` over `packages/*/src` finds one non-test provider mount, `ExpressionProvider.tsx:306`. Its `buildExpressionScope` (`ExpressionProvider.tsx:181-190`) returns `{ current_user, user, ctx: { user }, os: { user }, features }`. So `features` and `os.user` are mounted, and `data` is not. - **A `page:tabs` item's `visibleWhen`.** This is a separate evaluator, `packages/components/src/renderers/layout/containers.tsx:642-649`. It spreads the row's bare fields, spreads `predicateScope`, and binds `current_user`, `record: row`, `data: row` and `page`. The old "record ROW" sentence is still true, so it stays. - **What a `data.*` gate does on the component.** `ExpressionEvaluator.evaluateCondition` (`packages/core/src/evaluator/ExpressionEvaluator.ts:341` onward) answers a faulting predicate with the fail-soft `true`, and the SHOW leg negates that into "not hidden". So the component renders whether the predicate is true or false. This matches the card's measurement through `SchemaRenderer` at `d758f2f2` (`data.total > 0` and `data.total == 0` both SHOWN). - **The planned API binding.** objectstack-ai/objectui#12197 (the renderer half of `provider: 'api'`) is open. `isElementDataSourceConfig` (`packages/core/src/data-scope/element-data-source.ts:169`) still admits only a plain object with a string `object`. No renderer reads the API binding today. ## Each changed sentence, FROM → TO ### `PageComponentSchema.visibleWhen` `.describe()` (published; republished into `content/docs/references/ui/page.mdx`) The sentences before and after these are unchanged ("Visibility predicate (CEL) … only when TRUE.", "Contract-bound roots: …", and the closing `e.g.` example). - FROM: "The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only)." TO: "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`." Backed by: `configEvaluationScope`, `buildExpressionScope` and `evaluateCondition` above. - NEW: "`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." Backed by: the `provider: 'api'` describe (`page.zod.ts:502`, unchanged) and objectui#12197 still open. - FROM: "⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead." TO: "⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW." Backed by: `containers.tsx:642-649`. ### The TSDoc docblock above `visibleWhen` (read by whoever opens the file) - 「Ambient roots」 FROM: "The shipping renderer additionally mounts `features` and `os.user` from app-shell's `ExpressionProvider`, and binds `data`." TO: "… from app-shell's `ExpressionProvider`." The rest of the paragraph (no ADR rules those roots on this surface) is unchanged. - Measurement line FROM: pin `190fbd01d061` and objectui `f2732afe`, `ExpressionProvider.tsx:59`, `SchemaRenderer.tsx:463-471`. TO: pin `1fb1ced36243` and objectui `f228d710`, `ExpressionProvider.tsx:181-190` (`buildExpressionScope`: `current_user`, `user`, `ctx.user`, `os.user`, `features`, and nothing else), and `SchemaRenderer.tsx:665-676` (`configEvaluationScope`, adding `record` when the page has a row, and `page`). - Section FROM: "⚠️ `data` is surface-dependent. HERE `data` is the data-source ADAPTER — what `${data.total}` in a `properties` / `props` / `content` value interpolates against. On a `page:tabs` item-level `visibleWhen` a separate evaluator (`containers.tsx:450-457`) binds `data` to the record ROW instead. Same key name, two bindings; …" TO: two sections. - "No `data` root on this surface": ruling B removed `data` as a runtime expression root (`SchemaRenderer.tsx:1194`). A data scope reaches a predicate only through the predicate-scope channel, whose one shipping feeder publishes no `data`. So a `data.*` read faults and the component renders in both polarities. The row is `record`. Then: the one `data` this surface declares is the PLANNED API binding's, read once that binding's renderer ships, and no element has a `data` to read today. - "⚠️ A `page:tabs` item is a different evaluator": `containers.tsx:642-649` binds `data` to the row and spreads the row's bare fields. That is a binding this surface does not have. - The `${data.total}` interpolation claim is dropped, not reworded. `properties` values are evaluated by the same `configEvaluationScope` evaluator, so `data` does not resolve there either. ### How the two texts agree (dispatch assumption A3) `page.zod.ts:502` (unchanged) says the PLANNED API binding "binds the selected value, which the element reads as `data` in its expression holes and its `visibleWhen`". The new `visibleWhen` text makes both statements true together. Today no `data` root is mounted. A `data` read exists only on an element that carries the API binding, and only once that binding's renderer ships. `:502` did not have to change, so there is no open question. The new pin holds the agreement: it goes red if the API binding's describe loses its `[PLANNED — not yet rendered]` marker while this text still calls it planned. ## Verification record Every reading below is at head `8f64f1ae3` (`git rev-parse --short HEAD`), which is `f43d856ca` plus `96b07ccb9` plus a merge of `origin/main` `dfb3ba2b2`. The merge touched no file in `packages/spec`. - **Pins.** New `packages/spec/src/ui/expression-scope-data-root.pin.test.ts`. It checks six things: - the mounted-roots sentence names exactly `features` and `os.user`, on both faces; - each published sentence that names `data` is one of the three true readings (not mounted, PLANNED binding, `page:tabs`); - the two shapes the retired claim was written in are absent; - the `page:tabs` row reading survives; - the contract-bound roots survive; - the PLANNED agreement with `ElementApiDataSourceSchema`'s describe holds. DARK controls run the classifier over the replaced sentence and require it to fail. They also prove the splitter keeps dotted tokens together, and that a missing anchor throws. The existing `expression-scope-app-root.pin.test.ts` drops its two `data` survival controls (`toContain('`data`')` on the describe, `toContain('binds `data`')` on the docblock), and its `app` assertions are unchanged. - **Red first.** I committed the fix, wrote BASE `bcebd8409`'s `page.zod.ts` to disk under a `trap … EXIT INT TERM` restore, and ran the new pin. On-disk proof: retired-claim grep 1, new-claim grep 0, blob `7519cc64` against HEAD blob `62efbb6f`. Result: `Tests 5 failed | 6 passed (11)`. The five red assertions are both mounted-roots sentences (`expected [ 'features', 'os.user', 'data' ] to deeply equal [ 'features', 'os.user' ]`), both absence assertions, and the PLANNED-binding sentence. Restore was proved by blob `62efbb6f` equal to the HEAD blob and an empty `git diff HEAD`. `git status --porcelain` was empty afterwards. - **Spec suite, both projects.** `pnpm --filter @objectstack/spec exec vitest run --project local --project repo --maxWorkers=2` reported `Test Files 717 passed (717)` and `Tests 20995 passed | 1 todo (20996)`, with VERDICT command-exit 0. - **Generated artifacts.** `pnpm --filter @objectstack/spec build`, then `check:generated` named exactly one stale artifact (`content/docs/references/ui/page.mdx`, two rows). After `gen:docs`, the re-run printed `✓ All 14 generated artifacts are up to date`. - **Gate families.** I derived them with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `8f64f1ae3`: 109 commands. All except `pnpm check:type-check-debt` were run, which is NOT MEASURED by this dispatch's order. Each exit was captured before any pipe. Results: 102 exit 0 on the first pass. Six exited 3 with `PREREQUISITE NOT MET` because unbuilt packages meant nothing was measured: `@objectstack/lint` `check:doc-formula-expressions` and `check:doc-security-posture`, spec `check:skill-examples`, `check:docs-transcript-drift`, `check:lean-entry-closure`, and `check:dual-build-cjs-loads`. After building the `@objectstack/lint...`, `@objectstack/objectql...` and `@objectstack/client-react...` closures, the first five re-ran exit 0. Then I built the rest of the workspace (`turbo run build --filter=!@objectstack/docs`, 74 tasks, 35 cached), and `check:dual-build-cjs-loads` re-ran exit 0: `108 published require entry point(s) across 67 package(s) load`. `--ran` reconciliation over the final record (every line carries its exit code): `109 derived famil(ies) accounted for — 108 run, 1 NOT-MEASURED (0 DERIVED from a recorded exit 3, 1 claimed)`. The one claimed is `check:type-check-debt`, by order. `pnpm --filter @objectstack/spec typecheck` exited 0. Both pin files are in `tsconfig.test.json`'s program (`tsc --listFilesOnly`, counted). - **Lint.** The `os validate` reading for dispatch assumption A4 is under Acceptance notes. - **Not run locally, declared narrowing.** `turbo ls --affected` names 82 packages: `@objectstack/spec` and every dependent. This diff changes `.describe()` and TSDoc text in one spec file and nothing else in any package. No export, type, accept set or wire shape moves (`check:api-surface` and `check:authorable-surface` are green). `git grep` for the old and new sentences outside `packages/spec/src` finds only the regenerated reference page and this PR's changeset. So the spec package's own suite is what this change can move. The other 81 packages' suites are CI's Test Core. Blind spot, stated with that claim: `turbo ls --affected` comes from the package dependency graph, so it cannot see a test that reads another package's source from disk. `check:cross-package-test-inputs` is green, with every such read declared. The repo-root suites are not on the graph. - **Changeset.** `.changeset/23036-visiblewhen-no-data-root.md`: `@objectstack/spec` patch, `Clause-②: no`. `check-adr-0087-registration` reads it as non-breaking, so no marker is owed. ## Acceptance notes - **Assumption A4, corrected rule name.** The rule that answers a `data.`-rooted page-component predicate is `visibility-root-mislayered` (severity `warning`), not `visibility-bare-identifier`. `data` is in `@objectstack/formula`'s `SCOPE_ROOTS`, so the bare-identifier rule never fires on it. Reading: `pnpm --filter @objectstack/lint exec vitest run src/validate-visibility-predicates.test.ts` filtered to the page-component and layer cases gave `48 passed | 148 skipped`. That covers "a PAGE COMPONENT predicate is still read through the page-side `visibility` key" and "resolves predicates stored as `{ dialect, source }` envelopes", both `[VISIBILITY_ROOT_MISLAYERED]` on `data.x`. The rule's runtime message already says such a predicate "never matches and the element always renders", which agrees with the new describe. No lint change is owed. - **Same-family residue outside this claim's file surface (not changed here).** `packages/spec/src/ui/component.zod.ts:994-995`, in the TSDoc of the `page:tabs` item `visibleWhen`, says "`data` is the record ROW here, where the component-node evaluator binds it to the data-source ADAPTER". The second half is the claim this PR retires. The same docblock cites `containers.tsx:450-457` at pin `190fbd01d061`; the lines are `642-649` at `1fb1ced36243`. The published describe at `:1017` ("binds `data` to the record ROW (not the data-source adapter)") is still true. The claim's file surface names `page.zod.ts` only, so I stopped at that fence. Suggested text for whoever carries it: "**`data` is the record ROW here**; the component-node evaluator mounts no `data` root at all (see `PageComponentSchema.visibleWhen`)". Its pin would be one more TSDoc face in `expression-scope-data-root.pin.test.ts`. - **A stale code comment in `@objectstack/lint`.** `packages/lint/src/validate-visibility-predicates.ts:343` records a measurement "a page component additionally `page` and the data-source adapter `data`". The argument it supports still holds, because `data` is in `SCOPE_ROOTS` either way. Comment only, with no author-visible reach. Carrier: none. - **For the API binding's renderer (objectui#12197).** Under ruling B, `SchemaRenderer` binds no `data` of its own. So the planned `data` read in an element's `visibleWhen` has one existing way in, the predicate-scope channel, which ruling B left un-shadowed for exactly this. This is an observation for that card's carrier, not a decision made here. - **Labels.** Zero label writes: this dispatch's write budget names none. --- _Generated by [Claude Code](https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 121847a commit 02bec6c

5 files changed

Lines changed: 261 additions & 19 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): a page component's `visibleWhen` describe no longer says the renderer binds `data` — the shipping renderer mounts no `data` root there (#23036)
6+
7+
Clause-②: no
8+
9+
The `.describe()` on `PageComponentSchema.visibleWhen` said "The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here", and the TSDoc above it said the same. The console's renderer stopped binding `data` on this surface (objectui ruling on the renderer seam's data scope, 2026-09-13): a `data.*` read there now faults, and a faulting `visibleWhen` renders the component, so a gate written from the old text, such as `data.total > 0`, shows the component whether it is true or false.
10+
11+
The describe now names what the renderer mounts beyond the contract-bound roots (`features` and `os.user`), states that no `data` root is mounted, and says the one declared `data` read on this surface is the PLANNED API binding's (`dataSource: { provider: 'api' }`), once that binding's renderer ships. The `page:tabs` item-level `visibleWhen`, a different evaluator that binds `data` to the record row, is still called out. Nothing an author can write changes: the accept set is the same, and only the description text and the generated reference page move.
12+
13+
**If you wrote a page component gate on `data.*`:** gate on the page's row as `record.*`, or on page state as `page.<var>`.

‎content/docs/references/ui/page.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ View filter rule
301301
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
302302
| **className** | `string` | optional | CSS class names |
303303
| **responsiveStyles** | `{ large?: Record<string, string \| number>; medium?: Record<string, string \| number>; small?: Record<string, string \| number>; xsmall?: Record<string, string \| number> }` | optional | Per-breakpoint scoped style maps (ADR-0065) |
304-
| **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.<var>`. The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" |
304+
| **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.<var>`. 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 != ''" |
305305
| **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
306306
| **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 |
307307
| **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. |
@@ -440,7 +440,7 @@ Print declaration: paper size, orientation, margins, the running header and foot
440440
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
441441
| **className** | `string` | optional | CSS class names |
442442
| **responsiveStyles** | `{ large?: Record<string, string \| number>; medium?: Record<string, string \| number>; small?: Record<string, string \| number>; xsmall?: Record<string, string \| number> }` | optional | Per-breakpoint scoped style maps (ADR-0065) |
443-
| **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.<var>`. The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" |
443+
| **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.<var>`. 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 != ''" |
444444
| **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
445445
| **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 |
446446
| **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. |

‎packages/spec/src/ui/expression-scope-app-root.pin.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@ describe('no UI prose face advertises `app` as an expression-scope root — the
101101
expect(mounts).not.toMatch(/`app`/);
102102

103103
// Survival controls — deleting the token must not have taken the sentence with it.
104+
// (`data` is no longer one of them: the renderer stopped mounting it too, and
105+
// `expression-scope-data-root.pin.test.ts` holds that half.)
104106
for (const root of SURVIVING_ROOTS) expect(mounts).toContain(root);
105-
expect(mounts).toContain('`data`');
106107
expect(mounts).toContain('NOT contract-guaranteed');
107108

108109
// Contract-bound roots are a different clause and are untouched.
@@ -117,7 +118,6 @@ describe('no UI prose face advertises `app` as an expression-scope root — the
117118

118119
expect(s).not.toMatch(/`app`/);
119120
for (const root of SURVIVING_ROOTS) expect(s).toContain(root);
120-
expect(s).toContain('binds `data`');
121121
});
122122

123123
it('action.zod.ts — the param-level `visible` scope list', () => {
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23036] A page component's `visibleWhen` has no `data` root, and neither of
5+
* its two prose faces may say the shipping renderer mounts one.
6+
*
7+
* ## The fact being pinned
8+
*
9+
* objectui#9308 ruling B (2026-09-13) removed `data` as a runtime expression
10+
* root: `SchemaRenderer` no longer publishes its injected data source as
11+
* `data`, and the one scope channel left (`PredicateScopeProvider`) is fed in
12+
* the shipping console only by app-shell's `ExpressionProvider`, which
13+
* publishes `current_user`, `user`, `ctx.user`, `os.user` and `features`. So a
14+
* `data.*` read in this predicate faults, and this surface answers a faulting
15+
* predicate by rendering the component: `data.total > 0` and `data.total == 0`
16+
* both show it. The `.describe()` said the renderer "binds `data` to the
17+
* data-source ADAPTER here" — a gate written from it never gates, and nothing
18+
* refuses it. #17203 corrected the same sentence for `app`
19+
* (`expression-scope-app-root.pin.test.ts`); this file holds the `data` half.
20+
*
21+
* ## What is pinned: the claim, not the wording
22+
*
23+
* 1. The mounted-roots sentence names exactly `features` and `os.user`, on both
24+
* faces (the published `.describe()` and the TSDoc an AI author reads).
25+
* 2. On the published face, every sentence that names `data` says it is NOT
26+
* mounted here, ties it to the PLANNED API binding, or is about the
27+
* `page:tabs` item evaluator — the three readings that are true today.
28+
* 3. The two texts in this file agree. The `visibleWhen` face names the
29+
* PLANNED API binding as the only `data` carrier, which holds exactly while
30+
* `ElementApiDataSourceSchema`'s own describe still carries
31+
* `[PLANNED — not yet rendered]`. When that binding's renderer ships and its
32+
* describe drops the marker, this pin goes red so the `visibleWhen` face is
33+
* rewritten in the same change, not left behind.
34+
*
35+
* Rewording any of these sentences is free; a sentence that tells an author
36+
* the renderer binds `data` on this surface is not.
37+
*/
38+
39+
import fs from 'node:fs';
40+
import path from 'node:path';
41+
import url from 'node:url';
42+
43+
import { describe, it, expect } from 'vitest';
44+
45+
import { ElementApiDataSourceSchema, PageComponentSchema } from './page.zod';
46+
47+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
48+
const pageSource = fs.readFileSync(path.resolve(HERE, 'page.zod.ts'), 'utf8');
49+
50+
/**
51+
* The published description of `PageComponentSchema.visibleWhen`.
52+
*
53+
* ⚠️ NOT `.shape` — ADR-0089 D3a made this schema a `.strict().transform(…)`
54+
* pipe, so the object carrying the property descriptions is its INPUT side.
55+
*/
56+
const visibleWhenDescription = (): string => {
57+
const shape = (PageComponentSchema as unknown as {
58+
def: { in: { shape: Record<string, { description?: string }> } };
59+
}).def.in.shape;
60+
const d = shape.visibleWhen.description;
61+
expect(d, 'the describe() must exist — this pin is about its content').toBeTruthy();
62+
return d!;
63+
};
64+
65+
/**
66+
* The TSDoc block directly above the `visibleWhen:` property, with the comment
67+
* gutter stripped and its paragraphs (headings included) kept apart by a blank
68+
* line. Anchored on its first line and on the property itself, so a block that
69+
* moves or loses either fails here instead of asserting over nothing.
70+
*/
71+
const visibleWhenDocblock = (): string => {
72+
const start = pageSource.indexOf('Conditional-visibility predicate (CEL) — the component is rendered only when');
73+
const end = pageSource.indexOf('visibleWhen: EvaluatedExpressionInputSchema', start);
74+
expect(start, 'docblock anchor not found — the pin has drifted off its site').toBeGreaterThan(-1);
75+
expect(end, 'property anchor not found after the docblock').toBeGreaterThan(start);
76+
return pageSource.slice(start, end)
77+
.split('\n')
78+
.map((line) => line.replace(/^\s*\*\/?\s?/, '').trimEnd())
79+
.join('\n')
80+
.split(/\n\s*\n/)
81+
.map((paragraph) => paragraph.replace(/\s*\n\s*/g, ' ').trim())
82+
.filter(Boolean)
83+
.join('\n\n');
84+
};
85+
86+
/**
87+
* Sentences of a prose face. A paragraph break ends one; a period, `!` or `?`
88+
* ends one only when white space and a capital, a backtick or `⚠` follow — so
89+
* dotted tokens (`os.user`, `page.<var>`, `data.*`) and "e.g." stay inside
90+
* their sentence.
91+
*/
92+
const sentences = (text: string): string[] =>
93+
text.split(/\n\n|(?<=[.!?])\s+(?=[A-Z`⚠])/).map((s) => s.trim()).filter(Boolean);
94+
95+
/** The sentence a mounted-roots claim lives in, located by its anchor. */
96+
const sentenceAt = (text: string, anchor: string): string => {
97+
const hit = sentences(text).find((s) => s.includes(anchor));
98+
expect(hit, `anchor not found — the pin has drifted off its site: ${anchor}`).toBeTruthy();
99+
return hit!;
100+
};
101+
102+
/** Every backticked token in a sentence: the roots it names. */
103+
const backticked = (s: string): string[] => [...s.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
104+
105+
/**
106+
* The published-face rule (2): sentences naming `data` that are none of the
107+
* three true readings. Returned rather than asserted, so the controls below can
108+
* run it on a text that MUST fail.
109+
*/
110+
const dataClaimsOutsideTheTrueReadings = (text: string): string[] =>
111+
sentences(text)
112+
.filter((s) => s.includes('`data`'))
113+
.filter((s) => !/\bNO `data`/.test(s) && !s.includes('PLANNED') && !s.includes('`page:tabs`'));
114+
115+
/** The sentence shapes the retired claim was written in, on either face. */
116+
const RETIRED_CLAIM_SHAPES = [/binds `data` to the data-source/i, /`data` is the data-source/i] as const;
117+
118+
const MOUNTED_ROOTS = ['features', 'os.user'];
119+
120+
describe('a page component `visibleWhen` names no `data` root — the shipping renderer mounts none', () => {
121+
describe('published face — `PageComponentSchema.visibleWhen`.describe()', () => {
122+
it('the mounted-roots sentence names exactly `features` and `os.user`', () => {
123+
const s = sentenceAt(visibleWhenDescription(), 'additionally mounts');
124+
expect(backticked(s)).toEqual(MOUNTED_ROOTS);
125+
expect(s).toContain('NOT contract-guaranteed');
126+
});
127+
128+
it('states the absence, and no sentence claims `data` is bound here', () => {
129+
const d = visibleWhenDescription();
130+
expect(d).toContain('mounts NO `data` root');
131+
expect(dataClaimsOutsideTheTrueReadings(d)).toEqual([]);
132+
for (const shape of RETIRED_CLAIM_SHAPES) expect(d).not.toMatch(shape);
133+
});
134+
135+
it('keeps the `page:tabs` item reading, which is still true: that evaluator binds `data` to the row', () => {
136+
const s = sentenceAt(visibleWhenDescription(), '`page:tabs`');
137+
expect(s).toContain('`data`');
138+
expect(s).toContain('ROW');
139+
});
140+
141+
it('keeps the contract-bound roots, a different clause this card does not touch', () => {
142+
const s = sentenceAt(visibleWhenDescription(), 'Contract-bound roots');
143+
for (const root of ['record', 'current_user', 'user', 'ctx.user']) expect(backticked(s)).toContain(root);
144+
});
145+
});
146+
147+
describe('TSDoc face — the docblock above `visibleWhen` (no generator reads it; an AI author opening the file does)', () => {
148+
it('the "Ambient roots" sentence names exactly `features` and `os.user`', () => {
149+
const s = sentenceAt(visibleWhenDocblock(), 'The shipping renderer additionally mounts');
150+
expect(backticked(s)).toEqual([...MOUNTED_ROOTS, 'ExpressionProvider']);
151+
});
152+
153+
it('states the absence, and carries neither shape the retired claim was written in', () => {
154+
const doc = visibleWhenDocblock();
155+
expect(doc).toContain('mounts NO `data` root');
156+
for (const shape of RETIRED_CLAIM_SHAPES) expect(doc).not.toMatch(shape);
157+
});
158+
});
159+
160+
describe('the two texts in this file agree — the PLANNED API binding is the only `data` carrier', () => {
161+
it('`visibleWhen` names the PLANNED API binding as where `data` is declared', () => {
162+
const s = sentences(visibleWhenDescription()).find((x) => x.includes('PLANNED'));
163+
expect(s, 'the describe no longer names the planned binding').toBeTruthy();
164+
expect(s).toContain('`data`');
165+
expect(s).toContain("provider: 'api'");
166+
});
167+
168+
it('…which holds only while the API binding is still PLANNED and still declares a `data` read in `visibleWhen`', () => {
169+
const api = ElementApiDataSourceSchema.description ?? '';
170+
expect(
171+
api.startsWith('[PLANNED — not yet rendered]'),
172+
'The API binding is no longer marked PLANNED, so its renderer has shipped: on an element carrying '
173+
+ 'that binding `data` is now mounted. Rewrite `PageComponentSchema.visibleWhen`\'s `data` sentence '
174+
+ '(describe and docblock) in the same change, then this assertion.',
175+
).toBe(true);
176+
expect(api).toContain('`data`');
177+
expect(api).toContain('`visibleWhen`');
178+
});
179+
});
180+
181+
describe('probe controls — a pass above is only a reading if these hold', () => {
182+
/**
183+
* DARK. The describe sentence this card replaced, verbatim. Rule (2) and
184+
* the retired-shape list must both catch it, or their `toEqual([])` /
185+
* `not.toMatch` arms above could be passing on a matcher that matches
186+
* nothing.
187+
*/
188+
it('DARK: the replaced describe sentence fails rule (2) and the retired-shape list', () => {
189+
const replaced = 'Contract-bound roots: `record`, `current_user`, and page state as `page.<var>`. '
190+
+ 'The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source '
191+
+ 'ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). '
192+
+ '⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead.';
193+
expect(dataClaimsOutsideTheTrueReadings(replaced)).toHaveLength(1);
194+
expect(backticked(sentenceAt(replaced, 'additionally mounts'))).toContain('data');
195+
expect(RETIRED_CLAIM_SHAPES.some((shape) => shape.test(replaced))).toBe(true);
196+
});
197+
198+
/** DARK. The sentence splitter keeps dotted tokens and "e.g." inside one sentence. */
199+
it('DARK: dotted tokens do not split a sentence', () => {
200+
expect(sentences('Mounts `os.user` and `page.<var>`; e.g. "page.x != \'\'". Next one.')).toHaveLength(2);
201+
});
202+
203+
/** DARK. A site renamed out from under this pin fails loudly, never vacuously. */
204+
it('DARK: a missing anchor fails the pin rather than passing over nothing', () => {
205+
expect(() => sentenceAt(visibleWhenDescription(), 'no such anchor exists in this describe')).toThrow();
206+
});
207+
});
208+
});

0 commit comments

Comments
 (0)