Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/24953-page-predicate-bounds-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/lint': minor
---

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

Clause-②: no

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.

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.

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.
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,7 @@ os compile --json # JSON output for CI pipelines
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (49)...
→ Running author-time rules (50)...
→ Checking that every required capability has a provider installable in this edition...
→ Collecting package docs (ADR-0046)... 0 collected
→ Writing artifact...
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -732,7 +732,7 @@ A clean run walks the registry and reports timing:
Config: /path/to/support-desk/objectstack.config.ts
Load time: 21ms
→ Validating against ObjectStack Protocol...
→ Running author-time rules (49)...
→ Running author-time rules (50)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ visible: 'status != "resolved"'
◆ Validate
────────────────────────────────────────
→ Validating against ObjectStack Protocol...
→ Running author-time rules (49)...
→ Running author-time rules (50)...

✗ Author-time rules failed (1 issue)
• stack · action 'resolve_ticket' visible: bare reference `status` — a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ objectstack validate
────────────────────────────────────────
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (49)...
→ Running author-time rules (50)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
21 changes: 21 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
import { validateComponentProps } from './validate-component-props.js';
import { validateComponentTypes } from './validate-component-types.js';
import { validatePrintPageBlocks } from './validate-print-page-blocks.js';
import { validatePagePredicateBounds } from './validate-page-predicate-bounds.js';
import { validateResponsiveStyles } from './validate-responsive-styles.js';
import { validateJsxPages } from './validate-jsx-pages.js';
import { validateReactPages } from './validate-react-pages.js';
Expand Down Expand Up @@ -1704,6 +1705,26 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
runtimeTypes: ['view', 'page'],
run: (stack) => validatePredicatePathRefs(stack),
},
// #24953 — the CEL slots INSIDE a page component's `properties` bag (a
// `page:tabs` item `visibleWhen`, `object-form` section / field predicates,
// `record:alert` / `action:*` `visible` and `disabled`), judged for the
// platform's parse bounds with `validateExpression`'s verdict. The component
// node's own `visibleWhen` is the visibility family's above, an error; these
// slots were judged by no rule at any door. `advisory` in 18.x: refusing a
// page that saves today is a narrowing no ADR-0087 conversion can carry, so
// under the standing ruling for post-GA narrowings (「甲」, record
// 6292005854) only the warning ships now and the refusal waits for the next
// major. Page-local, so the per-write snapshot is the whole input.
{
name: 'validatePagePredicateBounds',
tier: 'advisory',
input: 'normalized',
commands: ALL,
source: 'packages/lint/src/validate-page-predicate-bounds.ts',
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['page'],
run: (stack) => validatePagePredicateBounds(stack),
},
// #1874 — flow authoring anti-patterns. Advisory by default; a finding marked
// `error` gates. Three do today: `flow-runas-unscoped` (#3760 — metadata the
// runtime REFUSES to execute), plus `flow-branch-label-unmatched` and
Expand Down
9 changes: 9 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,15 @@ export {
} from './validate-print-page-blocks.js';
export type { PrintPageBlockFinding } from './validate-print-page-blocks.js';

// #24953 — a CEL slot inside a page component's `properties` (a `page:tabs`
// item `visibleWhen`, an `object-form` section predicate, an `action:*`
// `visible`, …) over the platform's parse bounds. A warning in 18.x.
export {
validatePagePredicateBounds,
PAGE_PREDICATE_OVER_BUDGET,
} from './validate-page-predicate-bounds.js';
export type { PagePredicateBoundsFinding } from './validate-page-predicate-bounds.js';

export {
validateChartBindings,
CHART_DIMENSION_UNKNOWN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ describe('#24491 — the page door gives the build\'s visibility-predicate verdi
'validateComponentTypes',
'validatePrintPageBlocks',
...FAMILY,
// #24953 — the props-bag CEL slots' bounds warning, after the family.
'validatePagePredicateBounds',
]);
});

Expand Down
198 changes: 198 additions & 0 deletions packages/lint/src/validate-page-predicate-bounds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #24953 — a CEL slot inside a page component's `properties` over the
* platform's parse bounds is reported, with `validateExpression`'s bounds
* verdict, at every door that accepts a page; a WARNING in 18.x (「甲」).
*
* The card's shape is a `page:tabs` item `visibleWhen` of ~390 AST nodes,
* which parsed through `PageSchema` and drew no finding anywhere while the
* console faulted on it at every evaluation and drew the tab for everyone.
*/
import { validateExpression } from '@objectstack/formula';
import { PageSchema } from '@objectstack/spec/ui';
import { describe, expect, it } from 'vitest';

import { authoringRulesFor, runAuthoringRules } from './authoring-rules.js';
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
import { PAGE_PREDICATE_OVER_BUDGET, validatePagePredicateBounds } from './validate-page-predicate-bounds.js';
import { VISIBILITY_PREDICATE_OVER_BUDGET } from './validate-visibility-predicates.js';

/** `n` `||` terms — five AST nodes each, so 78 terms is the card's ~390-node class. */
const orChain = (n: number) => Array.from({ length: n }, (_, i) => `record.f${i} == ${i}`).join(' || ');
const OVER_AST_NODES = orChain(78);
/** 60-level parenthesis nest — `maxDepth`. */
const OVER_DEPTH = `${'('.repeat(60)}record.a${')'.repeat(60)} == 1`;
/** 200-element list literal — `maxListElements`. */
const OVER_LIST = `record.id in [${Array.from({ length: 200 }, (_, i) => `'u${i}'`).join(',')}]`;
const WITHIN = "record.stage == 'won' || page.tab != ''";

const tabsPage = (tabVisibleWhen: unknown, nodeVisibleWhen?: unknown) => ({
name: 'deal_home',
label: 'Deal',
type: 'app',
regions: [
{
name: 'main',
components: [
{
type: 'page:tabs',
...(nodeVisibleWhen === undefined ? {} : { visibleWhen: nodeVisibleWhen }),
properties: {
items: [
{ label: 'Overview', value: 'overview', children: [] },
{ label: 'Finance', value: 'finance', visibleWhen: tabVisibleWhen, children: [] },
],
},
},
],
},
],
});
const TAB_PATH = 'pages[0].regions[0].components[0].properties.items[1].visibleWhen';

const pageWith = (component: Record<string, unknown>) => ({
name: 'deal_home',
label: 'Deal',
type: 'app',
regions: [{ name: 'main', components: [component] }],
});

const ours = <T extends { rule: string }>(fs: readonly T[]): T[] => fs.filter((f) => f.rule === PAGE_PREDICATE_OVER_BUDGET);
const gatePage = (item: unknown) => runRuntimeAuthoringRules({ type: 'page', item, context: { objects: [] } });

describe('#24953 — the card\'s shape: a `page:tabs` item `visibleWhen` over `maxAstNodes`', () => {
it('premise: the source is over the bound by `validateExpression`, and `PageSchema` still accepts the page', () => {
const verdict = validateExpression('predicate', OVER_AST_NODES);
expect(verdict.errors.map((e) => e.code)).toEqual(['cel-too-large']);
expect(verdict.errors[0]!.params).toMatchObject({ limit: 'maxAstNodes', limitValue: 256 });
// Parse acceptance is unchanged by this rule — it is a finding, not a refusal.
expect(PageSchema.safeParse(tabsPage(OVER_AST_NODES)).success).toBe(true);
});

it('is a WARNING naming the field and the bound, at the slot, without echoing the source', () => {
const findings = validatePagePredicateBounds({ pages: [tabsPage(OVER_AST_NODES)] });
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'warning',
rule: PAGE_PREDICATE_OVER_BUDGET,
where: 'page "deal_home" · page:tabs',
path: TAB_PATH,
});
expect(findings[0]!.message).toContain('`visibleWhen`');
expect(findings[0]!.message).toContain('`maxAstNodes` budget (platform limit 256)');
expect(findings[0]!.message).not.toContain('record.f0');
});

for (const command of ['validate', 'build', 'lint'] as const) {
it(`\`os ${command}\` reports it as a warning, never an error`, () => {
const stack = { pages: [tabsPage(OVER_AST_NODES)] };
const findings = runAuthoringRules(command, { normalized: stack, parsed: stack });
expect(ours(findings).map((f) => [f.severity, f.path])).toEqual([['warning', TAB_PATH]]);
expect(findings.filter((f) => f.severity === 'error')).toEqual([]);
});
}

it('the page publish door carries it on the advisories channel, the build\'s finding verbatim', () => {
const result = gatePage(tabsPage(OVER_AST_NODES));
expect(result.rulesRun).toContain('validatePagePredicateBounds');
expect(ours(result.errors)).toEqual([]);
const atDoor = ours(result.advisories);
expect(atDoor.map((f) => f.rule)).toEqual([PAGE_PREDICATE_OVER_BUDGET]);
const stack = { pages: [tabsPage(OVER_AST_NODES)] };
const atBuild = ours(runAuthoringRules('build', { normalized: stack, parsed: stack }));
expect(atDoor.map(({ message, hint, severity }) => ({ message, hint, severity })))
.toEqual(atBuild.map(({ message, hint, severity }) => ({ message, hint, severity })));
});

it('CONTROL — a within-bounds tab predicate draws nothing at any door', () => {
const stack = { pages: [tabsPage(WITHIN)] };
expect(validatePagePredicateBounds(stack)).toEqual([]);
expect(ours(runAuthoringRules('validate', { normalized: stack, parsed: stack }))).toEqual([]);
const result = gatePage(tabsPage(WITHIN));
expect(ours([...result.errors, ...result.advisories])).toEqual([]);
});

it('the `{ dialect, source }` envelope is the same slot', () => {
const findings = validatePagePredicateBounds({ pages: [tabsPage({ dialect: 'cel', source: OVER_AST_NODES })] });
expect(findings.map((f) => f.path)).toEqual([TAB_PATH]);
});

it.each([
['maxDepth', OVER_DEPTH],
['maxListElements', OVER_LIST],
])('names the bound the source overruns — %s', (limit, source) => {
const findings = validatePagePredicateBounds({ pages: [tabsPage(source)] });
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain(`\`${limit}\` budget`);
});
});

describe('#24953 — the slots are read off the props schema, not a key list', () => {
it.each([
[
'an `object-form` section `visibleWhen`',
{ type: 'object-form', properties: { objectName: 'deal', sections: [{ label: 'S', visibleWhen: OVER_AST_NODES, fields: ['name'] }] } },
'.properties.sections[0].visibleWhen',
],
[
'an `object-form` inline field `requiredWhen`',
{ type: 'object-form', properties: { objectName: 'deal', customFields: [{ name: 'note', type: 'text', requiredWhen: OVER_AST_NODES }] } },
'.properties.customFields[0].requiredWhen',
],
[
'a `record:alert` `visible`',
{ type: 'record:alert', properties: { title: 'Heads up', visible: OVER_AST_NODES } },
'.properties.visible',
],
])('%s', (_label, component, rel) => {
const findings = validatePagePredicateBounds({ pages: [pageWith(component)] });
expect(findings.map((f) => f.path)).toEqual([`pages[0].regions[0].components[0]${rel}`]);
});

it('a `boolean` in a `boolean | expression` slot is not CEL and draws nothing', () => {
const stack = { pages: [pageWith({ type: 'record:alert', properties: { title: 'Heads up', visible: false } })] };
expect(validatePagePredicateBounds(stack)).toEqual([]);
});

it('a tab panel nested in another tab panel is reached through the shared page walk', () => {
const inner = tabsPage(OVER_AST_NODES).regions[0]!.components[0]!;
const outer = pageWith({
type: 'page:tabs',
properties: { items: [{ label: 'Outer', value: 'outer', children: [inner] }] },
});
expect(validatePagePredicateBounds({ pages: [outer] }).map((f) => f.path)).toEqual([
'pages[0].regions[0].components[0].properties.items[0].children[0].properties.items[1].visibleWhen',
]);
});
});

describe('#24953 — no double report with the visibility family', () => {
it('the component NODE `visibleWhen` stays the visibility family\'s error, and this rule says nothing of it', () => {
const stack = { pages: [tabsPage(WITHIN, OVER_AST_NODES)] };
const findings = runAuthoringRules('build', { normalized: stack, parsed: stack });
expect(ours(findings)).toEqual([]);
expect(findings.filter((f) => f.rule === VISIBILITY_PREDICATE_OVER_BUDGET).map((f) => [f.severity, f.path]))
.toEqual([['error', 'pages[0].regions[0].components[0]']]);
});

it('a child component\'s node `visibleWhen` inside a tab panel is not judged here either', () => {
const page = pageWith({
type: 'page:tabs',
properties: { items: [{ label: 'A', value: 'a', children: [{ type: 'element:text', visibleWhen: OVER_AST_NODES }] }] },
});
expect(validatePagePredicateBounds({ pages: [page] })).toEqual([]);
});
});

describe('#24953 — wiring', () => {
it('runs on all three commands as an advisory rule, and at the page door only', () => {
const entry = authoringRulesFor('validate').find((r) => r.name === 'validatePagePredicateBounds');
expect(entry?.tier).toBe('advisory');
for (const command of ['build', 'lint'] as const) {
expect(authoringRulesFor(command).map((r) => r.name)).toContain('validatePagePredicateBounds');
}
expect(runtimeAuthoringRulesFor('page').map((r) => r.name)).toContain('validatePagePredicateBounds');
expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).not.toContain('validatePagePredicateBounds');
});
});
Loading
Loading