Skip to content

Commit 0a3ce08

Browse files
feat(lint): a page component props CEL slot over the platform parse bounds warns at validate, build and the page door (#24999)
Closes #24953 ## What A CEL slot declared **inside** a page component's `properties` bag that overruns `@objectstack/formula`'s `DEFAULT_LIMITS` (`maxAstNodes` 256, `maxDepth`, `maxListElements`) is now reported at `os validate`, `os build`, `os lint` and the page publish door (on its `advisories` channel). The rule is `page-predicate-over-budget` (`validatePagePredicateBounds`, a new file in `packages/lint`). It is a **warning** in 18.x under the standing ruling 「甲」 (#6015, record 6292005854). Refusing a page that saves today is a narrowing that no ADR-0087 conversion can carry, because a predicate cannot be shrunk mechanically. The 19.0 refusal text is in the report for the seat to file. ## Premise, measured on `origin/main` f32d795 - The page:tabs item `visibleWhen` the card names had 98 `||` terms (489 AST nodes, `validateExpression` answers `cel-too-large` / `maxAstNodes` / 256). `PageSchema.safeParse` gave **success**. `runAuthoringRules('validate' | 'build')` gave **0 findings**. The page door gave **0 errors, 0 advisories**. - Control: the same source on the component **node** `visibleWhen` gave `visibility-predicate-over-budget`, **error**. So the bound is already enforced as an error on the node slot, by the visibility family. The tab item is not a component, so that pass never reads its `visibleWhen`. - The same gap holds for every other CEL slot a props row declares. Measured clean on main, each over the bound: an `object-form` section `visibleWhen`, an `object-form` inline field `visibleWhen`, and `record:alert` `visible`. A syntax fault on a tab item (`record.x === 1`) also drew nothing; see Acceptance notes. ## How - The slots come from the schema, not from a list of key names. Each walked component's `properties` is parsed with its `ComponentPropsMap` row. Every position where the parse **produces** a `{ dialect: 'cel', source }` envelope (the output of `EvaluatedExpressionInputSchema`) is a CEL slot. A slot that a row declares later is covered with no edit here. - The verdict is `validateExpression`'s. Only its `cel-too-large` refusal is reported, with the `limit` and `limitValue` it carries. The finding names the key and the bound. It does not echo the source: `validateExpression`'s `detail` quotes the author's line. - There is no double report. Composition slots (`children`, `footer`, `items[].children`, from `pageComponentSlotPositions()`) are not descended, because `walkPageComponents` yields those children as components of their own. The node `visibleWhen` stays the visibility family's error. - Wiring: `advisory` tier, all three commands, `CLI_AND_RUNTIME`, `runtimeTypes: ['page']`. - `content/docs`: the author-time rule count in four CLI transcripts moves from 49 to 50 (`check:docs-transcript-drift`). ## Tests (head `cceddfcb61`) - `validate-page-predicate-bounds.test.ts`, 18 cases. They cover: the card's shape as a warning at each of validate, build and lint; the door's advisory equal to the build's finding; the within-bounds control drawing nothing at any door; the envelope form; `maxDepth` and `maxListElements` naming their bound; object-form section, inline field `requiredWhen` and `record:alert` `visible`; a boolean `visible` drawing nothing; nested tabs; no double report on node `visibleWhen`; and the wiring. - `@objectstack/lint` full suite: 152 files passed. The one red was the pinned page-door rule roster, updated in `runtime-gate.page-visibility-predicate-writes.test.ts`, and that file is 15/15 green. `@objectstack/lint` typecheck green. - `dispatch-gates --ran` accounts for all 94 derived families. `check:generated` is green. NOT MEASURED, prerequisite not met (no built `dist/` for the closure; declared to CI): `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `@objectstack/spec check:skill-examples`. ## Acceptance notes - Only bounds are judged on the props-bag slots. A **syntax** fault in the same slots (for example `===` on a page:tabs item `visibleWhen`) still draws nothing and fails open in the same way. Root and reference verdicts cannot be borrowed from the node surface: a tab item binds `data` to the row and spreads its bare fields. It is reported for the seat. - Out of scope: objectui's fail-open on a faulting tab-item predicate (reported for objectui). - Open PR #24947 also touches `authoring-rules.ts`, on a different entry. No textual overlap is expected beyond registry adjacency. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 86d2360 commit 0a3ce08

10 files changed

Lines changed: 468 additions & 4 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
feat(lint): a CEL slot inside a page component's `properties` that overruns the platform's parse bounds is reported as a warning at `os validate`, `os build`, `os lint` and the page publish door
6+
7+
Clause-②: no
8+
9+
A source over `DEFAULT_LIMITS` (`maxAstNodes` 256, `maxDepth`, `maxListElements`) does not parse anywhere on the platform. It faults on every evaluation, and the console fails open, so the element it was written to gate draws for everyone. The component node's own `visibleWhen` was already refused for this (`visibility-predicate-over-budget`). The CEL slots declared inside the props bag were judged by no rule. These include a `page:tabs` item `visibleWhen`, an `object-form` section or inline field `visibleWhen` / `readonlyWhen` / `requiredWhen`, `record:alert` `visible`, and the `visible` / `disabled` of the `action:*` blocks. A page carrying such a slot saved clean through every door.
10+
11+
The new rule `page-predicate-over-budget` (`validatePagePredicateBounds`) reads the slots off each component's `ComponentPropsMap` row: every position where the row's parse produces a `{ dialect: 'cel', source }` envelope. It judges each one with `validateExpression`'s bounds verdict and names the key and the bound. It does not echo the source. It is a **warning** in 18.x: the page still saves and builds, and the publish door carries the finding on its `advisories` channel. A future major release refuses it. A within-bounds source draws nothing.
12+
13+
Fix: shrink the expression. Collapse an `||` chain into one `in [...]` test, or precompute the heavy part into a formula or roll-up field and test that field.

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,7 @@ os compile --json # JSON output for CI pipelines
701701
→ Normalizing stack definition...
702702
→ Lowering inline handlers...
703703
→ Validating protocol compliance...
704-
→ Running author-time rules (49)...
704+
→ Running author-time rules (50)...
705705
→ Checking that every required capability has a provider installable in this edition...
706706
→ Collecting package docs (ADR-0046)... 0 collected
707707
→ Writing artifact...

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,7 @@ A clean run walks the registry and reports timing:
732732
Config: /path/to/support-desk/objectstack.config.ts
733733
Load time: 21ms
734734
→ Validating against ObjectStack Protocol...
735-
→ Running author-time rules (49)...
735+
→ Running author-time rules (50)...
736736
→ Checking that every required capability has a provider installable in this edition...
737737
→ Checking package docs (ADR-0046)...
738738

‎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 (49)...
308+
→ Running author-time rules (50)...
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
@@ -390,7 +390,7 @@ objectstack validate
390390
────────────────────────────────────────
391391
→ Loading configuration...
392392
→ Validating against ObjectStack Protocol...
393-
→ Running author-time rules (49)...
393+
→ Running author-time rules (50)...
394394
→ Checking that every required capability has a provider installable in this edition...
395395
→ Checking package docs (ADR-0046)...
396396

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

Lines changed: 21 additions & 0 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 { validatePagePredicateBounds } from './validate-page-predicate-bounds.js';
116117
import { validateResponsiveStyles } from './validate-responsive-styles.js';
117118
import { validateJsxPages } from './validate-jsx-pages.js';
118119
import { validateReactPages } from './validate-react-pages.js';
@@ -1704,6 +1705,26 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
17041705
runtimeTypes: ['view', 'page'],
17051706
run: (stack) => validatePredicatePathRefs(stack),
17061707
},
1708+
// #24953 — the CEL slots INSIDE a page component's `properties` bag (a
1709+
// `page:tabs` item `visibleWhen`, `object-form` section / field predicates,
1710+
// `record:alert` / `action:*` `visible` and `disabled`), judged for the
1711+
// platform's parse bounds with `validateExpression`'s verdict. The component
1712+
// node's own `visibleWhen` is the visibility family's above, an error; these
1713+
// slots were judged by no rule at any door. `advisory` in 18.x: refusing a
1714+
// page that saves today is a narrowing no ADR-0087 conversion can carry, so
1715+
// under the standing ruling for post-GA narrowings (「甲」, record
1716+
// 6292005854) only the warning ships now and the refusal waits for the next
1717+
// major. Page-local, so the per-write snapshot is the whole input.
1718+
{
1719+
name: 'validatePagePredicateBounds',
1720+
tier: 'advisory',
1721+
input: 'normalized',
1722+
commands: ALL,
1723+
source: 'packages/lint/src/validate-page-predicate-bounds.ts',
1724+
surfaces: CLI_AND_RUNTIME,
1725+
runtimeTypes: ['page'],
1726+
run: (stack) => validatePagePredicateBounds(stack),
1727+
},
17071728
// #1874 — flow authoring anti-patterns. Advisory by default; a finding marked
17081729
// `error` gates. Three do today: `flow-runas-unscoped` (#3760 — metadata the
17091730
// runtime REFUSES to execute), plus `flow-branch-label-unmatched` and

‎packages/lint/src/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,15 @@ export {
601601
} from './validate-print-page-blocks.js';
602602
export type { PrintPageBlockFinding } from './validate-print-page-blocks.js';
603603

604+
// #24953 — a CEL slot inside a page component's `properties` (a `page:tabs`
605+
// item `visibleWhen`, an `object-form` section predicate, an `action:*`
606+
// `visible`, …) over the platform's parse bounds. A warning in 18.x.
607+
export {
608+
validatePagePredicateBounds,
609+
PAGE_PREDICATE_OVER_BUDGET,
610+
} from './validate-page-predicate-bounds.js';
611+
export type { PagePredicateBoundsFinding } from './validate-page-predicate-bounds.js';
612+
604613
export {
605614
validateChartBindings,
606615
CHART_DIMENSION_UNKNOWN,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ describe('#24491 — the page door gives the build\'s visibility-predicate verdi
127127
'validateComponentTypes',
128128
'validatePrintPageBlocks',
129129
...FAMILY,
130+
// #24953 — the props-bag CEL slots' bounds warning, after the family.
131+
'validatePagePredicateBounds',
130132
]);
131133
});
132134

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24953 — a CEL slot inside a page component's `properties` over the
5+
* platform's parse bounds is reported, with `validateExpression`'s bounds
6+
* verdict, at every door that accepts a page; a WARNING in 18.x (「甲」).
7+
*
8+
* The card's shape is a `page:tabs` item `visibleWhen` of ~390 AST nodes,
9+
* which parsed through `PageSchema` and drew no finding anywhere while the
10+
* console faulted on it at every evaluation and drew the tab for everyone.
11+
*/
12+
import { validateExpression } from '@objectstack/formula';
13+
import { PageSchema } from '@objectstack/spec/ui';
14+
import { describe, expect, it } from 'vitest';
15+
16+
import { authoringRulesFor, runAuthoringRules } from './authoring-rules.js';
17+
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
18+
import { PAGE_PREDICATE_OVER_BUDGET, validatePagePredicateBounds } from './validate-page-predicate-bounds.js';
19+
import { VISIBILITY_PREDICATE_OVER_BUDGET } from './validate-visibility-predicates.js';
20+
21+
/** `n` `||` terms — five AST nodes each, so 78 terms is the card's ~390-node class. */
22+
const orChain = (n: number) => Array.from({ length: n }, (_, i) => `record.f${i} == ${i}`).join(' || ');
23+
const OVER_AST_NODES = orChain(78);
24+
/** 60-level parenthesis nest — `maxDepth`. */
25+
const OVER_DEPTH = `${'('.repeat(60)}record.a${')'.repeat(60)} == 1`;
26+
/** 200-element list literal — `maxListElements`. */
27+
const OVER_LIST = `record.id in [${Array.from({ length: 200 }, (_, i) => `'u${i}'`).join(',')}]`;
28+
const WITHIN = "record.stage == 'won' || page.tab != ''";
29+
30+
const tabsPage = (tabVisibleWhen: unknown, nodeVisibleWhen?: unknown) => ({
31+
name: 'deal_home',
32+
label: 'Deal',
33+
type: 'app',
34+
regions: [
35+
{
36+
name: 'main',
37+
components: [
38+
{
39+
type: 'page:tabs',
40+
...(nodeVisibleWhen === undefined ? {} : { visibleWhen: nodeVisibleWhen }),
41+
properties: {
42+
items: [
43+
{ label: 'Overview', value: 'overview', children: [] },
44+
{ label: 'Finance', value: 'finance', visibleWhen: tabVisibleWhen, children: [] },
45+
],
46+
},
47+
},
48+
],
49+
},
50+
],
51+
});
52+
const TAB_PATH = 'pages[0].regions[0].components[0].properties.items[1].visibleWhen';
53+
54+
const pageWith = (component: Record<string, unknown>) => ({
55+
name: 'deal_home',
56+
label: 'Deal',
57+
type: 'app',
58+
regions: [{ name: 'main', components: [component] }],
59+
});
60+
61+
const ours = <T extends { rule: string }>(fs: readonly T[]): T[] => fs.filter((f) => f.rule === PAGE_PREDICATE_OVER_BUDGET);
62+
const gatePage = (item: unknown) => runRuntimeAuthoringRules({ type: 'page', item, context: { objects: [] } });
63+
64+
describe('#24953 — the card\'s shape: a `page:tabs` item `visibleWhen` over `maxAstNodes`', () => {
65+
it('premise: the source is over the bound by `validateExpression`, and `PageSchema` still accepts the page', () => {
66+
const verdict = validateExpression('predicate', OVER_AST_NODES);
67+
expect(verdict.errors.map((e) => e.code)).toEqual(['cel-too-large']);
68+
expect(verdict.errors[0]!.params).toMatchObject({ limit: 'maxAstNodes', limitValue: 256 });
69+
// Parse acceptance is unchanged by this rule — it is a finding, not a refusal.
70+
expect(PageSchema.safeParse(tabsPage(OVER_AST_NODES)).success).toBe(true);
71+
});
72+
73+
it('is a WARNING naming the field and the bound, at the slot, without echoing the source', () => {
74+
const findings = validatePagePredicateBounds({ pages: [tabsPage(OVER_AST_NODES)] });
75+
expect(findings).toHaveLength(1);
76+
expect(findings[0]).toMatchObject({
77+
severity: 'warning',
78+
rule: PAGE_PREDICATE_OVER_BUDGET,
79+
where: 'page "deal_home" · page:tabs',
80+
path: TAB_PATH,
81+
});
82+
expect(findings[0]!.message).toContain('`visibleWhen`');
83+
expect(findings[0]!.message).toContain('`maxAstNodes` budget (platform limit 256)');
84+
expect(findings[0]!.message).not.toContain('record.f0');
85+
});
86+
87+
for (const command of ['validate', 'build', 'lint'] as const) {
88+
it(`\`os ${command}\` reports it as a warning, never an error`, () => {
89+
const stack = { pages: [tabsPage(OVER_AST_NODES)] };
90+
const findings = runAuthoringRules(command, { normalized: stack, parsed: stack });
91+
expect(ours(findings).map((f) => [f.severity, f.path])).toEqual([['warning', TAB_PATH]]);
92+
expect(findings.filter((f) => f.severity === 'error')).toEqual([]);
93+
});
94+
}
95+
96+
it('the page publish door carries it on the advisories channel, the build\'s finding verbatim', () => {
97+
const result = gatePage(tabsPage(OVER_AST_NODES));
98+
expect(result.rulesRun).toContain('validatePagePredicateBounds');
99+
expect(ours(result.errors)).toEqual([]);
100+
const atDoor = ours(result.advisories);
101+
expect(atDoor.map((f) => f.rule)).toEqual([PAGE_PREDICATE_OVER_BUDGET]);
102+
const stack = { pages: [tabsPage(OVER_AST_NODES)] };
103+
const atBuild = ours(runAuthoringRules('build', { normalized: stack, parsed: stack }));
104+
expect(atDoor.map(({ message, hint, severity }) => ({ message, hint, severity })))
105+
.toEqual(atBuild.map(({ message, hint, severity }) => ({ message, hint, severity })));
106+
});
107+
108+
it('CONTROL — a within-bounds tab predicate draws nothing at any door', () => {
109+
const stack = { pages: [tabsPage(WITHIN)] };
110+
expect(validatePagePredicateBounds(stack)).toEqual([]);
111+
expect(ours(runAuthoringRules('validate', { normalized: stack, parsed: stack }))).toEqual([]);
112+
const result = gatePage(tabsPage(WITHIN));
113+
expect(ours([...result.errors, ...result.advisories])).toEqual([]);
114+
});
115+
116+
it('the `{ dialect, source }` envelope is the same slot', () => {
117+
const findings = validatePagePredicateBounds({ pages: [tabsPage({ dialect: 'cel', source: OVER_AST_NODES })] });
118+
expect(findings.map((f) => f.path)).toEqual([TAB_PATH]);
119+
});
120+
121+
it.each([
122+
['maxDepth', OVER_DEPTH],
123+
['maxListElements', OVER_LIST],
124+
])('names the bound the source overruns — %s', (limit, source) => {
125+
const findings = validatePagePredicateBounds({ pages: [tabsPage(source)] });
126+
expect(findings).toHaveLength(1);
127+
expect(findings[0]!.message).toContain(`\`${limit}\` budget`);
128+
});
129+
});
130+
131+
describe('#24953 — the slots are read off the props schema, not a key list', () => {
132+
it.each([
133+
[
134+
'an `object-form` section `visibleWhen`',
135+
{ type: 'object-form', properties: { objectName: 'deal', sections: [{ label: 'S', visibleWhen: OVER_AST_NODES, fields: ['name'] }] } },
136+
'.properties.sections[0].visibleWhen',
137+
],
138+
[
139+
'an `object-form` inline field `requiredWhen`',
140+
{ type: 'object-form', properties: { objectName: 'deal', customFields: [{ name: 'note', type: 'text', requiredWhen: OVER_AST_NODES }] } },
141+
'.properties.customFields[0].requiredWhen',
142+
],
143+
[
144+
'a `record:alert` `visible`',
145+
{ type: 'record:alert', properties: { title: 'Heads up', visible: OVER_AST_NODES } },
146+
'.properties.visible',
147+
],
148+
])('%s', (_label, component, rel) => {
149+
const findings = validatePagePredicateBounds({ pages: [pageWith(component)] });
150+
expect(findings.map((f) => f.path)).toEqual([`pages[0].regions[0].components[0]${rel}`]);
151+
});
152+
153+
it('a `boolean` in a `boolean | expression` slot is not CEL and draws nothing', () => {
154+
const stack = { pages: [pageWith({ type: 'record:alert', properties: { title: 'Heads up', visible: false } })] };
155+
expect(validatePagePredicateBounds(stack)).toEqual([]);
156+
});
157+
158+
it('a tab panel nested in another tab panel is reached through the shared page walk', () => {
159+
const inner = tabsPage(OVER_AST_NODES).regions[0]!.components[0]!;
160+
const outer = pageWith({
161+
type: 'page:tabs',
162+
properties: { items: [{ label: 'Outer', value: 'outer', children: [inner] }] },
163+
});
164+
expect(validatePagePredicateBounds({ pages: [outer] }).map((f) => f.path)).toEqual([
165+
'pages[0].regions[0].components[0].properties.items[0].children[0].properties.items[1].visibleWhen',
166+
]);
167+
});
168+
});
169+
170+
describe('#24953 — no double report with the visibility family', () => {
171+
it('the component NODE `visibleWhen` stays the visibility family\'s error, and this rule says nothing of it', () => {
172+
const stack = { pages: [tabsPage(WITHIN, OVER_AST_NODES)] };
173+
const findings = runAuthoringRules('build', { normalized: stack, parsed: stack });
174+
expect(ours(findings)).toEqual([]);
175+
expect(findings.filter((f) => f.rule === VISIBILITY_PREDICATE_OVER_BUDGET).map((f) => [f.severity, f.path]))
176+
.toEqual([['error', 'pages[0].regions[0].components[0]']]);
177+
});
178+
179+
it('a child component\'s node `visibleWhen` inside a tab panel is not judged here either', () => {
180+
const page = pageWith({
181+
type: 'page:tabs',
182+
properties: { items: [{ label: 'A', value: 'a', children: [{ type: 'element:text', visibleWhen: OVER_AST_NODES }] }] },
183+
});
184+
expect(validatePagePredicateBounds({ pages: [page] })).toEqual([]);
185+
});
186+
});
187+
188+
describe('#24953 — wiring', () => {
189+
it('runs on all three commands as an advisory rule, and at the page door only', () => {
190+
const entry = authoringRulesFor('validate').find((r) => r.name === 'validatePagePredicateBounds');
191+
expect(entry?.tier).toBe('advisory');
192+
for (const command of ['build', 'lint'] as const) {
193+
expect(authoringRulesFor(command).map((r) => r.name)).toContain('validatePagePredicateBounds');
194+
}
195+
expect(runtimeAuthoringRulesFor('page').map((r) => r.name)).toContain('validatePagePredicateBounds');
196+
expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).not.toContain('validatePagePredicateBounds');
197+
});
198+
});

0 commit comments

Comments
 (0)