Skip to content

Commit cbe6d87

Browse files
feat(lint)!: the visibility-predicate family runs on the page save door (ruling B on #23388) (#24509)
Fixes #24491 Clause-②: no (narrowing) The visibility-predicate family now runs at the runtime save door for a `page` as it does for a `view`. This follows the principle of ruling B on #23388 (ruling of record `6161173492`, the maintainer: 「23388 如果页面有问题,不是应该修改了再保存吗?」): a page with a finding is fixed, then saved, so one rule holds at every door that accepts a page. A page component `visibleWhen` that `os validate` / `os build` / `os lint` refuse is now refused at Studio, `PUT /api/v1/meta/page/:name` and MCP with the build's own finding: same rule, location, message and hint. ## What changes - **`packages/lint/src/authoring-rules.ts`:** `validateVisibilityPredicates` and `validatePredicatePathRefs` declare `runtimeTypes: ['view', 'page']`. The #7220 family comment block gains a section on the page crossing: the ruling, the measurement that no gate change was needed, the inert path half, stored pages, and the corpus count. The runtime-input-tier paragraph now names the page-side `visibility` tombstone. The `validatePredicatePathRefs` entry comment says it declares `page` and judges nothing there. The rules' own code is not touched. - **No gate change (G2, measured).** `page` already maps to `pages` in `TYPE_TO_STACK_KEY`, and a page snapshot is `pages: [item]` with no `views`. The P1 "after" reading below shows both rules dispatched and the build's finding returned. `packages/lint/src/runtime-gate.ts` is unchanged, and so is `packages/metadata-protocol/src/protocol.ts`. - **Lint-level pin:** new `packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts`, shaped like `runtime-gate.object-action-predicate-writes.test.ts`, through `runtimeAuthoringRulesFor('page')` / `runRuntimeAuthoringRules`. It covers: - the exact page rule set; - five refused bodies: an unknown function bare, in the `P` envelope, and as `current_user.canApprove(...)`; a bare identifier (the wrong reference); a syntax fault; - six admitted bodies (`has()`, `size()`, `upper()`, `page.VAR`, `current_user.*`, the `P` envelope); - parity: the door's finding equals the build's, whole finding; - the path half on a page; - the `view` control. - **Protocol-level pin** (the engine-lane rider declared on #6367, `6262594842`): a block in `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, beside the #23388 page block, through the real `saveMetaItem` / `publishMetaItem` / `loadMetaFromDb` / `getMetaItem` for a `page`. It covers: - (a) an active save of `totallyBogusFn(1)` is refused with `422 INVALID_METADATA`, rule `visibility-predicate-unknown-function` at `pages[0].regions[0].components[0]`, and nothing is stored; the draft promotion is refused the same way; - (b) door and build agree key by key (rule, where, path, message, hint); - (c) three pages calling only registered functions save, and the build agrees; - (d) a stored page carrying the predicate loads (`loaded: 1, errors: 0, invalid: 0`), registers, and is served as stored. Its next save is refused, the stored row is unchanged, and the fixed page then saves. - **Changeset:** `.changeset/24491-page-save-door-visibility-predicates.md`, `@objectstack/lint` `minor` with the BREAKING banner. The level is the one PR #23418 and the #22032 object-door crossings took for an accept-set narrowing during `next` pre mode. The changeset has a `!` title, `Clause-②: no (narrowing)`, FROM / TO / Fix lines, the stored-page sentence, and the ADR-0087 disposition `not-required (no-migration-prescription)`. The disposition is explained under G3 below. ## Premises (each measured on this branch) **P1. Holds.** One probe ran before and after, on the page `{ type: 'page:header', properties: { title }, visibleWhen: 'totallyBogusFn(1)' }`. - Before (`origin/main` `ce2e08ee3b`, lint built from it): - `runtimeAuthoringRulesFor('page')` = `["validatePresetComparands","validateComponentProps","validateComponentTypes","validatePrintPageBlocks"]`. - The door's `errors` = `[]`. - `runAuthoringRules('validate')` errors = `[{"rule":"visibility-predicate-unknown-function","path":"pages[0].regions[0].components[0]","message":"visibility predicate calls unregistered `totallyBogusFn` (found no matching overload for 'totallyBogusFn(int)'), so view/page elements always render and actions hide for everyone"}]`. - Through the real `saveMetaItem` (the new protocol block, run against that lint build): `AssertionError: the save resolved — the door still accepts the page predicate: expected { success: true, …(4) } to be an instance of Error`. Four of the block's seven cases were red: (a), (a) promotion, (b) and (d). The three admitted cases were green. - After (this branch): - `runtimeAuthoringRulesFor('page')` = the four above + `"validateVisibilityPredicates","validatePredicatePathRefs"`. - The door's `errors` = `[{"rule":"visibility-predicate-unknown-function","path":"pages[0].regions[0].components[0]"}]`, the build's finding. - The protocol block: 7/7 green, inside 152/152 for the file. **P2. Holds for both rules, with one reading the seat should see.** - `validateVisibilityPredicates` reads `stack.views` and `stack.pages` and no other collection. It needs no object context. A page component takes the rule's default `runtime` layer, which is the correct layer for a page. - `validatePredicatePathRefs` reads `stack.views` only (`collectionEntries(stack.views, 'views')`, with no `pages` read anywhere in the file). Its oracle is `getMetadataTypeSchema(schemaId)` of a schema-bound form view, and a page component declares no `schemaId`. So the object-context question does not arise at the page door: the rule judges nothing on a page, at the door or at the build. That means no false finding and no finding at all. - The door's page rule set (all six rules) was run over every page body the platform and the examples ship: 0 errors and 0 advisories, before and after. The direct calls of the two rules found 0, and the build's family errors were 0. **P3. Holds.** No shipped page carries a predicate either rule refuses. The pages ran as follows (36 distinct; `crm_welcome` was read twice, from the composed stack and from its module): - `examples/app-crm` (composed stack): `crm_welcome`. - `examples/app-showcase` (`src/ui/pages/index.ts`, the barrel `objectstack.config.ts` imports; the composed stack did not load in this worktree because `@objectstack/connector-mcp` was unbuilt): 30 pages, `showcase_active_projects` … `showcase_task_workbench`. - `examples/app-todo` and `examples/app-multi-package` (composed stacks): 0 pages. - `packages/platform-objects`: `sys_user_detail`, `sys_organization_detail`. - `packages/plugins/plugin-approvals`: `sys_approval_request_detail`. - `packages/cloud-connection`: `cloud_connection_settings`. - `packages/mcp`: `connect_agent`. - `packages/qa/downstream-contract`: `dc_welcome`. - Templates: `packages/create-objectstack` ships no page (a `git grep` for `regions` / `pages:` there: 0 hits). The 8 component `visibleWhen` sites are `sys_user_detail` (1, a `P` envelope reading `ctx.user.id`), `showcase_contact_form` (1), `showcase_my_work` (1), `showcase_page_variables` (4) and `showcase_task_detail` (1). All 8 are clean. ## Mechanism assumptions, measured - **G1. Shape held, with one leg falsified.** "A wrong path ref is refused" does not hold for the path rule on a page, because `validatePredicatePathRefs` never reads `pages` (P2). On a page, a wrong reference is the visibility rule's verdict: - a bare identifier is refused (`visibility-bare-identifier`), and that is pinned; - a `data.`-rooted path draws the advisory `visibility-root-mislayered` at both doors and no refusal, and that is pinned as "the path half judges nothing on a page". Following the ruling's intent, both entries are wired anyway. The family is one wall on the type axis: `authoring-rule-wiring.test.ts` pins one type set for both entries, and the path rule cannot refuse a valid page because it reads none. - **G2. Held.** No `runtime-gate.ts` change was needed (above). - **G3. Held in part.** The level is `minor` + BREAKING, as in PR #23418, with a `!` title, `Clause-②: no (narrowing)` and FROM / TO / Fix lines. - #23418's disposition `already-registered` named D3 ids that recorded its props narrowings. No D3 entry covers the visibility-predicate family (a search of `packages/spec/src/migrations/entries` found none), so naming any id would be false. - The disposition is therefore `not-required (no-migration-prescription)`, as the five #22032 object-door crossings of the same shape used: a build verdict moved onto one more door. - The FROM / TO lines describe the door's answer (`200` → `422`), not a consumer rewrite, so the gate reads no prescription. `node scripts/check-adr-0087-registration.mjs --base origin/main` exits 0. - **G4. Main moved twice** (`ce2e08ee3b` → `7e58e0a27`), and I merged it through `bash scripts/pm/os-regen-merge.sh`. Steps 1–3 left no additional changes. Step 4: the spec closure was rebuilt and `pnpm --filter @objectstack/spec check:generated` reports all 14 artifacts up to date. This branch edits no generated artifact. ## Stored pages As ruling B read for the two component rules: no stored preview and no migration. A stored page with such a predicate keeps loading and serving, and its next save is refused with the fix (pin (d)). P3 found no shipped page that would be refused. ## File surface beyond the claim, and why - `packages/lint/src/validate-predicate-path-refs.test.ts` (one assertion and its comment): its registry pin asserted `runtimeTypes` equal to `['view']`, so it went red with this change (1 failed of 6888). It now asserts `['view', 'page']`. None of the 7 open PRs touches this file (their file lists were read in this act). The excluded `validate-visibility-predicates.test.ts` (PR #24478) is untouched, and it carries no `runtimeTypes` pin. - `content/docs/deployment/validating-metadata.mdx` (one table row): the runtime-publish cell lists one superscript per declared type, and `✓ᵛ` became false. It now reads `✓ᵛᵖ`, and the row title names page components, with schema path refs scoped to `views[]` only (patch round 1, contract review `6264267243` ③ 1: `validatePredicatePathRefs` walks `stack.views` only, so no door judges schema path refs on a page component). ## Verification (head `079ebf8b5`) - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: 146 files, 6893 tests passed. `typecheck` (with `check:test-typecheck`) passed. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 247 passed, 3 skipped (250 files); 20699 tests passed, 19 skipped. This ran on the pre-merge head `58b7f78bc`; the merge brought no `metadata-protocol` change. On `079ebf8b5`, `src/protocol.runtime-authoring-gate.test.ts` passed 152/152, and `typecheck` passed. - **Ablation**, after the fix was committed: - Mutation: `validateVisibilityPredicates` declares `['view', 'ablated_24491_no_page']` instead of `['view', 'page']`, through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `2572e080ca6d` → `76e2558ad8df`). Lint was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. - Mutated: the lint pin had 14 red and 1 green (the `view` control). The protocol block had 4 red ((a), (a) promotion, (b), (d)) and 3 green (the admitted pages). - Restore leg: `git checkout HEAD`, blob equal to the HEAD blob, `git diff HEAD` empty, rebuild, `--absent` (absent from all 20 built files, tree clean). Lint pin 15/15 and protocol block 7/7 green. - Consumer suites were not rerun. A `git grep` for `visibleWhen` across every `*.test.ts` that saves a `page` names no test outside `lint` and `metadata-protocol` that saves one through the door. ## Gates (head `079ebf8b5`; patch round 1 at `6d416dfc3` changed one docs line, re-ran the 40 families that docs path reaches, all exit 0, and `--ran` reconciled 93 derived: 92 run, 1 NOT-MEASURED, `check:dual-build-cjs-loads`) - **Local, derived.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths (6 changed paths vs merge base `7e58e0a27`), derived 93 families. Each ran verbatim, with its exit code recorded before any pipe. The `--ran` reconciliation answered "93 derived famil(ies) accounted for — 92 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)", exit 0. - 92 exited 0. They include `check-adr-0087-registration --base origin/main`, `check-empty-changeset --base origin/main`, `check-changeset-no-major --base origin/main`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage` and `check:type-check-debt`. - `check:skill-examples` and `check:lean-entry-closure` first exited 3, because the `client`, `client-react` and `objectql` dist were unbuilt. I built those three packages and re-ran both: exit 0. - NOT MEASURED: `check:dual-build-cjs-loads`. It exited 3 (`PREREQUISITE NOT MET`) because it needs every package built, and 72 have no dist here. Left to CI. - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts are up to date, after the merge and a spec rebuild. - **Not run locally, with the reason:** `test:repo` (no repo-project test changed, and the derivation does not name it) and `pnpm lint` (a repo-wide scan, which CI owns). - **CI check-runs:** this body is written once, at create. The seat reads the PR's check-runs. ## Acceptance notes - `authoring-rule-wiring.test.ts` says "the record of where #7220 left this: both are wired, for `view`". That is still true as a record, and its exact `view` rule list is unchanged, so it is not edited. - `validatePredicatePathRefs` on `page` is inert by construction (P2). If page components ever gain a schema-bound data source, it lights with no registry edit. --- _Generated by [Claude Code](https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a5a781b commit cbe6d87

6 files changed

Lines changed: 423 additions & 7 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: the page save door refuses a page component `visibleWhen` that `os build` refuses (#24491)
6+
7+
Clause-②: no (narrowing)
8+
9+
A page component's `visibleWhen` was judged by `os validate`, `os build` and `os lint`, and not by the metadata save door. A page saved through Studio, REST `/meta` or an MCP/AI author answered `200` and was stored when a component predicate called a function nothing registers (such as `totallyBogusFn(1)`), read a bare field (`status == 'open'`), did not parse, or overran the CEL parse budget. At render such a predicate faults, the visibility fallback is `true`, and the component renders for everyone.
10+
11+
The visibility-predicate family (`validateVisibilityPredicates`, `validatePredicatePathRefs`) now runs on a `page` write as well as on a `view` write, on the principle of ruling B on #23388: a page with a finding is fixed, then saved. The door's verdict is the build's finding: the same rule id (`visibility-predicate-unknown-function`, `visibility-bare-identifier`, `visibility-predicate-syntax`, `visibility-predicate-over-budget`), location (`pages[N].regions[N].components[N]`, and the slot or nested path the build names), message and hint. `visibility-root-mislayered` (a `data.`-rooted predicate on a page) rides the save response as an advisory, as it is a warning at the build. `validatePredicatePathRefs` judges schema-bound form views only, so it adds no finding on a page.
12+
13+
**BREAKING — what moves for consumers.** This is an accept-set narrowing on the metadata save door, shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is open in `next` pre mode).
14+
15+
- FROM: a page write in publish mode answered `200` for a component `visibleWhen` the build refuses. This covers `PUT /api/v1/meta/page/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/page/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`).
16+
- TO: it answers `422 INVALID_METADATA`, with the build's issue located at that component.
17+
- Fix: correct the predicate the issue names, as `os build` already requires, and save again. Call a function `introspectScope` lists, read through a bound root (`record.FIELD`, `current_user.FIELD`, `page.VAR`), and fix the syntax the verdict quotes. Saving the page as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.
18+
19+
**The next save of a stored page whose component `visibleWhen` the build refuses is refused with the fix; stored pages keep serving until then.** The door judges writes only: a stored page row is not re-judged at boot, loads and is served as before.
20+
21+
**Unchanged.**
22+
23+
- Stored rows are not migrated, and they are not refused on read.
24+
- A `view` write is judged as before.
25+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
26+
- Measured before crossing: the 36 pages this repository ships (the examples, `platform-objects`, `plugin-approvals`, `cloud-connection`, `mcp` and `qa/downstream-contract`) carry 8 component `visibleWhen` predicates. Neither rule finds anything in them, at the build or at the door, before this change or after it; deployed rows were not measured.
27+
- No public export or signature moves. The two registry entries gain `page` in `runtimeTypes`, and the gate itself is unchanged: `page` already mapped to `pages`.
28+
29+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at the page save door of a page component visibleWhen that the published validator already refuses at `os build`: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored page whose component predicate the validator refuses keeps loading until it is next saved, and the repair is the author's edit of the predicate, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not a declaration or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
484484
| View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — |
485485
| Flow authoring anti-patterns (#1874) | ✓ | ✓ | ✓ | ✓ᶠ |
486486
| Flow trigger readiness — a flow that looks armed and never launches (#5762) | ✓ | ✓ | ✓ | ✓ᶠ |
487-
| `views[]` conditional-visibility predicates — CEL syntax, parse budget, bare identifiers, binding-root layer, schema path refs (ADR-0089 D3b, #7010) | ✓ | ✓ | ✓ | ✓ᵛ |
487+
| `views[]` and page-component conditional-visibility predicates — CEL syntax, parse budget, bare identifiers, binding-root layer; schema path refs on `views[]` only (ADR-0089 D3b, #7010; page components at the save door #24491) | ✓ | ✓ | ✓ | ✓ᵛᵖ |
488488
| Advisory: record titles, semantic field pointers (ADR-0085), form-section layout, action placement, seed replay/state safety, capability references, liveness | ✓ | ✓ | ✓ | — |
489489
| Package docs — flatness, prefixes, links (ADR-0046) | ✓ | ✓ | ✓ | — |
490490
| Undeclared authoring keys — every metadata collection (#3786) and the stack's own top-level keys (#4167) | ✓ | ✓ | — | — |

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1579,19 +1579,48 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
15791579
// any.) The reason was not describing this rule; it was the default a rule got
15801580
// when nobody measured, which is the #4409/#4463 defect one layer in.
15811581
//
1582+
// ─── …and on `page` writes too (#24491), per ruling B on #23388 ───
1583+
//
1584+
// The family runs for `page` as well as `view`, on the principle of the
1585+
// ruling of record 6161173492 on #23388 (letter B), which moved the two page
1586+
// component rules (`validateComponentProps`, `validateComponentTypes`) to this
1587+
// door: a page with a finding is fixed, then saved, so one rule holds at
1588+
// every door that accepts a page. Before this, a page component `visibleWhen`
1589+
// calling a function nothing registers (`totallyBogusFn(1)`) was refused by
1590+
// `os validate` / `os build` / `os lint` and saved clean through Studio,
1591+
// REST `/meta/page` and MCP. The door's finding is now the build's: the same
1592+
// rule id, location, message and hint.
1593+
//
1594+
// The crossing needed no gate change. `page` already maps to `pages`, and a
1595+
// page snapshot holds the item as `pages: [item]` with no `views`, so the
1596+
// view half contributes zero findings on a page write, as the page half does
1597+
// on a view write. `validatePredicatePathRefs` walks `stack.views` only (its
1598+
// oracle is a schema-bound form's `schemaId`, which a page component does not
1599+
// declare), so it judges nothing on a page at either door. It declares `page`
1600+
// because the family is one wall on the type axis too, which
1601+
// `authoring-rule-wiring.test.ts` pins.
1602+
//
1603+
// Stored pages: the gate judges writes, never the boot or read path, so a
1604+
// stored page carrying such a predicate keeps loading and serving, and its
1605+
// next save is refused with the fix. Measured at the crossing: 36 shipped
1606+
// pages (the examples, `platform-objects`, the plugins, `qa`), 8 component
1607+
// `visibleWhen` sites, 0 findings from either rule.
1608+
//
15821609
// Runtime input tier: the gate hands the rules the body as persisted, without
15831610
// `normalizeStackInput`. That costs the family nothing: the door parses the
15841611
// body against the spec first (a 422 on refusal), and since the ADR-0089
15851612
// aliases retired at 18 (#23543 F4) an accepted body spells the predicate
1586-
// `visibleWhen` only, the one key the family reads.
1613+
// `visibleWhen` only, the one key the family reads — on a page component as
1614+
// on a view form (the page-side `visibility` retired with the view-side
1615+
// `visibleOn`).
15871616
{
15881617
name: 'validateVisibilityPredicates',
15891618
tier: 'gating',
15901619
input: 'normalized',
15911620
commands: ALL,
15921621
source: 'packages/lint/src/validate-visibility-predicates.ts',
15931622
surfaces: CLI_AND_RUNTIME,
1594-
runtimeTypes: ['view'],
1623+
runtimeTypes: ['view', 'page'],
15951624
run: (stack) => validateVisibilityPredicates(stack),
15961625
},
15971626
// #7010 — the same predicate surface, one question further in. The three
@@ -1627,9 +1656,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
16271656
source: 'packages/lint/src/validate-predicate-path-refs.ts',
16281657
// The second half of the #7220 family move — see the block above the
16291658
// `validateVisibilityPredicates` entry. This is the rule whose solo wiring
1630-
// was reverted; it is wired now because its siblings are.
1659+
// was reverted; it is wired now because its siblings are. It declares
1660+
// `page` with its sibling (#24491, ruling B on #23388) and judges nothing
1661+
// there: it walks `stack.views` only, so a page write draws no finding from
1662+
// it at either door.
16311663
surfaces: CLI_AND_RUNTIME,
1632-
runtimeTypes: ['view'],
1664+
runtimeTypes: ['view', 'page'],
16331665
run: (stack) => validatePredicatePathRefs(stack),
16341666
},
16351667
// #1874 — flow authoring anti-patterns. Advisory by default; a finding marked
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24491 — the PAGE write door runs the visibility-predicate family.
5+
*
6+
* ## The state this closes
7+
*
8+
* `validateVisibilityPredicates` and `validatePredicatePathRefs` (the #7220
9+
* family) declared `runtimeTypes: ['view']`, so `runtimeAuthoringRulesFor('page')`
10+
* left them out. A page component `visibleWhen` calling a function the CEL
11+
* environment does not register (`totallyBogusFn(1)`) was refused by
12+
* `os validate`, `os build` and `os lint`, and saved clean through Studio,
13+
* REST `/meta/page` and MCP. At render the predicate faults, the visibility
14+
* fallback is `true`, and the component renders unconditionally.
15+
*
16+
* ## The crossing
17+
*
18+
* Ruling of record 6161173492 on #23388 (letter B) moved the two page component
19+
* rules to this door on one principle: a page with a finding is fixed, then
20+
* saved. The family follows it, so both entries declare `['view', 'page']`.
21+
* No gate change: `page` already maps to `pages`, and the page snapshot holds
22+
* the item as `pages: [item]`.
23+
*
24+
* ## What the path half does on a page
25+
*
26+
* Nothing, at either door. `validatePredicatePathRefs` walks `stack.views`
27+
* only, because its oracle is a schema-bound form's `schemaId`, which a page
28+
* component does not declare. On a page, a wrong reference is the visibility
29+
* rule's verdict: a bare identifier is refused (`visibility-bare-identifier`),
30+
* and a `data.`-rooted path draws the advisory `visibility-root-mislayered`.
31+
* Both doors agree on it. The pin below says so, so that the inert half reads
32+
* as measured and not as coverage.
33+
*
34+
* The protocol-level half (the same verdict through the real `saveMetaItem`
35+
* and `publishMetaItem`, and a stored page that keeps serving until its next
36+
* save) is the #24491 block of
37+
* `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
38+
*/
39+
import { describe, expect, it } from 'vitest';
40+
import { runAuthoringRules, type AuthoringFinding } from './authoring-rules.js';
41+
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
42+
import {
43+
VISIBILITY_BARE_IDENTIFIER,
44+
VISIBILITY_PREDICATE_OVER_BUDGET,
45+
VISIBILITY_PREDICATE_SYNTAX,
46+
VISIBILITY_PREDICATE_UNKNOWN_FUNCTION,
47+
VISIBILITY_ROOT_MISLAYERED,
48+
} from './validate-visibility-predicates.js';
49+
import {
50+
PREDICATE_PATH_UNRESOLVED,
51+
PREDICATE_PATH_UNROOTED,
52+
PREDICATE_RHS_PATH_SHAPED,
53+
} from './validate-predicate-path-refs.js';
54+
55+
const FAMILY = ['validateVisibilityPredicates', 'validatePredicatePathRefs'];
56+
57+
/** Every rule id the two family entries emit; the page door also runs the component rules. */
58+
const FAMILY_IDS = new Set<string>([
59+
VISIBILITY_BARE_IDENTIFIER,
60+
VISIBILITY_PREDICATE_OVER_BUDGET,
61+
VISIBILITY_PREDICATE_SYNTAX,
62+
VISIBILITY_PREDICATE_UNKNOWN_FUNCTION,
63+
VISIBILITY_ROOT_MISLAYERED,
64+
PREDICATE_PATH_UNRESOLVED,
65+
PREDICATE_PATH_UNROOTED,
66+
PREDICATE_RHS_PATH_SHAPED,
67+
]);
68+
69+
/** One `page:header` whose props are declared, so a finding is the predicate's. */
70+
const page = (visibleWhen: unknown) => ({
71+
name: 'pipeline_home',
72+
label: 'Pipeline',
73+
type: 'app',
74+
regions: [
75+
{ name: 'main', components: [{ type: 'page:header', properties: { title: 'Pipeline' }, visibleWhen }] },
76+
],
77+
});
78+
const AT = 'pages[0].regions[0].components[0]';
79+
const WHERE = 'page "pipeline_home"';
80+
81+
/**
82+
* One refused body per finding kind, each refused by `os build` at `error`.
83+
* The first is the card's shape; the second is the `{ dialect, source }`
84+
* envelope the platform's own pages author through `P`.
85+
*/
86+
const REFUSED = [
87+
{ body: 'totallyBogusFn(1)', rule: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, subject: '`totallyBogusFn`' },
88+
{ body: { dialect: 'cel', source: 'totallyBogusFn(1)' }, rule: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, subject: '`totallyBogusFn`' },
89+
{ body: "current_user.canApprove(record, 'approve')", rule: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, subject: '`canApprove`' },
90+
// A wrong reference: no binding root resolves `status`.
91+
{ body: "status == 'open'", rule: VISIBILITY_BARE_IDENTIFIER, subject: '`status`' },
92+
{ body: 'record.status ==', rule: VISIBILITY_PREDICATE_SYNTAX, subject: 'not valid CEL' },
93+
] as const;
94+
95+
/**
96+
* Bodies the build accepts on a page component, and so must the door: the
97+
* shapes the platform and the examples ship (`has()`-guarded record reads, a
98+
* page variable, the acting user, the `P` envelope) and registered functions.
99+
*/
100+
const ACCEPTED = [
101+
"has(record.status) && record.status == 'in_review'",
102+
{ dialect: 'cel', source: 'has(record.id) && has(record.email_verified) && record.email_verified == false' },
103+
"page.selectedProjectId != ''",
104+
"current_user.email == 'admin@objectos.ai'",
105+
'size(record.name) > 0',
106+
"upper(record.name) == 'X'",
107+
] as const;
108+
109+
const gatePage = (item: unknown) =>
110+
runRuntimeAuthoringRules({ type: 'page', item, context: { objects: [] } });
111+
112+
const family = <T extends { rule: string }>(fs: readonly T[]): T[] => fs.filter((f) => FAMILY_IDS.has(f.rule));
113+
114+
/** The build's family findings for one page, through the build's own entry. */
115+
const buildFindings = (item: unknown): AuthoringFinding[] => {
116+
const stack = { pages: [item] };
117+
return family(runAuthoringRules('build', { normalized: stack, parsed: stack }));
118+
};
119+
120+
const dump = (r: unknown) => JSON.stringify(r, null, 2);
121+
122+
describe('#24491 — the page door gives the build\'s visibility-predicate verdict', () => {
123+
it('dispatches `page` writes to both family rules, after the component rules', () => {
124+
expect(runtimeAuthoringRulesFor('page').map((r) => r.name)).toEqual([
125+
'validatePresetComparands',
126+
'validateComponentProps',
127+
'validateComponentTypes',
128+
'validatePrintPageBlocks',
129+
...FAMILY,
130+
]);
131+
});
132+
133+
for (const refused of REFUSED) {
134+
const { body, rule, subject } = refused;
135+
it(`⭐ LIT — a component \`visibleWhen: ${JSON.stringify(body)}\` is REFUSED at the component`, () => {
136+
const result = gatePage(page(body));
137+
138+
expect(result.rulesRun).toEqual(expect.arrayContaining(FAMILY));
139+
const errs = family(result.errors);
140+
expect(errs, dump(result)).toHaveLength(1);
141+
expect(errs[0]).toMatchObject({ rule, severity: 'error', where: WHERE, path: AT });
142+
expect(errs[0]!.message).toContain(subject);
143+
});
144+
}
145+
146+
for (const body of ACCEPTED) {
147+
it(`⭐ CONTROL — a component \`visibleWhen: ${JSON.stringify(body)}\` publishes clean, and the build agrees`, () => {
148+
const result = gatePage(page(body));
149+
150+
expect(result.rulesRun).toEqual(expect.arrayContaining(FAMILY));
151+
expect(family(result.errors), dump(result)).toEqual([]);
152+
expect(family(result.advisories), dump(result)).toEqual([]);
153+
expect(buildFindings(page(body))).toEqual([]);
154+
});
155+
}
156+
157+
it('⭐ PARITY — for each refused page body the door findings ARE the build findings', () => {
158+
for (const { body } of REFUSED) {
159+
const atBuild = buildFindings(page(body));
160+
const atDoor = family(gatePage(page(body)).errors);
161+
162+
// Non-vacuous: the build refuses each of them.
163+
expect(atBuild.filter((f) => f.severity === 'error'), JSON.stringify(body)).toHaveLength(1);
164+
// Rule, location, message and hint: the whole finding, compared as one.
165+
expect(atDoor, JSON.stringify(body)).toEqual(atBuild);
166+
}
167+
});
168+
169+
it('the path half judges nothing on a page: a `data.` path draws the same advisory at both doors, and no refusal', () => {
170+
const body = page("data.tpye == 'formula'");
171+
const result = gatePage(body);
172+
173+
expect(result.rulesRun).toContain('validatePredicatePathRefs');
174+
expect(family(result.errors), dump(result)).toEqual([]);
175+
expect(family(result.advisories).map((f) => f.rule), dump(result)).toEqual([VISIBILITY_ROOT_MISLAYERED]);
176+
expect(family(result.advisories)).toEqual(buildFindings(body));
177+
});
178+
179+
it('the `view` control: a view write is judged exactly as before', () => {
180+
expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([
181+
'validatePresetComparands',
182+
'validateReferenceIntegrity',
183+
...FAMILY,
184+
]);
185+
const view = (visibleWhen: string, data?: unknown) => ({
186+
name: 'field_editor',
187+
label: 'Field editor',
188+
form: {
189+
...(data ? { data } : {}),
190+
sections: [{ label: 'Main', fields: [{ field: 'name', visibleWhen }] }],
191+
},
192+
});
193+
const gateView = (item: unknown) => runRuntimeAuthoringRules({ type: 'view', item, context: { objects: [] } });
194+
195+
const fn = family(gateView(view('totallyBogusFn(1)')).errors);
196+
expect(fn.map((f) => [f.rule, f.path])).toEqual([
197+
[VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, 'views[0].form.sections[0].fields[0]'],
198+
]);
199+
// The path half is live on the view surface it was built for.
200+
const path = family(gateView(view("data.tpye == 'text'", { provider: 'schema', schemaId: 'field' })).errors);
201+
expect(path.map((f) => f.rule)).toContain(PREDICATE_PATH_UNRESOLVED);
202+
});
203+
});

‎packages/lint/src/validate-predicate-path-refs.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,9 +494,12 @@ describe('registry wiring', () => {
494494
// here because its siblings are: the solo wiring this rule's own PR
495495
// implemented was reverted, and `authoring-rule-wiring.test.ts` now pins the
496496
// family property (all of the surface's ids, or none) rather than trusting a
497-
// prose reason to keep the halves in step.
497+
// prose reason to keep the halves in step. #24491 — the family declares
498+
// `page` too (ruling B on #23388); this rule walks `stack.views` only, so a
499+
// page write draws nothing from it, and the declaration keeps the family on
500+
// one type set.
498501
expect([...entry!.surfaces]).toEqual(['cli', 'runtime-publish']);
499-
expect([...entry!.runtimeTypes!]).toEqual(['view']);
502+
expect([...entry!.runtimeTypes!]).toEqual(['view', 'page']);
500503
expect(entry!.surfaceReason).toBeUndefined();
501504
});
502505
});

0 commit comments

Comments
 (0)