Skip to content

Commit d9c4cdc

Browse files
fix(lint)!: refuse a page variable source naming no component on the page, and a page.NAME read the page does not declare (#28769)
domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XUmUy7mddyM9KUzene3eqMA)_ Fixes #28725 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — no file under `skills/**` teaches a page variable's `source` binding or `page.NAME` reads; both refusals carry their own fix and `os explain` entries. ## 维护者速读 - **改了什么**:页面变量的 `source` 写了页面上不存在的组件 id(如 `no_such_component`),以及组件 `visibleWhen` 读了页面没声明的变量(如 `page.nosuch == 'x'`),现在都被拒:保存、草稿诊断、发布、`os validate` 四处同一条结论,定位到 `variables[i].source` 或该组件。 - **为什么改**:以前这两种都能保存、草稿显示 valid、照常发布。控制台按组件 id 精确匹配写入方,`source` 写错就没有任何组件会写这个变量;`page` 里只有声明过的变量,读没声明的名字永远取不到值,组件就无条件显示。作者毫不知情。 - **风险与回滚**:低。只收紧写入口,已存页面照常加载和服务,只有下次保存才被判。仓内语料 3 个 `source`、5 个 `page.X` 读取(都在 showcase 两个页面),零命中;examples 三个 `os validate` 全过。回滚即撤回 `@objectstack/lint` 这次改动。 - **先看**:`packages/lint/src/validate-visibility-predicates.ts`(`firstUndeclaredPageRead` 与 checkElement 的 3c 段);`packages/lint/src/validate-page-variables.ts`(`source` 判定);`packages/lint/src/runtime-gate.page-variable-references.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/lint exec vitest run src/runtime-gate.page-variable-references.test.ts`,预期 17/17 通过。 - **需要你做的**:无,席位落地。 ## What changes - `packages/lint/src/validate-page-variables.ts`: a fourth id beside #28663's three, `page-variable-source-unknown`. A variable whose `source` names a component `id` the page does not hold is refused, located at `variables[i].source`. The page's component ids come from `walkPageComponents`, the one traversal every page rule shares (regions, slots, and the sub-trees inside `properties`), read once per page and only when some entry has a `source`; no second walk. A source-authored page (`kind: 'react'` / `'html'`) is not judged on `source`: its components live in the code, which that walk deliberately skips. - `packages/lint/src/validate-visibility-predicates.ts`: a new gating id, `visibility-page-variable-undeclared`. On a page component, a `visibleWhen` reading `page.NAME` or `page.?NAME` where the page declares no variable `NAME` is refused, located at the component, naming the first such read. Judged from the AST the `has()` check already holds (formula's `parseCelToAst`, no second parser), with the page's declared names passed into `checkElement` from the pages loop only. The nav walker is untouched (#28726 owns it). Stand-downs are missed catches only: an index read (`page['x']`), and any predicate whose comprehension binds `page` as its loop variable (`COMPREHENSION_MACROS`, reused from `predicate-rhs-position.ts`). - Why that file and not `validate-predicate-path-refs.ts` (measured, as the order asked): the path rule resolves `data.*` against a metadata type's schema only; nothing in it reads a `page` root. The visibility rule is where `page` is declared as a root (`VIEW_PAGE_EXTRA_ROOTS`) and where the page walk already hands each component's predicate to the parser. - `packages/lint/src/index.ts`: exports both id constants. `packages/lint/src/rule-explanations.ts`: two `os explain` entries. - No `authoring-rules.ts` change: both ids live inside rules already registered on `page` writes, so save, draft `_diagnostics`, publish and `os validate` carry them with no registry or `metadata-protocol` source change, and the CLI rule count does not move (no `content/docs/**` edit). - `.changeset/28725-page-variable-source-and-read.md`: `@objectstack/lint` minor, BREAKING banner, both declaration lines, ADR-0087 `not-required (no-migration-prescription)`. ## Why #28663 judged the `variables` list itself; the contract review of PR #28723 (finding S3) left these two references admitted, and this card measured both on a stock showcase: draft `_diagnostics` `valid: true`, publish succeeds. What the console does with each, read at `ui/` on this head: - **`source`.** `usePageVariableBinding(componentId)` (`ui/packages/react/src/hooks/usePageVariables.tsx`) returns `definitions.find((d) => d.source === componentId)`, and the two writer elements (`element:record_picker`, `element:text_input`) call it with their own `schema.id`. A `source` no component carries matches nothing, so no element writes the variable and it keeps its default. The refusal matches a binding that can never resolve. - **`page.NAME`.** `buildNodeEvaluationScope` (`ui/packages/react/src/SchemaRenderer.tsx`) sets `page: pageVariables`, written after the ambient spread, so nothing else can supply `page`. The variables come from `PageVariablesProvider`, mounted by the page renderer only when the page declares variables and seeded from the declarations alone. Its only writers are a bound element (by a declaration's `source`) and the dashboard filter bar (its own declared filters), so an undeclared name is never present. A page with no variables gets the frozen empty fallback. No route params or other bindings reach `page`. The read faults and the element renders, which is the refusal's stated consequence. - Nesting: a provider merges an outer scope (objectui#2578), documented for a filtered dashboard embedded in a page. That is a dashboard reading the page's variables, not a page component reading another scope's, so it does not reach this rule. **Migration note.** Shipped corpus (`examples/**`, `packages/*/src`, platform apps): 3 variable `source` bindings and 5 `page.NAME` predicate reads, all on two showcase pages (`page-variables.page.ts`, `contact-form.page.ts`). **0 refused.** `os validate` at head on `examples/app-showcase`, `app-crm`, `app-todo`: `"valid": true`, 0 findings of the new ids. Positive controls on the same command: planting `source: 'project_pickr'` or `visibleWhen: "page.selectedProjectID == ''"` in the showcase page makes `os validate` exit 1 with the new id at the right path. Stored pages keep loading and serving; only the next save of one with a finding is refused. ## Tests - `packages/lint/src/runtime-gate.page-variable-references.test.ts` (new): the card's `source: "no_such_component"`, a case-mismatched id, the card's `page.nosuch == 'x'`, a `{ dialect, source }` envelope, `page.?nosuch`, and a `page.X` read on a page with no variables, each refused at the page door with its path, with door / `os validate` (`runAuthoringRules('validate')`) parity. Controls admitted at both: the showcase picker shape, a variable with no `source`, a `has()`-guarded declared read, a read through a declared `object` variable, a comprehension binding `page`, no `page` read. Also: a writer nested in a container's `children` resolves; a `kind: 'react'` page is not judged on `source`; two undeclared reads are one finding; both faults are two findings. - `packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts`: two new rows in the #26707 table (the draft saves, reads `valid: false` with the publish's words, publish refuses `422 INVALID_METADATA`); the clean controls read `valid: true` and publish; paths are item-relative (`variables.0.source`, `regions.0.components.0`). - `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`: the active `saveMetaItem` refuses each (door message = build message, nothing stored); the clean page lands. - Fixture triage (declaration added, not respelled): `validate-visibility-predicates.test.ts` and `runtime-gate.page-visibility-predicate-writes.test.ts` each had a clean control reading `page.selectedId` / `page.selectedProjectId` on a page that declared no variables. Each page now declares the variable; the second file's family id set gains the new id, so its controls also assert it stays silent. <details><summary>Red on base 8bceb6a, green on head</summary> Base = the four edited lint src files written from 8bceb6a, lint rebuilt (`ablation-dist-preflight --absent` exit 0), then restored (`git diff HEAD` empty) and rebuilt. ``` base lint runtime-gate.page-variable-references.test.ts Tests 9 failed | 8 passed (17) (the 8 = the controls) base protocol draft-publish-verdict + runtime-authoring-gate Tests 5 failed | 255 passed (260) head lint Tests 17 passed (17) · protocol Tests 260 passed (260) ``` Record: `pm-orders/server/evidence/28725/redbase2.log` (script `redbase.sh`). </details> <details><summary>Ablations (scripts/ablation-replace.mjs, lint rebuilt, ablation-dist-preflight both legs, restore = blob equals HEAD)</summary> ``` SRC (source check behind globalThis.ABL_SRC) lint 4 failed | 283 passed (3 files) · protocol 3 failed | 257 passed READ (read check behind globalThis.ABL_READ) lint 7 failed | 280 passed (3 files) · protocol 3 failed | 257 passed each leg: anchor x1 -> x0, marker in dist (preflight exit 0), restore blob == HEAD, git diff HEAD empty; rebuild, preflight --absent exit 0 for both markers ``` Record: `pm-orders/server/evidence/28725/ablate.log` (scripts `ablate.sh`, `ablate-run.sh`). </details> <details><summary>Live probe: both card cases on a stock showcase boot, base vs head</summary> Commands: `pnpm --filter @objectstack/example-showcase dev -- --fresh -p PORT`; `POST /api/v1/auth/sign-in/email` as the seeded admin; `POST /api/v1/packages` a scratch writable package; `PUT /api/v1/meta/page/qa_src_page?mode=draft&package=PKG` (a picker `project_picker`, `variables[0].source: "no_such_component"`); `PUT /api/v1/meta/page/qa_read_page?mode=draft&package=PKG` (`visibleWhen: "page.nosuch == 'x'"`, variable declared and sourced from the picker); `GET …?state=draft&package=PKG` for each; `POST /api/v1/packages/PKG/publish-drafts`; an active `PUT` of a clean control and of the read case. Base = 8bceb6a lint sources over the four edited files, lint rebuilt. ``` base case 1 draft _diagnostics {"valid":true} · case 2 {"valid":true} · publish-drafts "outcome":"published" (2) · active PUT case 2 200 head case 1 _diagnostics valid:false [variables.0.source page-variable-source-unknown] head case 2 _diagnostics valid:false [regions.0.components.1 visibility-page-variable-undeclared] head publish-drafts "outcome":"refused" INVALID_METADATA pages[0].variables[0].source · active PUT case 2 422 INVALID_METADATA both clean control (source = the picker, reads page.selectedProjectId) active PUT 200 ``` Record: `pm-orders/server/evidence/28725/live-probe.txt` (`probe.sh`, `probe-base.sh`). </details> ## Acceptance notes - 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. - File surface: the claim names `packages/lint/src/**` and its tests; the two `packages/metadata-protocol` test files above are added for the draft / publish / active-save proof, as #28723 did. Test-only, no source change there. - Serial with #28726: its PR #28756 (the nav over-budget arm in the same file) landed after this PR opened; this PR merged it and carries the seat-ordered fold of the two `visibility-predicate-over-budget` sentence builders (see "Patch round" below). - Not judged, by design (missed catches, never false errors): `page['x']` index reads; `{{page.NAME}}` tokens in action bodies (an interpolation surface, not a predicate the visibility rule walks); `page.NAME` in props-bag CEL slots (`page:tabs` item `visibleWhen`, which binds `data` to the row, and the other slots `validatePagePredicateBounds` reads). Whether `source` names a component that can WRITE (only `element:record_picker` and `element:text_input` call the binding) is not judged either; the ruling asks only that the id exist. ## Verification Derived gate loop (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 59 families) at `d350dda3` (the second merge of origin/main): 59 exit 0. `dispatch-gates --ran`: `59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED (a DERIVED zero — all 59 recorded an exit code and none of them is 3)`. Record: `pm-orders/server/28725-gates-ran.txt`. - Three families first read exit 3 (prerequisite, nothing measured) and were re-run to exit 0 on the same head: `check-plugin-teardown-shape --self-test` (shallow clone; `git fetch --depth=1 origin 621a487`), `check:dual-build-cjs-loads` (seven packages outside the built closure had no `dist/`; built), `check:type-check-debt` (lock queue-timeout behind this run's own dogfood battery; re-run: `--re-measure: OK — 0 raw tsc error(s)`). The same 59 also all read exit 0 at the first merge `cef4f60f`. - Package suites at `d350dda3`: `@objectstack/lint` Test Files 188 passed, Tests 7780 passed; `@objectstack/metadata-protocol` Test Files 294 passed / 4 skipped, Tests 22029 passed / 26 skipped; both `typecheck` exit 0. `eslint packages/lint packages/metadata-protocol --no-inline-config` exit 0. `check-platform-checklist --changed-from origin/main` OK. - Dogfood battery (the `(narrowing)` arm) at `d350dda3`, closure built, `pnpm --filter @objectstack/dogfood test` under the lock: VERDICT command-exit 0 · Test Files 374 passed / 3 skipped (377) · Tests 3034 passed / 28 skipped (3062). - `os validate` at `d350dda3` on `examples/app-showcase`, `app-crm`, `app-todo`, `app-multi-package`: `"valid": true`, 0 findings of the new ids (`pm-orders/server/evidence/28725/os-validate.txt`). - CI owns the repo-wide lint and every family outside the derived set. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` edit.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (scoped: `eslint packages/lint packages/metadata-protocol --no-inline-config` exit 0). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief excepted, by the dispatch contract). ## Patch round: over-budget builder fold (seat-ordered deviation) Ordered by server seat 5 after #28756 (PR for #28726) landed: `checkElement`'s bounds arm and #28756's `navOverBudgetFinding` built the `visibility-predicate-over-budget` sentence in two places. Head `f0519ad0` (after merging origin/main `d08b859a`) folds them into one `overBudgetFinding(refusal, where, path, surface, root)`, the way `syntaxFinding` already works: the `SyntaxSurface` gains `overBudgetHint(root)`, so the element surface keeps its `ROOT.f in [...]` / formula-field remedy (root from the site's layer) and the nav surface keeps its `current_user.positions.exists(...)` remedy. `navOverBudgetFinding` is gone. - Wording byte-identical: 9 over-budget findings (view section and page component on the runtime layer, a schema-bound form and a page on the metadata layer, a nav group and its child) from `dist/` built off the pre-fold blob and off the fold compare `cmp`-identical (`pm-orders/server/evidence/28725/fold-byte-identity.txt`). The existing pins (#28726's nav tests, the page/view over-budget tests) pass unchanged. - At `f0519ad0`: `@objectstack/lint` Tests 7797 passed (188 files), typecheck exit 0; `@objectstack/metadata-protocol` draft-publish-verdict + runtime-authoring-gate Tests 265 passed, typecheck exit 0; scoped eslint exit 0. Derived gate loop 59/59 exit 0, `--ran` derived zero (`pm-orders/server/28725-fold-gates-ran.txt`). - Merge conflict resolved in `protocol.draft-publish-verdict.test.ts` (this PR's two #28725 tests and #28756's nav over-budget test sat at the same spot; both kept). - Dogfood battery: green at `d350dda3`; NOT MEASURED at `f0519ad0` (lint-only, byte-identical refactor plus the merge; CI runs it). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mBAqi5RnPHhcrHgTn --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mBAqi5RnPHhcrHgTn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f294e0 commit d9c4cdc

10 files changed

Lines changed: 534 additions & 59 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
**BREAKING** fix(lint)!: a page variable `source` naming no component on the page, and a page predicate reading a `page.NAME` the page does not declare, are refused
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
Skills-docs: none — no file under `skills/**` teaches a page variable's `source` binding or `page.NAME` reads; both 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 references that never resolved at runtime: a page variable `source` that names no component id on the page (the console binds the writer by exact id, so nothing ever writes the variable), and a page component `visibleWhen` reading `page.NAME` for a `NAME` the page's `variables` does not declare (the read faults and the element renders unconditionally). 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 component id or variable name they meant, so there is no mechanical conversion for `objectstack migrate meta` to apply; the shipped corpus (examples, platform packages) carries 3 variable `source` bindings and 5 `page.NAME` predicate reads (all on two showcase pages), none of them refused. -->
12+
13+
#28663 judged a page's `variables` list itself. Two references into that list still saved, read `valid: true` as drafts, published and passed `os validate`, though neither can ever resolve:
14+
15+
- `page-variable-source-unknown` (in `validatePageVariables`): a variable whose `source` names a component `id` the page does not hold. The console binds a writer element by comparing its `id` with `source` exactly, so no element writes the variable and it keeps its default for the page's whole life. Components nested inside containers count; a page authored as code (`kind: 'react'` or `'html'`) is not judged, since its components live in the code. Located at `variables[i].source`.
16+
- `visibility-page-variable-undeclared` (in `validateVisibilityPredicates`): a page component `visibleWhen` reading `page.NAME` (or `page.?NAME`) where the page declares no variable `NAME`. `page` holds the page's declared variables and nothing else, so the read faults and the element always renders. Judged from the predicate's existing parse; an index read (`page['x']`) and a predicate whose comprehension binds `page` as its loop variable are not judged. Located at the component.
17+
18+
**Where.** The metadata save door (`PUT /api/v1/meta/page/:name`, Studio, MCP), a draft's `_diagnostics`, the publish (`422 INVALID_METADATA`), and `os validate` / `os build` / `os lint`. Stored pages keep loading and serving; only the next save of one with a finding is refused, with the fix.

‎packages/lint/src/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export {
279279
VISIBILITY_PREDICATE_OVER_BUDGET,
280280
VISIBILITY_PREDICATE_UNKNOWN_FUNCTION,
281281
VISIBILITY_PREDICATE_HAS_ARGUMENT,
282+
VISIBILITY_PAGE_VARIABLE_UNDECLARED,
282283
} from './validate-visibility-predicates.js';
283284
export type {
284285
VisibilityFinding,
@@ -675,12 +676,14 @@ export {
675676
export type { PrintPageBlockFinding } from './validate-print-page-blocks.js';
676677

677678
// #28663 — a page's `variables` declarations: unique, named, and seeded with
678-
// a `defaultValue` of the declared `type`.
679+
// a `defaultValue` of the declared `type`; #28725 — a `source` naming a
680+
// component the page holds.
679681
export {
680682
validatePageVariables,
681683
PAGE_VARIABLE_NAME_DUPLICATE,
682684
PAGE_VARIABLE_NAME_EMPTY,
683685
PAGE_VARIABLE_DEFAULT_TYPE,
686+
PAGE_VARIABLE_SOURCE_UNKNOWN,
684687
} from './validate-page-variables.js';
685688
export type { PageVariableFinding } from './validate-page-variables.js';
686689

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,22 @@ const VISIBILITY_PREDICATE_HAS_ARGUMENT_EXPLANATION: RuleExplanation = {
10141014
],
10151015
};
10161016

1017+
const VISIBILITY_PAGE_VARIABLE_UNDECLARED_EXPLANATION: RuleExplanation = {
1018+
rule: 'visibility-page-variable-undeclared',
1019+
covers: 'which page variables a page predicate can read',
1020+
paragraphs: [
1021+
'On a page component `page` holds the page\'s own variables and nothing else: the console seeds it ' +
1022+
'from the page\'s `variables` declarations, and the only things that write it — an element bound ' +
1023+
'by a declaration\'s `source`, and a dashboard\'s filter bar — write declared names. So ' +
1024+
'`page.NAME` for a `NAME` no declaration carries is never there: the predicate cannot evaluate and ' +
1025+
'the console falls OPEN, rendering the element unconditionally.',
1026+
'It is an error on every authoring door — `os validate`, `os build`, `os lint` and the page save ' +
1027+
'door (Studio, REST, MCP), including a draft\'s diagnostics and its publish. Declare the variable ' +
1028+
'in the page\'s `variables`, or correct the name to one the page declares (names are compared ' +
1029+
'exactly, case included). A predicate that reads through an index (`page[\'x\']`) is not judged.',
1030+
],
1031+
};
1032+
10171033
const VISIBILITY_BARE_IDENTIFIER_EXPLANATION: RuleExplanation = {
10181034
rule: 'visibility-bare-identifier',
10191035
covers: 'why every value is read through a namespace',
@@ -2772,6 +2788,22 @@ const PAGE_VARIABLE_DEFAULT_TYPE_EXPLANATION: RuleExplanation = {
27722788
],
27732789
};
27742790

2791+
const PAGE_VARIABLE_SOURCE_UNKNOWN_EXPLANATION: RuleExplanation = {
2792+
rule: 'page-variable-source-unknown',
2793+
covers: 'what a page variable `source` must name',
2794+
paragraphs: [
2795+
'A page variable\'s `source` is the `id` of the component that writes it — a record picker or a ' +
2796+
'text input on the same page. The console binds the writer by comparing that `id` exactly, so a ' +
2797+
'`source` no component on the page carries binds nothing: no element ever writes the variable, ' +
2798+
'and it keeps its default for as long as the page is open while the author believes it is fed.',
2799+
'It is an error on every authoring door — `os validate`, `os build`, `os lint` and the page save ' +
2800+
'door (Studio, REST, MCP), including a draft\'s diagnostics and its publish. Set `source` to the ' +
2801+
'writer\'s `id` (components nested inside a container count), give the writer that `id`, or drop ' +
2802+
'`source` when nothing on the page writes the variable. A page authored as `source` code ' +
2803+
'(`kind: \'react\'` or `\'html\'`) is not judged, because its components live in the code.',
2804+
],
2805+
};
2806+
27752807
// ── Filter comparands (`validate-preset-comparands.ts`) ────────────────────
27762808

27772809
const FILTER_PRESET_COMPARAND_EXPLANATION: RuleExplanation = {
@@ -5418,6 +5450,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
54185450
[VISIBILITY_PREDICATE_SYNTAX_EXPLANATION.rule]: VISIBILITY_PREDICATE_SYNTAX_EXPLANATION,
54195451
[VISIBILITY_PREDICATE_UNKNOWN_FUNCTION_EXPLANATION.rule]: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION_EXPLANATION,
54205452
[VISIBILITY_PREDICATE_HAS_ARGUMENT_EXPLANATION.rule]: VISIBILITY_PREDICATE_HAS_ARGUMENT_EXPLANATION,
5453+
[VISIBILITY_PAGE_VARIABLE_UNDECLARED_EXPLANATION.rule]: VISIBILITY_PAGE_VARIABLE_UNDECLARED_EXPLANATION,
54215454
[VISIBILITY_BARE_IDENTIFIER_EXPLANATION.rule]: VISIBILITY_BARE_IDENTIFIER_EXPLANATION,
54225455
[HOOK_BODY_WRITE_UNKNOWN_FIELD_EXPLANATION.rule]: HOOK_BODY_WRITE_UNKNOWN_FIELD_EXPLANATION,
54235456
[HOOK_BODY_WRITE_UNPROVISIONED_ANCHOR_EXPLANATION.rule]: HOOK_BODY_WRITE_UNPROVISIONED_ANCHOR_EXPLANATION,
@@ -5480,6 +5513,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
54805513
[PAGE_VARIABLE_NAME_DUPLICATE_EXPLANATION.rule]: PAGE_VARIABLE_NAME_DUPLICATE_EXPLANATION,
54815514
[PAGE_VARIABLE_NAME_EMPTY_EXPLANATION.rule]: PAGE_VARIABLE_NAME_EMPTY_EXPLANATION,
54825515
[PAGE_VARIABLE_DEFAULT_TYPE_EXPLANATION.rule]: PAGE_VARIABLE_DEFAULT_TYPE_EXPLANATION,
5516+
[PAGE_VARIABLE_SOURCE_UNKNOWN_EXPLANATION.rule]: PAGE_VARIABLE_SOURCE_UNKNOWN_EXPLANATION,
54835517
[FILTER_PRESET_COMPARAND_EXPLANATION.rule]: FILTER_PRESET_COMPARAND_EXPLANATION,
54845518
[FILTER_TOKEN_UNKNOWN_EXPLANATION.rule]: FILTER_TOKEN_UNKNOWN_EXPLANATION,
54855519
[APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY_EXPLANATION.rule]: APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY_EXPLANATION,
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #28725 — a page's references to its own variables are resolved at every door.
5+
*
6+
* ## The state this closes
7+
*
8+
* #28663 judged the `variables` list itself (names, defaults). Two references
9+
* INTO it still published `valid: true` though neither can ever resolve:
10+
*
11+
* - a variable whose `source` names a component id the page does not hold —
12+
* the console binds a writer by `definitions.find((d) => d.source ===
13+
* schema.id)` (`usePageVariableBinding`), so no component ever writes it and
14+
* the variable keeps its default for the page's whole life;
15+
* - a component `visibleWhen` reading `page.NAME` where `NAME` is not declared —
16+
* `page` binds exactly the page's declared variables (`buildNodeEvaluationScope`
17+
* → `PageVariablesProvider`, which seeds only the declarations and has no
18+
* writer for any other name), so the read faults and the element renders
19+
* unconditionally.
20+
*
21+
* Both are refused at `os validate` / `os build` / `os lint` and at the page
22+
* save door (draft `_diagnostics`, publish), located at the reference.
23+
*/
24+
import { describe, expect, it } from 'vitest';
25+
import { runAuthoringRules } from './authoring-rules.js';
26+
import { runRuntimeAuthoringRules } from './runtime-gate.js';
27+
28+
const SOURCE_UNKNOWN = 'page-variable-source-unknown';
29+
const READ_UNDECLARED = 'visibility-page-variable-undeclared';
30+
const IDS = new Set([SOURCE_UNKNOWN, READ_UNDECLARED]);
31+
32+
/** A page with one picker (`project_picker`) and one gated panel. */
33+
const page = (variables: unknown[], visibleWhen: unknown = "page.selectedProjectId != ''") => ({
34+
name: 'vars_page',
35+
label: 'Vars',
36+
type: 'app',
37+
variables,
38+
regions: [
39+
{
40+
name: 'main',
41+
components: [
42+
{ type: 'page:header', properties: { title: 'Vars' } },
43+
{ id: 'project_picker', type: 'element:record_picker', dataSource: { object: 'project' }, properties: { label: 'Project', labelField: 'name' } },
44+
{ id: 'detail_panel', type: 'page:card', properties: { title: 'Detail' }, visibleWhen },
45+
],
46+
},
47+
],
48+
});
49+
const PANEL = 'pages[0].regions[0].components[2]';
50+
const PICKED = { name: 'selectedProjectId', type: 'record_id', source: 'project_picker' };
51+
52+
const gatePage = (item: unknown) => runRuntimeAuthoringRules({ type: 'page', item, context: { objects: [] } });
53+
const ours = <T extends { rule: string }>(fs: readonly T[]): T[] => fs.filter((f) => IDS.has(f.rule));
54+
const buildFindings = (item: unknown) => {
55+
const stack = { pages: [item] };
56+
return ours(runAuthoringRules('validate', { normalized: stack, parsed: stack }));
57+
};
58+
const dump = (r: unknown) => JSON.stringify(r, null, 2);
59+
60+
const REFUSED = [
61+
{
62+
name: 'a `source` naming no component on the page (the card\'s shape)',
63+
item: page([{ ...PICKED, source: 'no_such_component' }]),
64+
rule: SOURCE_UNKNOWN,
65+
path: 'pages[0].variables[0].source',
66+
subject: '`no_such_component`',
67+
},
68+
{
69+
name: 'a `source` naming a component of another page\'s spelling (case differs)',
70+
item: page([{ ...PICKED, source: 'Project_Picker' }]),
71+
rule: SOURCE_UNKNOWN,
72+
path: 'pages[0].variables[0].source',
73+
subject: '`Project_Picker`',
74+
},
75+
{
76+
name: 'a `visibleWhen` reading an undeclared `page.nosuch` (the card\'s shape)',
77+
item: page([PICKED], "page.nosuch == 'x'"),
78+
rule: READ_UNDECLARED,
79+
path: PANEL,
80+
subject: '`page.nosuch`',
81+
},
82+
{
83+
name: 'a `{ dialect, source }` envelope reading an undeclared variable',
84+
item: page([PICKED], { dialect: 'cel', source: "page.selectedProjectID != ''" }),
85+
rule: READ_UNDECLARED,
86+
path: PANEL,
87+
subject: '`page.selectedProjectID`',
88+
},
89+
{
90+
name: 'an optional select `page.?nosuch`',
91+
item: page([PICKED], "page.?nosuch.orValue('') == 'x'"),
92+
rule: READ_UNDECLARED,
93+
path: PANEL,
94+
subject: '`page.nosuch`',
95+
},
96+
{
97+
name: 'a `page.X` read on a page that declares no variables at all',
98+
item: page([], "page.selectedProjectId != ''"),
99+
rule: READ_UNDECLARED,
100+
path: PANEL,
101+
subject: '`page.selectedProjectId`',
102+
},
103+
] as const;
104+
105+
/** References that resolve, the showcase's own included. */
106+
const ACCEPTED = [
107+
{ name: 'the showcase picker: `source` names the picker, the panel reads the variable', item: page([PICKED]) },
108+
{ name: 'a variable with no `source` (nothing writes it but its default)', item: page([{ name: 'tab', type: 'string', defaultValue: 'a' }], "page.tab == 'a'") },
109+
{ name: 'a `has()` guard on a declared variable', item: page([PICKED], "has(page.selectedProjectId) && page.selectedProjectId != ''") },
110+
{ name: 'a deeper read through a declared object variable', item: page([PICKED, { name: 'filters', type: 'object' }], "page.filters.status == 'open'") },
111+
{ name: 'a comprehension binding `page` as its own loop variable', item: page([PICKED], "[1, 2].exists(page, page > 1)") },
112+
{ name: 'no `page` read at all', item: page([PICKED], "record.status == 'open'") },
113+
] as const;
114+
115+
describe('#28725 — references into a page\'s variables are resolved at every door', () => {
116+
for (const c of REFUSED) {
117+
it(`⭐ LIT — ${c.name} is refused at the page door, located at the reference`, () => {
118+
const result = gatePage(c.item);
119+
120+
const errs = ours(result.errors);
121+
expect(errs, dump(result)).toHaveLength(1);
122+
expect(errs[0]).toMatchObject({ rule: c.rule, severity: 'error', path: c.path, where: 'page "vars_page"' });
123+
expect(errs[0]!.message).toContain(c.subject);
124+
});
125+
}
126+
127+
it('⭐ PARITY — `os validate` refuses each with the door\'s own finding', () => {
128+
for (const c of REFUSED) {
129+
const atBuild = buildFindings(c.item);
130+
expect(atBuild, c.name).toHaveLength(1);
131+
expect(ours(gatePage(c.item).errors), c.name).toEqual(atBuild);
132+
}
133+
});
134+
135+
for (const c of ACCEPTED) {
136+
it(`⭐ CONTROL — ${c.name} is admitted at the door and by \`os validate\``, () => {
137+
const result = gatePage(c.item);
138+
expect(ours(result.errors), dump(result)).toEqual([]);
139+
expect(ours(result.advisories), dump(result)).toEqual([]);
140+
expect(buildFindings(c.item)).toEqual([]);
141+
});
142+
}
143+
144+
it('a `source` naming a component nested in a container\'s `children` resolves', () => {
145+
const item = page([{ ...PICKED, source: 'nested_picker' }]);
146+
(item.regions[0]!.components[2] as any).properties.children = [
147+
{ id: 'nested_picker', type: 'element:record_picker', dataSource: { object: 'project' }, properties: { label: 'Project', labelField: 'name' } },
148+
];
149+
expect(ours(gatePage(item).errors)).toEqual([]);
150+
});
151+
152+
it('a source-authored (`kind: \'react\'`) page is not judged on `source`: its components live in the source', () => {
153+
const item = { ...page([{ ...PICKED, source: 'jsx_picker' }]), kind: 'react', source: 'export default () => null' };
154+
expect(ours(gatePage(item).errors).filter((f) => f.rule === SOURCE_UNKNOWN)).toEqual([]);
155+
});
156+
157+
it('two undeclared reads in one predicate are one finding naming the first', () => {
158+
const errs = ours(gatePage(page([PICKED], "page.a == 'x' && page.b == 'y'")).errors);
159+
expect(errs.map((f) => f.rule)).toEqual([READ_UNDECLARED]);
160+
expect(errs[0]!.message).toContain('`page.a`');
161+
});
162+
163+
it('both faults on one page are two findings, each at its own reference', () => {
164+
const errs = ours(gatePage(page([{ ...PICKED, source: 'no_such_component' }], "page.nosuch == 'x'")).errors);
165+
expect(errs.map((f) => [f.rule, f.path]).sort()).toEqual([
166+
[SOURCE_UNKNOWN, 'pages[0].variables[0].source'],
167+
[READ_UNDECLARED, PANEL],
168+
].sort());
169+
});
170+
});

‎packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
VISIBILITY_PREDICATE_SYNTAX,
4646
VISIBILITY_PREDICATE_HAS_ARGUMENT,
4747
VISIBILITY_PREDICATE_UNKNOWN_FUNCTION,
48+
VISIBILITY_PAGE_VARIABLE_UNDECLARED,
4849
VISIBILITY_ROOT_MISLAYERED,
4950
} from './validate-visibility-predicates.js';
5051
import {
@@ -62,17 +63,23 @@ const FAMILY_IDS = new Set<string>([
6263
VISIBILITY_PREDICATE_SYNTAX,
6364
VISIBILITY_PREDICATE_HAS_ARGUMENT,
6465
VISIBILITY_PREDICATE_UNKNOWN_FUNCTION,
66+
VISIBILITY_PAGE_VARIABLE_UNDECLARED,
6567
VISIBILITY_ROOT_MISLAYERED,
6668
PREDICATE_PATH_UNRESOLVED,
6769
PREDICATE_PATH_UNROOTED,
6870
PREDICATE_RHS_PATH_SHAPED,
6971
]);
7072

71-
/** One `page:header` whose props are declared, so a finding is the predicate's. */
73+
/**
74+
* One `page:header` whose props are declared, so a finding is the predicate's.
75+
* The page declares the one variable a control reads (#28725: an undeclared
76+
* `page.NAME` read is refused).
77+
*/
7278
const page = (visibleWhen: unknown) => ({
7379
name: 'pipeline_home',
7480
label: 'Pipeline',
7581
type: 'app',
82+
variables: [{ name: 'selectedProjectId', type: 'record_id' }],
7683
regions: [
7784
{ name: 'main', components: [{ type: 'page:header', properties: { title: 'Pipeline' }, visibleWhen }] },
7885
],

0 commit comments

Comments
 (0)