Skip to content

Commit 12a920b

Browse files
committed
feat(lint): warn on a page component props CEL slot over the platform parse bounds
Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
1 parent f32d795 commit 12a920b

4 files changed

Lines changed: 449 additions & 0 deletions

File tree

‎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,
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)