From 11739644d97901279403da4b2f3a7f70eea3d558 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 14:02:04 +0000 Subject: [PATCH 1/2] feat(evaluator): route CEL-dialect component/action predicates to the canonical engine (#2661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component/action `visible`/`disabled`/`hidden` predicates ran on the home-grown JS `ExpressionEvaluator`, while field rules (fieldRules.ts) and row/list conditionals (evalRowPredicate) already delegate to `@objectstack/formula`. So a `{ dialect: 'cel' }` predicate in a renderer/action surface was executed as JavaScript — CEL-only forms (`x in list`, `has()`, typed `==`, the today()/ daysFromNow() catalog) diverged from or faulted against server enforcement. - core: ExpressionEvaluator.evaluateCondition now detects a `{ dialect: 'cel' }` envelope and evaluates it on the canonical engine (via evalFieldPredicate), binding record.* + the context bag (features.*/user.*/app.*). Fail-soft to visible/enabled (legacy parity); throwOnError still fails closed on a *faulting* predicate, never on a genuine false. Fixes every SchemaRenderer visibility read. - react: toPredicateInput preserves a CEL envelope (instead of collapsing it to a `${source}` string), and useCondition accepts + forwards it. Action buttons (action-icon/group/bar/button) now evaluate CEL visible/enabled/disabled on the canonical engine. Back-compat: bare strings and `${…}` templates stay on the legacy JS path; only an explicit `{ dialect: 'cel' }` envelope is rerouted. Also fixes a pre-existing preserve-caught-error lint in evaluateExpression's catch (adds { cause }). Verified: core evaluator + react hooks + SchemaRenderer + action-renderer suites green (585 core/react, incl. new #2661 tests); core & react type-check clean; eslint 0 errors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SuiM565BZ3TR1VD3prMguB --- .changeset/cel-renderer-predicates.md | 40 ++++++++++++ .../core/src/evaluator/ExpressionEvaluator.ts | 49 +++++++++++++- .../__tests__/ExpressionEvaluator.test.ts | 64 ++++++++++++++++++- .../src/hooks/__tests__/useExpression.test.ts | 43 ++++++++++++- packages/react/src/hooks/useExpression.ts | 12 +++- 5 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 .changeset/cel-renderer-predicates.md diff --git a/.changeset/cel-renderer-predicates.md b/.changeset/cel-renderer-predicates.md new file mode 100644 index 000000000..e6820b068 --- /dev/null +++ b/.changeset/cel-renderer-predicates.md @@ -0,0 +1,40 @@ +--- +"@object-ui/core": minor +"@object-ui/react": minor +--- + +feat(evaluator): route `{ dialect: 'cel' }` component/action predicates to the canonical CEL engine (#2661) + +Component and action `visible` / `disabled` / `hidden` predicates were evaluated +by the home-grown JS `ExpressionEvaluator`, while field rules +(`visibleWhen`/`readonlyWhen`/`requiredWhen`, via `fieldRules.ts`) and row/list +conditionals (via `evalRowPredicate`) already delegate to the canonical +`@objectstack/formula` engine. That split meant a `{ dialect: 'cel' }` predicate +in a renderer/action surface was executed as **JavaScript** — CEL-only forms +(`x in list`, `has()`, typed `==`, the `today()`/`daysFromNow()` catalog) behaved +differently from, or faulted against, the server's enforcement. + +This converges the remaining tier onto the same engine: + +- **`@object-ui/core`** — `ExpressionEvaluator.evaluateCondition` now detects a + `{ dialect: 'cel', source }` envelope and evaluates it on `@objectstack/formula` + (via `evalFieldPredicate`), binding the `record` namespace plus the whole + context bag as top-level scope (`record.*`, `features.*`, `user.*`, `app.*`). + Fail-soft to visible/enabled to match the legacy default; `throwOnError` + callers still fail closed on a *faulting* predicate (a genuine `false` never + throws). This fixes every `SchemaRenderer` visibility/disabled read at once. +- **`@object-ui/react`** — `toPredicateInput` preserves a CEL envelope instead of + collapsing it to a `${source}` string, and `useCondition` accepts and forwards + the envelope (keyed on a stable `(dialect, source)` so it doesn't re-evaluate + each render). Action buttons (`action-icon`/`group`/`bar`/`button`) therefore + evaluate CEL `visible`/`enabled`/`disabled` on the canonical engine. + +**Back-compat:** bare strings and `${…}` templates stay on the legacy JS path +(deprecation window); only an explicit `{ dialect: 'cel' }` envelope is rerouted. +`{ dialect: 'template' }` is unaffected. + +Together with the `^15.1.1` alignment (#2662), a renderer CEL predicate now +reaches the identical verdict as the server — including the framework's +`dateField == today()` equality fix (objectstack-ai/framework#3205) once it +lands in a published 15.x. The broader home-grown-vs-canonical divergence +motivation is #2661. diff --git a/packages/core/src/evaluator/ExpressionEvaluator.ts b/packages/core/src/evaluator/ExpressionEvaluator.ts index c64565be8..dd9a14962 100644 --- a/packages/core/src/evaluator/ExpressionEvaluator.ts +++ b/packages/core/src/evaluator/ExpressionEvaluator.ts @@ -19,6 +19,7 @@ import { ExpressionContext } from './ExpressionContext.js'; import { ExpressionCache } from './ExpressionCache.js'; import { FormulaFunctions } from './FormulaFunctions.js'; +import { evalFieldPredicate } from './fieldRules.js'; /** * Options for expression evaluation @@ -168,7 +169,7 @@ export class ExpressionEvaluator { // Execute with context values return compiled.fn(...varValues); } catch (error) { - throw new Error(`Failed to evaluate expression "${expression}": ${(error as Error).message}`); + throw new Error(`Failed to evaluate expression "${expression}": ${(error as Error).message}`, { cause: error }); } } @@ -208,6 +209,21 @@ export class ExpressionEvaluator { return condition; } + // #2661 — a CEL-dialect envelope routes to the canonical `@objectstack/formula` + // engine (the one `fieldRules` / list conditionals already use), NOT the legacy + // JS evaluator below. This makes a component / action `visible` / `disabled` + // predicate reach the SAME verdict as server enforcement — including CEL-only + // behavior like `record.due_date == today()` (framework#3205). Bare strings and + // `${…}` templates stay on the legacy path (back-compat deprecation window); + // only an explicit `{ dialect: 'cel' }` envelope is rerouted. + if ( + condition && typeof condition === 'object' + && (condition as { dialect?: string }).dialect === 'cel' + && typeof (condition as { source?: string }).source === 'string' + ) { + return this.evaluateCelCondition((condition as { source: string }).source, options); + } + // Unwrap Expression envelope (see `evaluate` for rationale). if (condition && typeof condition === 'object' && typeof (condition as any).source === 'string') { condition = (condition as any).source as string; @@ -250,6 +266,37 @@ export class ExpressionEvaluator { } } + /** + * Evaluate a `{ dialect: 'cel' }` predicate on the canonical `@objectstack/formula` + * engine (via `evalFieldPredicate`), binding this evaluator's context: the + * `record` key as the `record` namespace and the whole context bag as top-level + * scope so `record.*`, `features.*`, `user.*`, `app.*` all resolve. Fail-soft to + * `true` (visible/enabled — the legacy default) unless the caller opted into + * `throwOnError`, in which case a *faulting* predicate (bad field / non-CEL + * syntax) throws; a genuine `false` never throws. + */ + private evaluateCelCondition(source: string, options: EvaluationOptions): boolean { + if (!source.trim()) return true; // no predicate → visible/enabled + const bag = this.context.toObject(); + const rec = bag.record; + const record = (rec && typeof rec === 'object' && !Array.isArray(rec)) + ? (rec as Record) + : (bag as Record); + if (!options.throwOnError) { + // Fast path: one evaluation, fail-soft to visible/enabled (legacy parity). + return evalFieldPredicate(source, record, true, undefined, bag); + } + // Fail-closed callers need to tell a genuine `false` from a fault. The + // canonical helper fails soft to the fallback, so a value that tracks the + // fallback in BOTH runs means the predicate faulted — then we throw. + const asTrue = evalFieldPredicate(source, record, true, undefined, bag); + const asFalse = evalFieldPredicate(source, record, false, undefined, bag); + if (asTrue !== asFalse) { + throw new Error(`CEL predicate failed to evaluate: ${source}`); + } + return asTrue; + } + /** * Update the context with new data */ diff --git a/packages/core/src/evaluator/__tests__/ExpressionEvaluator.test.ts b/packages/core/src/evaluator/__tests__/ExpressionEvaluator.test.ts index 255db5fb6..92f176670 100644 --- a/packages/core/src/evaluator/__tests__/ExpressionEvaluator.test.ts +++ b/packages/core/src/evaluator/__tests__/ExpressionEvaluator.test.ts @@ -94,11 +94,73 @@ describe('ExpressionEvaluator', () => { it('should handle boolean values directly', () => { const evaluator = new ExpressionEvaluator({}); - + expect(evaluator.evaluateCondition(true)).toBe(true); expect(evaluator.evaluateCondition(false)).toBe(false); }); }); + + // #2661 — a `{ dialect: 'cel' }` predicate must route to the canonical + // @objectstack/formula engine (like fieldRules / list conditionals), NOT the + // legacy JS evaluator, so component/action `visible`/`disabled` verdicts match + // the server. Bare strings and `${…}` templates stay on the legacy path. + describe('evaluateCondition — CEL dialect routing (#2661)', () => { + const cel = (source: string) => ({ dialect: 'cel' as const, source }); + + it('evaluates a CEL envelope on the canonical engine (CEL `in`, not JS `in`)', () => { + // CEL: `x in list` is list membership → true. Legacy JS `in` checks object + // keys/array indices → false. The divergence proves the canonical route. + const evaluator = new ExpressionEvaluator({ record: { roles: ['admin'] } }); + expect(evaluator.evaluateCondition(cel("'admin' in record.roles"))).toBe(true); + expect(evaluator.evaluateCondition(cel("'editor' in record.roles"))).toBe(false); + }); + + it('binds record.* and the host scope (features.*) for a CEL envelope', () => { + const evaluator = new ExpressionEvaluator({ + record: { status: 'open' }, + features: { beta: true }, + }); + expect(evaluator.evaluateCondition(cel('record.status == "open" && features.beta'))).toBe(true); + expect(evaluator.evaluateCondition(cel('record.status == "closed"'))).toBe(false); + }); + + it('resolves an ISO date field against today() with an ordering comparison', () => { + // today() is a canonical stdlib fn absent from the legacy evaluator; the + // string date field hydrates on the CEL ordering path (framework #1530). + const iso = new Date().toISOString().slice(0, 10); + const evaluator = new ExpressionEvaluator({ record: { due: iso } }); + expect(evaluator.evaluateCondition(cel('record.due <= today()'))).toBe(true); + expect(evaluator.evaluateCondition(cel('record.due >= today()'))).toBe(true); + }); + + it('fails soft to visible/enabled on a faulting CEL predicate (legacy parity)', () => { + const evaluator = new ExpressionEvaluator({ record: {} }); + // `record.nope.deep` faults; default (no throwOnError) → true. + expect(evaluator.evaluateCondition(cel('record.nope.deep == 1'))).toBe(true); + }); + + it('throws on a faulting CEL predicate when throwOnError is set (fail-closed)', () => { + const evaluator = new ExpressionEvaluator({ record: { x: 2 } }); + // Missing-key deep access faults in CEL → throws under throwOnError. + expect(() => evaluator.evaluateCondition(cel('record.a.b.c == 1'), { throwOnError: true })).toThrow(); + // A genuine `false` (field present, predicate simply false) must NOT throw. + expect(evaluator.evaluateCondition(cel('record.x == 1'), { throwOnError: true })).toBe(false); + }); + + it('an empty CEL source is "no predicate" → visible/enabled', () => { + const evaluator = new ExpressionEvaluator({ record: { x: 1 } }); + expect(evaluator.evaluateCondition(cel(' '))).toBe(true); + }); + + it('leaves bare strings and `${…}` templates on the legacy JS path (back-compat)', () => { + const evaluator = new ExpressionEvaluator({ data: { age: 25 }, record: { roles: ['admin'] } }); + // `${…}` template — legacy JS. + expect(evaluator.evaluateCondition('${data.age >= 18}')).toBe(true); + // A `{ dialect: 'template' }` envelope is NOT rerouted — unwrapped to source + // and run on the legacy path (here a bare non-`${}` string → its own value). + expect(evaluator.evaluateCondition({ dialect: 'template', source: '${data.age < 18}' })).toBe(false); + }); + }); }); describe('evaluatePlainCondition', () => { diff --git a/packages/react/src/hooks/__tests__/useExpression.test.ts b/packages/react/src/hooks/__tests__/useExpression.test.ts index cc02194e7..6a8044e08 100644 --- a/packages/react/src/hooks/__tests__/useExpression.test.ts +++ b/packages/react/src/hooks/__tests__/useExpression.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, vi } from 'vitest'; import { renderHook } from '@testing-library/react'; import { createElement } from 'react'; -import { useExpression, useCondition, useRowPredicate, PredicateScopeProvider } from '../useExpression'; +import { useExpression, useCondition, useRowPredicate, toPredicateInput, PredicateScopeProvider } from '../useExpression'; describe('useExpression', () => { it('returns string value directly for non-expression strings', () => { @@ -177,3 +177,44 @@ describe('useRowPredicate (canonical CEL row predicate — issue #1584)', () => warn.mockRestore(); }); }); + +// #2661 — a CEL-dialect action/component predicate must reach the canonical +// engine through `toPredicateInput` → `useCondition`, not collapse to a legacy +// `${…}` string. +describe('toPredicateInput — CEL envelope preservation (#2661)', () => { + it('preserves a { dialect: "cel" } envelope (does not wrap as ${…})', () => { + expect(toPredicateInput({ dialect: 'cel', source: 'record.x == 1' })) + .toEqual({ dialect: 'cel', source: 'record.x == 1' }); + }); + + it('still wraps bare strings and non-cel envelopes as legacy ${…}', () => { + expect(toPredicateInput('data.age >= 18')).toBe('${data.age >= 18}'); + expect(toPredicateInput({ dialect: 'template', source: 'data.age >= 18' })).toBe('${data.age >= 18}'); + }); + + it('passes booleans / empties through', () => { + expect(toPredicateInput(true)).toBe(true); + expect(toPredicateInput('')).toBeUndefined(); + expect(toPredicateInput({ dialect: 'cel', source: '' })).toBeUndefined(); + }); +}); + +describe('useCondition — CEL envelope routes to the canonical engine (#2661)', () => { + it('evaluates a cel envelope from toPredicateInput on the CEL engine (CEL `in`)', () => { + const { result } = renderHook(() => + useCondition(toPredicateInput({ dialect: 'cel', source: "'admin' in record.roles" }), { + record: { roles: ['admin'] }, + }), + ); + expect(result.current).toBe(true); + }); + + it('a cel envelope predicate that is false hides/disables (not defaulted true)', () => { + const { result } = renderHook(() => + useCondition(toPredicateInput({ dialect: 'cel', source: 'record.status == "open"' }), { + record: { status: 'closed' }, + }), + ); + expect(result.current).toBe(false); + }); +}); diff --git a/packages/react/src/hooks/useExpression.ts b/packages/react/src/hooks/useExpression.ts index 2394bfa2b..35c09f308 100644 --- a/packages/react/src/hooks/useExpression.ts +++ b/packages/react/src/hooks/useExpression.ts @@ -67,13 +67,19 @@ export function usePredicateScope(): Record { */ export function toPredicateInput( value: unknown, -): string | boolean | undefined { +): string | boolean | { dialect: 'cel'; source: string } | undefined { if (value === null || value === undefined || value === '') return undefined; if (typeof value === 'boolean') return value; if (typeof value === 'string') return `\${${value}}`; if (typeof value === 'object' && typeof (value as any).source === 'string') { const src = (value as any).source as string; - return src ? `\${${src}}` : undefined; + if (!src) return undefined; + // #2661 — preserve a CEL-dialect envelope so `useCondition` routes it to the + // canonical `@objectstack/formula` engine (identical verdict to the server), + // instead of collapsing it to a `${source}` string on the legacy JS path. + // Every other dialect (template / unset) keeps the legacy `${…}` behavior. + if ((value as any).dialect === 'cel') return { dialect: 'cel', source: src }; + return `\${${src}}`; } return undefined; } @@ -117,7 +123,7 @@ export function useExpression( const _warnedConditions = new Set(); export function useCondition( - condition: string | boolean | undefined, + condition: string | boolean | undefined | { dialect?: string; source?: string }, context: Record = {}, options?: { throwOnError?: boolean; label?: string } ): boolean { From 31d8f5c85ab39199792a5aeb7737962d65d6b573 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 14:11:26 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(core):=20revert=20Error=20cause=20arg?= =?UTF-8?q?=20=E2=80=94=20package=20TS=20lib=20is=20<=20ES2022=20(unbreaks?= =?UTF-8?q?=20build)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2-arg `new Error(msg, { cause })` form needs ES2022 lib; this package targets lower, so tsc failed TS2554 in the Bundle Analysis build. It was a gratuitous fix for a non-CI-gated lint rule on pre-existing code — reverted to the original single-arg throw. The #2661 CEL routing is unchanged. --- packages/core/src/evaluator/ExpressionEvaluator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/evaluator/ExpressionEvaluator.ts b/packages/core/src/evaluator/ExpressionEvaluator.ts index dd9a14962..98eacc41e 100644 --- a/packages/core/src/evaluator/ExpressionEvaluator.ts +++ b/packages/core/src/evaluator/ExpressionEvaluator.ts @@ -169,7 +169,7 @@ export class ExpressionEvaluator { // Execute with context values return compiled.fn(...varValues); } catch (error) { - throw new Error(`Failed to evaluate expression "${expression}": ${(error as Error).message}`, { cause: error }); + throw new Error(`Failed to evaluate expression "${expression}": ${(error as Error).message}`); } }