Skip to content

Commit 256c86b

Browse files
fix(lint)!: refuse an app nav visible that does not parse, and page variables that are duplicated, unnamed or wrong-typed (#28723)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XT5d6rCa6Ukdbma6oi1ufki)_ Fixes #28663 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — no file under `skills/**` teaches a navigation entry's `visible` grammar or the page `variables` list; the refusals carry their own fix and `os explain` entries. ## 维护者速读 - **改了什么**:应用导航项的 `visible` 写成解析不了的 CEL(如 `'org_owner' in`),现在和页面组件的 `visibleWhen` 一样被拒:保存、草稿诊断、发布、`os validate` 四处同一条结论。页面 `variables` 新增一条规则:重名、空名、默认值类型和声明类型不符(`number` 配 `"abc"`)都拒。 - **为什么改**:以前这两类都能保存、草稿显示 valid、照常发布;导航项到了控制台解析失败,对所有人隐藏,作者毫不知情。变量重名时控制台只留最后一个,默认值类型错则所有读它的谓词比的都是错类型的值。 - **风险与回滚**:低到中。只收紧写入口,已存的应用和页面照常加载、照常服务,只有下次保存才被判。仓内语料(examples 和平台包)里 1 个导航 `visible`、5 个页面变量,零命中。回滚即撤回 `@objectstack/lint` 的这次改动。 - **先看**:`packages/lint/src/validate-visibility-predicates.ts`(新增的导航遍历和 `syntaxFinding`);`packages/lint/src/validate-page-variables.ts`(新规则);`packages/lint/src/authoring-rules.ts`(家族加 `app`、新规则注册)。 - **两分钟验证**:`pnpm --filter @objectstack/lint exec vitest run src/runtime-gate.app-nav-visible-predicate-writes.test.ts src/runtime-gate.page-variable-writes.test.ts`,预期 31/31 通过。 - **需要你做的**:无,席位落地。 ## What changes - `packages/lint/src/validate-visibility-predicates.ts`: `validateVisibilityPredicates` now also walks `apps[].navigation`, `apps[].areas[].navigation` and every entry's `children`, and runs the existing `visibility-predicate-syntax` judgment (`celRefusal`, formula's `parseCelToAstWithReason`, no second parser) on each entry's `visible`. A bare string and a `{ dialect: 'cel' }` envelope are judged (the console evaluates both on the CEL engine since #27457); an envelope of another dialect is the console's legacy reading and a boolean is no predicate, so neither is judged. The finding is located at the key (`apps[0].navigation[0].visible`). The syntax finding is built by one helper, `syntaxFinding`, for page/view elements and nav entries alike; only two words move per surface: the consequence clause (a nav gate falls closed, so "the entry is hidden from every viewer") and the remedy example (`'org_admin' in current_user.positions`, the spec's own nav example, since a nav entry binds no `record`). Page and view wording is byte-identical (live probe below shows the same page message on base and head). - Only the syntax verdict runs on a nav entry, by measurement: the family's reference verdicts judge roots a view or page binds (`record`, `current_user`, `page`), and a nav entry binds `current_user`, `user`, `ctx.user`, `os.user`, `features` (plus `data` beside a `dataSource`), so `features.cloudManaged != true` would be refused as an unbound root. - `packages/lint/src/validate-page-variables.ts` (new) with three ids: `page-variable-name-duplicate` (at the second entry's `name`, naming the first index), `page-variable-name-empty` (empty or blank), `page-variable-default-type` (a `defaultValue` of another kind than `type`: string for `string`/`record_id`, finite number, boolean, plain object, array; `null` admitted for any type; no `type` = the schema's default `string`). No `packages/spec/**` edit: the rule needs no shape change. - `packages/lint/src/authoring-rules.ts`: the visibility family (`validateVisibilityPredicates`, `validatePredicatePathRefs`) declares `runtimeTypes: ['view', 'page', 'app']` (the family stays on one type set, as `authoring-rule-wiring.test.ts` pins; the path rule judges nothing on an app). `validatePageVariables` is registered gating, `CLI_AND_RUNTIME`, `runtimeTypes: ['page']`, after `validatePrintPageBlocks`. No gate change in `metadata-protocol`: `app` already maps to `apps`, and save, draft `_diagnostics` and publish all read `judgeRuntimeAuthoringRules`. - `packages/lint/src/index.ts`: exports the rule and its three id constants. `packages/lint/src/rule-explanations.ts`: three `os explain` entries, and a paragraph on `visibility-predicate-syntax` for nav entries. - `content/docs/**` (4 CLI transcripts): `author-time rules (57)` → `(58)`, the count the registry derives with the new rule (`check:docs-transcript-drift`). - `.changeset/28663-nav-visible-syntax-page-variables.md`: `@objectstack/lint` minor, BREAKING banner, both declaration lines, ADR-0087 `not-required (no-migration-prescription)`. **Migration note.** Nav `visible` values in the shipped corpus: 1 (`examples/app-crm` `current_user.can('crm_opportunity', 'read')`), 0 refused. Page variables: 5 on 2 showcase pages, 0 refused. `os validate` on `example-crm`, `example-showcase`, `example-todo` at head: exit 0, 0 findings of the new ids. Stored apps and pages keep loading and serving; only the next save of one with a finding is refused. ## Tests - `packages/lint/src/runtime-gate.app-nav-visible-predicate-writes.test.ts` (new): the card's `visible: "'org_owner' in"` top-level, inside an area and inside a group's `children` is refused at the app door at the key, and `os validate` (`runAuthoringRules('validate')`) gives the same finding; the `{ dialect: 'cel' }` envelope too. Controls admitted at both: `'org_owner' in current_user.positions` (string and envelope), `features.cloudManaged != true`, `current_user.can(...)`, `user.isPlatformAdmin == true`, a `template` envelope, `true`. - `packages/lint/src/runtime-gate.page-variable-writes.test.ts` (new): duplicate, empty, blank, `number`+`"abc"`, `boolean`/`array`/`object`/`record_id` mismatches and an untyped number default, each refused at the page door with its path, with door/`os validate` parity; four declaration sets admitted (the showcase's two included, `null` defaults, `[]`); three faults on one page are three findings. - `packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts`: five new rows in the #26707 table (nav top-level, nav in an area, the three variable faults): the draft saves, reads `valid: false` with the publish's own words, and publish refuses `422 INVALID_METADATA`; clean nav + variables read `valid: true` and publish; paths are item-relative (`areas.0.navigation.0.visible`, `variables.0.defaultValue`). - `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`: the active `saveMetaItem` refuses the app (door finding = build finding key by key, nothing stored) and each variable fault; clean controls land. - Pins updated for the app door's new rule list: `validate-security-posture.runtime-surface.test.ts` (two), `validate-predicate-path-refs.test.ts` (one), and the page door's ordered list in `runtime-gate.page-visibility-predicate-writes.test.ts`. <details><summary>Red on base 2678600, green on head</summary> ``` base: Test Files 2 failed (2) · Tests 20 failed | 11 passed (31) (the 11 = the controls) head: Test Files 2 passed (2) · Tests 31 passed (31) ``` </details> <details><summary>Ablations (scripts/ablation-replace.mjs, lint rebuilt, ablation-dist-preflight both legs, restore = blob equals HEAD)</summary> ``` navA (nav syntax arm off) lint 7 failed | 24 passed · protocol 2 failed dupB (duplicate check off) lint 3 failed | 13 passed · protocol 2 failed emptyC (empty-name check off) lint 4 failed | 12 passed · protocol 2 failed typeD (default-type check off) lint 8 failed | 8 passed · protocol 3 failed every leg: mutate x0 -> x1, preflight exit 0, restore ok, git diff HEAD empty, rebuild, preflight --absent exit 0 ``` Record: `pm-orders/server/evidence/28663/ablations.txt`. </details> <details><summary>Live probe: card steps 2 and 4 on a stock showcase boot, base vs head</summary> Commands: `pnpm --filter @objectstack/example-showcase dev -- --fresh -p PORT`; sign in `POST /api/v1/auth/sign-in/email` as the seeded admin; `POST /api/v1/packages` a scratch writable package; then `PUT /api/v1/meta/app/qa_nav_app?mode=draft&package=PKG` (nav `visible: "'org_owner' in"`), `GET …?state=draft&package=PKG`, `POST /api/v1/packages/PKG/publish-drafts`; the same for a page with `visibleWhen: "record.status =="` and a page with `variables` [number "abc", "", duplicate]; and an active `PUT /api/v1/meta/app/qa_nav_app2?package=PKG`. Base = origin/main lint sources over the four edited files, lint rebuilt. ``` base step 2 PUT draft 200 · _diagnostics {"valid":true} · publish-drafts "outcome":"published" base active PUT app 200 (state=active) base step 4 page visibleWhen: _diagnostics valid:false visibility-predicate-syntax · publish refused INVALID_METADATA base variables: _diagnostics {"valid":true} head step 2 PUT draft 200 · _diagnostics valid:false [navigation.0.visible visibility-predicate-syntax "…so it never evaluates and the entry is hidden from every viewer"] · publish-drafts "outcome":"refused" INVALID_METADATA apps[0].navigation[0].visible head active PUT app 422 INVALID_METADATA head step 4 page visibleWhen: same message as base ("…the element always renders") head variables: _diagnostics valid:false [variables.0.defaultValue page-variable-default-type, variables.1.name page-variable-name-empty, variables.2.name page-variable-name-duplicate] os validate (app-crm, nav visible planted "'org_owner' in"): base exit 0 "Validation passed" · head exit 1 "rule: visibility-predicate-syntax at apps[0].navigation[1].children[0].visible" ``` Record: `pm-orders/server/evidence/28663/live-probe.txt` (with `probe.sh`). </details> ## Acceptance notes Out of scope by the order, each measured at head on the same boot and still admitted (`_diagnostics {"valid":true}`), not fixed here: - A page variable `source` naming a component the page does not hold (`source: "no_such_component"`): admitted. - A `visibleWhen` reading an undeclared `page.nosuch`: admitted. - Seen beside the work: a nav `visible` that is flawless CEL but over the parse budget (a 90-clause `||` chain) is admitted at the draft read; the page-side twin is `visibility-predicate-over-budget`. Not wired here because the ruling names the syntax judgment only. Also noted: when a nav `visible` uses an operator from another language (`===`), the remedy's example comes from the shared operator table and is `record`-rooted (`record.country == 'USA'`). The operator correction itself is right on any surface. Declaration: the claim's line reads `Clause-②: no`; this body and the changeset carry `Clause-②: no (narrowing)`, because `check-changeset-no-major.mjs` refuses `Metadata-protocol: narrowed` beside a line without the `(narrowing)` arm. The order's Declarations section names it a narrowing. ## Verification Derived gate loop (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 89 families with the docs edit) at `bb33ace7`: 89 exit 0. `dispatch-gates --ran`: `89 derived famil(ies) accounted for — 89 run, 0 NOT-MEASURED`. Record: `pm-orders/server/28663-gates-ran.txt`. - An earlier loop at `9fcdbbc4` read `check:docs-transcript-drift` exit 1 (the four transcripts printed 57 rules; the registry derives 58 with the new rule), fixed in `bb33ace7`; and `check-plugin-teardown-shape --self-test` exit 3 (shallow clone), exit 0 after `git fetch --depth=1 origin 621a487`. - `check:type-check-debt` under the lock: `check-type-check-coverage --re-measure: OK — 0 raw tsc error(s)`. - Package suites at `9fcdbbc4` (merge of origin/main; `bb33ace7` adds only the docs transcripts): `@objectstack/lint` Test Files 186 passed, Tests 7749 passed; `@objectstack/metadata-protocol` Test Files 291 passed / 4 skipped, Tests 21963 passed / 26 skipped; both `typecheck` exit 0. `eslint packages/lint packages/metadata-protocol --no-inline-config` exit 0. - Dogfood battery (the `(narrowing)` arm) at `bb33ace7`, closure built, `pnpm --filter @objectstack/dogfood test` under the lock: VERDICT command-exit 0 · Test Files 369 passed / 3 skipped (372) · Tests 2981 passed / 28 skipped (3009). - CI owns the repo-wide lint and every family outside the derived set. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5nHMwmgiBBzV1TsunS --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5nHMwmgiBBzV1TsunS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fa494d0 commit 256c86b

17 files changed

Lines changed: 978 additions & 28 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
**BREAKING** fix(lint)!: an app navigation `visible` that does not parse is refused like a page `visibleWhen`, and page variables must be unique, named and seeded with a default of their type
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
Skills-docs: none — no file under `skills/**` teaches a navigation entry's `visible` grammar or the page `variables` list; the refusals carry their own fix and `os explain` entries.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) The narrowing is a refusal at the authoring doors (the metadata save door, a draft's diagnostics and its publish, and os validate / os build / os lint) of two value shapes that never worked at runtime: a navigation `visible` the CEL front end cannot parse (the console hides the entry from every viewer), and a page `variables` list with a duplicate name, an empty name, or a `defaultValue` of another type than the declared one. No metadata key, accepted key, export or error code is removed or renamed, and no stored shape changes. The fix is the author's choice of the predicate or the declaration they meant, so there is no mechanical conversion for `objectstack migrate meta` to apply; the shipped corpus (examples, platform packages) carries 1 navigation `visible` and 5 page variables, none of them refused. -->
12+
13+
Two authoring shapes saved, read `valid: true` as drafts, published and passed `os validate`, and then did something the author never asked for.
14+
15+
**A navigation `visible` that does not parse.** `navigation[].visible`, `areas[].navigation[].visible` and every entry's `children` are now judged by `visibility-predicate-syntax`, the verdict a page component's `visibleWhen` already gets. A bare string and a `{ dialect: 'cel' }` envelope are CEL (the console evaluates both on the CEL engine); an envelope of another dialect is the console's legacy reading and is not judged. Only the syntax is judged on a navigation entry: it binds the shell's roots (`current_user`, `user`, `features`, …), not a page's `record`, so the reference checks a page predicate gets do not apply. The finding is located at the key (`apps[0].navigation[0].visible`), and its verdict says the entry is hidden from every viewer, since the console's navigation gate falls closed on a fault.
16+
17+
**Page variable declarations.** A new rule, `validatePageVariables`, refuses at the same doors:
18+
19+
- `page-variable-name-duplicate`: the same `name` twice (the console keeps the last declaration, so the first one's type and default were silently dropped);
20+
- `page-variable-name-empty`: an empty or blank `name` (no `page.NAME` read can reach it);
21+
- `page-variable-default-type`: a `defaultValue` of another kind than the declared `type` (a string for `string` and `record_id`, a finite number for `number`, a boolean for `boolean`, a plain object for `object`, an array for `array`; `null` is admitted for any type, and a variable with no `type` is a `string`).
22+
23+
**Where.** The metadata save door (`PUT /api/v1/meta/app/:name` and `/meta/page/:name`, Studio, MCP), a draft's `_diagnostics`, the publish (`422 INVALID_METADATA`), and `os validate` / `os build` / `os lint`. Stored apps and pages keep loading and serving; only the next save of one with a finding is refused, with the fix.

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -771,7 +771,7 @@ os compile --json # JSON output for CI pipelines
771771
→ Normalizing stack definition...
772772
→ Lowering inline handlers...
773773
→ Validating protocol compliance...
774-
→ Running author-time rules (57)...
774+
→ Running author-time rules (58)...
775775
→ Checking that every required capability has a provider installable in this edition...
776776
→ Collecting package docs (ADR-0046)... 0 collected
777777
→ Writing artifact...

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,7 @@ A clean run walks the registry and reports timing:
759759
Config: /path/to/support-desk/objectstack.config.ts
760760
Load time: 21ms
761761
→ Validating against ObjectStack Protocol...
762-
→ Running author-time rules (57)...
762+
→ Running author-time rules (58)...
763763
→ Checking that every required capability has a provider installable in this edition...
764764
→ Checking package docs (ADR-0046)...
765765

‎content/docs/getting-started/build-with-claude-code.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ visible: 'status != "resolved"'
305305
◆ Validate
306306
────────────────────────────────────────
307307
→ Validating against ObjectStack Protocol...
308-
→ Running author-time rules (57)...
308+
→ Running author-time rules (58)...
309309
310310
✗ Author-time rules failed (1 issue)
311311
• stack · action 'resolve_ticket' visible: bare reference `status` — a

‎content/docs/ui/react-pages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ objectstack validate
404404
────────────────────────────────────────
405405
→ Loading configuration...
406406
→ Validating against ObjectStack Protocol...
407-
→ Running author-time rules (57)...
407+
→ Running author-time rules (58)...
408408
→ Checking that every required capability has a provider installable in this edition...
409409
→ Checking package docs (ADR-0046)...
410410

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

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
113113
import { validateComponentProps } from './validate-component-props.js';
114114
import { validateComponentTypes } from './validate-component-types.js';
115115
import { validatePrintPageBlocks } from './validate-print-page-blocks.js';
116+
import { validatePageVariables } from './validate-page-variables.js';
116117
import { validatePagePredicateBounds } from './validate-page-predicate-bounds.js';
117118
import { validateResponsiveStyles } from './validate-responsive-styles.js';
118119
import { validateJsxPages } from './validate-jsx-pages.js';
@@ -1355,6 +1356,26 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
13551356
runtimeTypes: ['page'],
13561357
run: (stack) => validatePrintPageBlocks(stack),
13571358
},
1359+
// #28663 — a page's `variables` declarations: each named once, by a
1360+
// non-empty name, with a `defaultValue` of its declared `type`. The schema
1361+
// types each entry alone and nothing judged the list, so a duplicate name
1362+
// (the console keeps the last), an empty name (unreachable as `page.NAME`)
1363+
// and a `number` seeded with `"abc"` all published `valid: true`. Gating on
1364+
// every door from birth: page-local, so a `page` write's per-write snapshot
1365+
// (`pages: [item]`) is the whole input. Measured at birth: the shipped
1366+
// corpus declares 5 variables on 2 showcase pages, 0 findings. Stored pages
1367+
// keep loading and serving; only the next save of one with a finding is
1368+
// refused, with the fix.
1369+
{
1370+
name: 'validatePageVariables',
1371+
tier: 'gating',
1372+
input: 'normalized',
1373+
commands: ALL,
1374+
source: 'packages/lint/src/validate-page-variables.ts',
1375+
surfaces: CLI_AND_RUNTIME,
1376+
runtimeTypes: ['page'],
1377+
run: (stack) => validatePageVariables(stack),
1378+
},
13581379
// ADR-0065 — a styled node's responsiveStyles must be scopable (needs an
13591380
// `id`), name real CSS properties + design tokens, and carry a `large` base.
13601381
{
@@ -1782,14 +1803,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
17821803
// `visibleWhen` only, the one key the family reads — on a page component as
17831804
// on a view form (the page-side `visibility` retired with the view-side
17841805
// `visibleOn`).
1806+
//
1807+
// ─── …and on `app` writes (#28663) ───
1808+
//
1809+
// An app navigation entry's `visible` is a predicate the console evaluates
1810+
// as CEL (#27457), and its gate falls CLOSED: a `visible` that does not parse
1811+
// hides the entry from every viewer. Before this, `"'org_owner' in"` there
1812+
// saved, read `valid: true` as a draft, published and passed `os validate`,
1813+
// while the same fault in a page `visibleWhen` was refused. The rule now
1814+
// walks `apps[].navigation` / `areas[].navigation` (and `children`) for the
1815+
// SYNTAX verdict alone — the rule's module note says why the root-binding
1816+
// verdicts stay off a nav entry. `app` maps to `apps`, and an app snapshot
1817+
// holds the item as `apps: [item]` with no `views` or `pages`, so the view
1818+
// and page halves contribute nothing on an app write, as the app half
1819+
// contributes nothing on a view or page write. `validatePredicatePathRefs`
1820+
// declares `app` with it for the one-wall reason above and judges nothing
1821+
// there. Stored apps keep loading and serving; only the next save of an app
1822+
// with such a predicate is refused, with the fix.
17851823
{
17861824
name: 'validateVisibilityPredicates',
17871825
tier: 'gating',
17881826
input: 'normalized',
17891827
commands: ALL,
17901828
source: 'packages/lint/src/validate-visibility-predicates.ts',
17911829
surfaces: CLI_AND_RUNTIME,
1792-
runtimeTypes: ['view', 'page'],
1830+
runtimeTypes: ['view', 'page', 'app'],
17931831
run: (stack) => validateVisibilityPredicates(stack),
17941832
},
17951833
// #7010 — the same predicate surface, one question further in. The three
@@ -1828,9 +1866,10 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
18281866
// was reverted; it is wired now because its siblings are. It declares
18291867
// `page` with its sibling (#24491, ruling B on #23388) and judges nothing
18301868
// there: it walks `stack.views` only, so a page write draws no finding from
1831-
// it at either door.
1869+
// it at either door. `app` likewise (#28663): declared with its sibling,
1870+
// judging nothing on an app write.
18321871
surfaces: CLI_AND_RUNTIME,
1833-
runtimeTypes: ['view', 'page'],
1872+
runtimeTypes: ['view', 'page', 'app'],
18341873
run: (stack) => validatePredicatePathRefs(stack),
18351874
},
18361875
// #24953 — the CEL slots INSIDE a page component's `properties` bag (a

‎packages/lint/src/index.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,16 @@ export {
674674
} from './validate-print-page-blocks.js';
675675
export type { PrintPageBlockFinding } from './validate-print-page-blocks.js';
676676

677+
// #28663 — a page's `variables` declarations: unique, named, and seeded with
678+
// a `defaultValue` of the declared `type`.
679+
export {
680+
validatePageVariables,
681+
PAGE_VARIABLE_NAME_DUPLICATE,
682+
PAGE_VARIABLE_NAME_EMPTY,
683+
PAGE_VARIABLE_DEFAULT_TYPE,
684+
} from './validate-page-variables.js';
685+
export type { PageVariableFinding } from './validate-page-variables.js';
686+
677687
// #24953 — a CEL slot inside a page component's `properties` (a `page:tabs`
678688
// item `visibleWhen`, an `object-form` section predicate, an `action:*`
679689
// `visible`, …) over the platform's parse bounds. A warning in 18.x.

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,11 @@ const VISIBILITY_PREDICATE_SYNTAX_EXPLANATION: RuleExplanation = {
962962
'evaluate, and the console falls OPEN: the element renders unconditionally and looks exactly like ' +
963963
'one with no predicate at all (failing open is the console\'s settled behaviour). Every other ' +
964964
'predicate surface already gates a syntax fault (ADR-0032), so this one does too.',
965+
'An app navigation entry\'s `visible` is judged the same way, and there the cost is the opposite ' +
966+
'one: the console\'s navigation gate falls CLOSED, so an entry whose predicate does not parse is ' +
967+
'hidden from every viewer, its author included. Only the syntax is judged on a navigation entry: ' +
968+
'it binds `current_user`, `user`, `features` and the rest of the shell\'s roots rather than ' +
969+
'`record`, so the reference checks a page predicate gets do not apply to it.',
965970
'Spellings from other languages do not parse: write `==` (not `===`), `!=` (not `!==` or `<>`), ' +
966971
'`&&` (not `and`), `||` (not `or`), `!` (not `not`). A predicate that is flawless CEL but too ' +
967972
'large is `visibility-predicate-over-budget` instead.',
@@ -2713,6 +2718,53 @@ const PRINT_PAGE_BLOCK_UNPRINTABLE_EXPLANATION: RuleExplanation = {
27132718
],
27142719
};
27152720

2721+
// ── Page variables (`validate-page-variables.ts`) ───────────────────────────
2722+
2723+
const PAGE_VARIABLE_NAME_DUPLICATE_EXPLANATION: RuleExplanation = {
2724+
rule: 'page-variable-name-duplicate',
2725+
covers: 'why a page variable name may be declared only once',
2726+
paragraphs: [
2727+
'A page variable is page-local state that predicates read as `page.NAME` and an element writes ' +
2728+
'through its `source` binding. The console seeds the page\'s variable store from the declarations ' +
2729+
'in order, so a second declaration of the same name silently replaces the first: the first one\'s ' +
2730+
'`type` and `defaultValue` are gone, and nothing says so.',
2731+
'It is an error on every authoring door — `os validate`, `os build`, `os lint` and the page save ' +
2732+
'door (Studio, REST, MCP), including a draft\'s diagnostics and its publish. Rename one of the two ' +
2733+
'or remove the duplicate. A stored page keeps loading and serving; only its next save is judged.',
2734+
],
2735+
};
2736+
2737+
const PAGE_VARIABLE_NAME_EMPTY_EXPLANATION: RuleExplanation = {
2738+
rule: 'page-variable-name-empty',
2739+
covers: 'why a page variable needs a name',
2740+
paragraphs: [
2741+
'A page variable is reached only by its name: predicates read it as `page.NAME`, and an element ' +
2742+
'writes it through its `source` binding. A declaration whose name is empty or blank can be neither ' +
2743+
'read nor written, so it is a declaration that does nothing while looking like one that works.',
2744+
'It is an error on every authoring door — `os validate`, `os build`, `os lint` and the page save ' +
2745+
'door (Studio, REST, MCP), including a draft\'s diagnostics and its publish. Name the variable or ' +
2746+
'remove the entry.',
2747+
],
2748+
};
2749+
2750+
const PAGE_VARIABLE_DEFAULT_TYPE_EXPLANATION: RuleExplanation = {
2751+
rule: 'page-variable-default-type',
2752+
covers: 'which `defaultValue` each page variable type admits',
2753+
paragraphs: [
2754+
'A page variable starts with its `defaultValue`, or with its type\'s empty value when the default ' +
2755+
'is omitted: `\'\'` for `string` and `record_id`, `0` for `number`, `false` for `boolean`, `{}` ' +
2756+
'for `object`, `[]` for `array`. A default of another kind — `defaultValue: "abc"` on a `number` ' +
2757+
'variable — is seeded as written, so every predicate that reads it compares the wrong kind of ' +
2758+
'value while the declaration claims otherwise.',
2759+
'Admitted: a string for `string` and `record_id`, a finite number for `number`, `true` or `false` ' +
2760+
'for `boolean`, a plain object for `object`, an array for `array`, and `null` for any type ' +
2761+
'("no value yet"). A variable with no `type` is a `string` variable, as the schema defaults it.',
2762+
'It is an error on every authoring door — `os validate`, `os build`, `os lint` and the page save ' +
2763+
'door (Studio, REST, MCP), including a draft\'s diagnostics and its publish. Fix the default, omit ' +
2764+
'it, or declare the type the default really has.',
2765+
],
2766+
};
2767+
27162768
// ── Filter comparands (`validate-preset-comparands.ts`) ────────────────────
27172769

27182770
const FILTER_PRESET_COMPARAND_EXPLANATION: RuleExplanation = {
@@ -5384,6 +5436,9 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
53845436
[COMPONENT_TYPE_UNKNOWN_EXPLANATION.rule]: COMPONENT_TYPE_UNKNOWN_EXPLANATION,
53855437
[COMPONENT_PROPS_INVALID_EXPLANATION.rule]: COMPONENT_PROPS_INVALID_EXPLANATION,
53865438
[PRINT_PAGE_BLOCK_UNPRINTABLE_EXPLANATION.rule]: PRINT_PAGE_BLOCK_UNPRINTABLE_EXPLANATION,
5439+
[PAGE_VARIABLE_NAME_DUPLICATE_EXPLANATION.rule]: PAGE_VARIABLE_NAME_DUPLICATE_EXPLANATION,
5440+
[PAGE_VARIABLE_NAME_EMPTY_EXPLANATION.rule]: PAGE_VARIABLE_NAME_EMPTY_EXPLANATION,
5441+
[PAGE_VARIABLE_DEFAULT_TYPE_EXPLANATION.rule]: PAGE_VARIABLE_DEFAULT_TYPE_EXPLANATION,
53875442
[FILTER_PRESET_COMPARAND_EXPLANATION.rule]: FILTER_PRESET_COMPARAND_EXPLANATION,
53885443
[FILTER_TOKEN_UNKNOWN_EXPLANATION.rule]: FILTER_TOKEN_UNKNOWN_EXPLANATION,
53895444
[APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY_EXPLANATION.rule]: APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY_EXPLANATION,

0 commit comments

Comments
 (0)