From 453bd210e769ba725d479cfbeefeac13a2d78309 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Mon, 15 Jun 2026 15:06:37 +0800 Subject: [PATCH 1/2] fix(formula): catch unknown functions in CEL conditions at build (#1877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cel-js's `check()` returns a `TypeCheckResult` object (`{ valid, error }`), not an array, so `compile()`'s `Array.isArray(checkErrors)` guard never matched and the type-check verdict was silently discarded. A condition calling an unknown function (`PRIOR(status)`, a typo'd `isBlnk(...)`) type-checks as `found no matching overload`, but that result never surfaced — so `objectstack compile`, `registerFlow`, and the `validate_expression` tool all accepted the predicate, which then silently no-op'd the flow at runtime. Read the documented `{ valid, error }` shape instead. Because every author surface routes through this one `compile()`, the fix closes the gap for flow conditions, validation rules, and field formulas at once. Verified cel-js's `check()` cleanly separates valid predicates from unknown-function ones with no false positives on existing conditions. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/formula/src/cel-engine.test.ts | 16 ++++++++++++++++ packages/formula/src/cel-engine.ts | 15 +++++++++++---- packages/formula/src/validate.test.ts | 21 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/formula/src/cel-engine.test.ts b/packages/formula/src/cel-engine.test.ts index 6a3776aeda..d1f1b52479 100644 --- a/packages/formula/src/cel-engine.test.ts +++ b/packages/formula/src/cel-engine.test.ts @@ -70,6 +70,22 @@ describe('celEngine', () => { expect(r.ok).toBe(true); }); + // #1877 — cel-js `check()` returns a `{ valid, error }` object, not an array. + // compile() must read that shape so an UNKNOWN function (here `PRIOR`) is + // reported as a type fault at build time instead of slipping through. + it('compile() rejects an unknown function as a type error (#1877)', () => { + const r = celEngine.compile('PRIOR(status) != "promoted"'); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error.kind).toBe('type'); + expect(r.error.message).toMatch(/overload|PRIOR/); + } + }); + + it('compile() still accepts a registered stdlib function (#1877)', () => { + expect(celEngine.compile('!isBlank(record.target_channels)').ok).toBe(true); + }); + it('handles timestamp + duration arithmetic', () => { const pinned = new Date('2026-01-01T00:00:00Z'); const r = celEngine.evaluate(cel('now() + duration("720h")'), { now: pinned }); diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index e52981b1d3..4e852aa5e8 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -146,12 +146,19 @@ export const celEngine: DialectEngine = { // type-checking; the function is never actually called. const env = buildEnv(() => new Date(0)); const compiled = env.parse(source); - // Surface check errors eagerly. - const checkErrors = compiled.check?.(); - if (checkErrors && Array.isArray(checkErrors) && checkErrors.length > 0) { + // Surface check errors eagerly. cel-js's `check()` returns a + // `TypeCheckResult` object (`{ valid, type?, error? }`) — NOT an array — + // so the type fault (including `found no matching overload for 'PRIOR(dyn)'` + // when a condition calls an UNKNOWN function) only surfaces when we read + // `valid === false`. The previous `Array.isArray(...)` guard never matched + // an object, so unknown-function predicates type-checked clean and were + // silently accepted by `objectstack build` / `registerFlow`, then no-op'd + // the flow at runtime (#1877). Reading the documented shape closes that. + const checkResult = compiled.check?.(); + if (checkResult && checkResult.valid === false) { return { ok: false, - error: { kind: 'type', message: checkErrors.join('; ') }, + error: { kind: 'type', message: checkResult.error?.message ?? 'expression failed type checking' }, }; } return { ok: true, value: compiled.ast }; diff --git a/packages/formula/src/validate.test.ts b/packages/formula/src/validate.test.ts index 36473dc5df..1f8f1567b3 100644 --- a/packages/formula/src/validate.test.ts +++ b/packages/formula/src/validate.test.ts @@ -26,6 +26,27 @@ describe('validateExpression (ADR-0032)', () => { expect(validateExpression('predicate', '').ok).toBe(true); expect(validateExpression('predicate', null).ok).toBe(true); }); + + // #1877 — a predicate calling an UNKNOWN function (e.g. `PRIOR()`, a typo'd + // `isBlnk()`) must be rejected at build/registration, not silently accepted + // and then no-op the flow at runtime. cel-js's type checker reports these as + // `found no matching overload`; the engine surfaces them as an invalid CEL + // predicate. + it('rejects an unknown function call (#1877)', () => { + const r = validateExpression('predicate', 'PRIOR(status) != "promoted"'); + expect(r.ok).toBe(false); + expect(r.errors[0].message).toMatch(/invalid CEL predicate/i); + expect(r.errors[0].message).toMatch(/overload|PRIOR/); + }); + + it('rejects an unknown function even when guarded by a short-circuit (#1877)', () => { + const r = validateExpression('predicate', 'status == "promoted" && PRIOR(status) != "promoted"'); + expect(r.ok).toBe(false); + }); + + it('still accepts a registered stdlib function (isBlank)', () => { + expect(validateExpression('predicate', '!isBlank(record.target_channels)').ok).toBe(true); + }); }); describe('templates', () => { From a913499d83dc26eda8ccc749df3e4c931d43d670 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Mon, 15 Jun 2026 15:09:16 +0800 Subject: [PATCH 2/2] chore(changeset): formula unknown-function condition validation --- .changeset/formula-condition-unknown-function.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/formula-condition-unknown-function.md diff --git a/.changeset/formula-condition-unknown-function.md b/.changeset/formula-condition-unknown-function.md new file mode 100644 index 0000000000..dfa3b34af4 --- /dev/null +++ b/.changeset/formula-condition-unknown-function.md @@ -0,0 +1,7 @@ +--- +"@objectstack/formula": patch +--- + +fix(formula): catch unknown functions in CEL conditions at build (#1877) + +`compile()` discarded cel-js's type-check verdict because `check()` returns a `TypeCheckResult` object (`{ valid, error }`), not an array — so the `Array.isArray(checkErrors)` guard never matched. A condition calling an unknown function (`PRIOR(status)`, a typo'd `isBlnk(...)`) type-checks as `found no matching overload`, but that result never surfaced, so `objectstack compile`, `registerFlow`, and the `validate_expression` tool all accepted the predicate, which then silently no-op'd the flow at runtime. Now reads the documented `{ valid, error }` shape, closing the gap for flow conditions, validation rules, and field formulas at once.