diff --git a/.changeset/24953-page-predicate-bounds-warning.md b/.changeset/24953-page-predicate-bounds-warning.md new file mode 100644 index 00000000000..481f07aba77 --- /dev/null +++ b/.changeset/24953-page-predicate-bounds-warning.md @@ -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. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index b7d8d69e440..80b4a54a45d 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -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... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index d1d4195b252..6711a28076c 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -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)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 54a452c3437..3c0b902c7e7 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -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 diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 7907a0ea76f..bec9c89b2ed 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -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)... diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 7e28c489059..aade544ca9a 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -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'; @@ -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 diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index d5446e44dde..fac2fbcb61e 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -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, diff --git a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts index c365bfc9b27..89de9d23072 100644 --- a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts +++ b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts @@ -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', ]); }); diff --git a/packages/lint/src/validate-page-predicate-bounds.test.ts b/packages/lint/src/validate-page-predicate-bounds.test.ts new file mode 100644 index 00000000000..a9b36894faf --- /dev/null +++ b/packages/lint/src/validate-page-predicate-bounds.test.ts @@ -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) => ({ + name: 'deal_home', + label: 'Deal', + type: 'app', + regions: [{ name: 'main', components: [component] }], +}); + +const ours = (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'); + }); +}); diff --git a/packages/lint/src/validate-page-predicate-bounds.ts b/packages/lint/src/validate-page-predicate-bounds.ts new file mode 100644 index 00000000000..965eca646c6 --- /dev/null +++ b/packages/lint/src/validate-page-predicate-bounds.ts @@ -0,0 +1,221 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Page component props predicates over the platform's CEL parse bounds (#24953). + * + * ## What this closes + * + * `@objectstack/formula`'s canonical front end states the platform contract + * (`parseCelToAst`, `cel-engine.ts`): a source over `DEFAULT_LIMITS` + * (`maxAstNodes` 256, `maxDepth`, `maxListElements`, …) "does not parse here, + * because it does not parse anywhere else on the platform either". A predicate + * that does not parse never evaluates, and the console fails OPEN on a + * predicate fault — the gated element draws for everyone. + * + * The component NODE's own `visibleWhen` is already judged for this + * (`visibility-predicate-over-budget`, an error, in + * `validate-visibility-predicates.ts`). The CEL slots a component's props + * schema declares INSIDE its `properties` bag were judged by nobody: a + * `page:tabs` item `visibleWhen`, an `object-form` section / inline-field + * `visibleWhen` / `readonlyWhen` / `requiredWhen`, an inline option's + * `visibleWhen`, `record:alert` `visible`, the `visible` / `disabled` of the + * `action:*` blocks and their members. Measured on `main` before this rule: a + * `page:tabs` item `visibleWhen` of 98 `||` terms (489 AST nodes) parsed + * through `PageSchema` and drew ZERO findings from `os validate`, `os build` + * and the page publish door, while the same source on the component node drew + * the over-budget error. + * + * ## Which strings are CEL — read off the props schema, not guessed + * + * Every one of those slots is `EvaluatedExpressionInputSchema` (directly, or + * as the expression arm of a `boolean | expression` union), whose string arm + * transforms to the `{ dialect: 'cel', source }` envelope. So the positions + * are the ones where the component's `ComponentPropsMap` row, parsing the + * authored bag, PRODUCES a CEL envelope — this rule keeps no list of key + * names, and a slot a props row declares tomorrow is judged with no edit here. + * A type with no props row, or a bag the row refuses, yields nothing: an + * unregistered type is an open union by design (`validate-component-props.ts` + * header), and a refused bag already draws `component-props-invalid` at the + * same doors. + * + * Child components in the bag's composition slots (`children`, `footer`, + * `items[].children`, from spec's `pageComponentSlotPositions()`) are NOT + * descended here: `walkPageComponents` yields each of them as a component of + * its own, so its node `visibleWhen` is the visibility family's and its own + * bag is judged when the walk reaches it — descending here would report it + * twice. + * + * ## The verdict is `validateExpression`'s + * + * Each source is handed to `validateExpression` (ADR-0032) and only its bounds + * refusal (`cel-too-large`, with the bound's key and value) is reported. The + * other refusal classes are not this rule's: they depend on the binding + * environment, and a `page:tabs` item, for one, is evaluated in a different + * environment from the component node (its row is `data`, and its bare fields + * are spread), so a root or reference verdict borrowed from another surface + * would be wrong here. The bounds verdict is environment-free — it is a fact + * about the source's size. The finding does not echo the source (it is over + * budget by definition, and `validateExpression`'s own `detail` quotes the + * author's line); the `path` locates it. + * + * ## Why a warning + * + * Refusing a page that saves today is a narrowing no ADR-0087 conversion can + * carry — a predicate cannot be shrunk mechanically — so under the standing + * ruling for breaking narrowings after the 18.0 GA (「甲」, record 6292005854) + * the 18.x line ships the loud half only: a `warning` at `os validate`, + * `os build`, `os lint` and the page publish door's `advisories` channel. The + * refusal belongs to the next major, which promotes the finding to `error`. + */ + +import { validateExpression } from '@objectstack/formula'; +import { ComponentPropsMap, pageComponentSlotPositions } from '@objectstack/spec/ui'; + +import { walkPageComponents, type AnyRec } from './page-walk.js'; +import { recordsOf } from './object-graph.js'; + +/** A CEL slot inside a page component's `properties` that overruns a platform parse bound. */ +export const PAGE_PREDICATE_OVER_BUDGET = 'page-predicate-over-budget'; + +export interface PagePredicateBoundsFinding { + severity: 'warning'; + rule: typeof PAGE_PREDICATE_OVER_BUDGET; + /** e.g. `page "pipeline_home" · page:tabs`. */ + where: string; + /** The slot itself, e.g. `pages[0].regions[0].components[0].properties.items[1].visibleWhen`. */ + path: string; + message: string; + hint: string; +} + +/** A zod schema, as much of one as this file reads. */ +interface PropsSchema { + safeParse(value: unknown): { success: boolean; data?: unknown }; +} + +const PROPS_SCHEMAS = ComponentPropsMap as unknown as Record; + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** The parsed form of every CEL slot: `EvaluatedExpressionInputSchema`'s output. */ +function celSource(v: unknown): string | undefined { + if (!isRec(v) || v.dialect !== 'cel' || typeof v.source !== 'string') return undefined; + return v.source.trim() ? v.source : undefined; +} + +/** A CEL slot found in a parsed props bag, with the key it is authored under. */ +interface CelSlot { + /** Path relative to the bag, e.g. `.items[1].visibleWhen`. */ + rel: string; + /** The key the source sits under, e.g. `visibleWhen`. */ + key: string; + source: string; +} + +/** + * Every CEL envelope in a parsed props bag, depth-first, skipping the + * composition slots (see the module header). Cycle-safe on an ANCESTOR set for + * the reason `walkPageComponents` gives: an open `z.unknown()` member passes + * authored references through the parse, and a self-containing value is legal + * input. + */ +function celSlots(bag: AnyRec): CelSlot[] { + const slots = pageComponentSlotPositions().filter((position) => !position.retired); + const direct = new Set(slots.filter((s) => s.panelKey === undefined).map((s) => s.key)); + const panels = new Map(slots.filter((s) => s.panelKey !== undefined).map((s) => [s.key, s.panelKey!])); + + const out: CelSlot[] = []; + const ancestors = new Set(); + const visit = (node: unknown, rel: string, key: string, panelKey?: string): void => { + const source = celSource(node); + if (source !== undefined) { + out.push({ rel, key, source }); + return; + } + if (!node || typeof node !== 'object' || ancestors.has(node)) return; + ancestors.add(node); + try { + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i++) visit(node[i], `${rel}[${i}]`, key, panelKey); + return; + } + for (const [k, v] of Object.entries(node)) { + // A panel entry's child list (`items[].children`) is the walk's. + if (panelKey !== undefined && k === panelKey) continue; + visit(v, `${rel}.${k}`, k); + } + } finally { + ancestors.delete(node); + } + }; + + for (const [k, v] of Object.entries(bag)) { + if (direct.has(k)) continue; + const panelKey = panels.get(k); + if (panelKey !== undefined && Array.isArray(v)) { + ancestors.add(v); + for (let i = 0; i < v.length; i++) visit(v[i], `.${k}[${i}]`, k, panelKey); + ancestors.delete(v); + continue; + } + visit(v, `.${k}`, k); + } + return out; +} + +/** `validateExpression`'s bounds verdict on one source, or `null` when it gives none. */ +function boundsVerdict(source: string): { limit?: string; limitValue?: number } | null { + const result = validateExpression('predicate', { dialect: 'cel', source }); + for (const error of result.errors) { + if (error.code !== 'cel-too-large') continue; + const { limit, limitValue } = error.params as { limit?: string; limitValue?: number }; + return { limit, limitValue }; + } + return null; +} + +/** Report every CEL slot in a page component's props that overruns a `DEFAULT_LIMITS` bound. */ +export function validatePagePredicateBounds(stack: AnyRec): PagePredicateBoundsFinding[] { + const findings: PagePredicateBoundsFinding[] = []; + if (!isRec(stack)) return findings; + + const pages = recordsOf(stack.pages); + for (let pi = 0; pi < pages.length; pi++) { + const page = pages[pi]; + if (!isRec(page)) continue; + const pageName = typeof page.name === 'string' && page.name ? page.name : `pages[${pi}]`; + + for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) { + const type = typeof component.type === 'string' ? component.type : undefined; + if (!type || !isRec(component.properties)) continue; + const schema = PROPS_SCHEMAS[type]; + if (!schema) continue; + const parsed = schema.safeParse(component.properties); + if (!parsed.success || !isRec(parsed.data)) continue; + + for (const slot of celSlots(parsed.data)) { + const verdict = boundsVerdict(slot.source); + if (!verdict) continue; + const bound = verdict.limit && verdict.limitValue !== undefined + ? `the \`${verdict.limit}\` budget (platform limit ${verdict.limitValue})` + : "one of the platform's CEL parse budgets"; + findings.push({ + severity: 'warning', + rule: PAGE_PREDICATE_OVER_BUDGET, + where: `page "${pageName}" · ${type}`, + path: `${path}.properties${slot.rel}`, + message: + `\`${slot.key}\` is valid CEL that exceeds ${bound}, so it does not parse anywhere on the ` + + 'platform: it faults on every evaluation and never gates what it was written to gate. ' + + 'A future major release refuses it.', + hint: + "Make the expression smaller: collapse an `||` chain into one `in ['a', 'b', …]` test, or " + + 'precompute the heavy part into a formula or roll-up field on the object and test that one field', + }); + } + } + } + return findings; +}