Skip to content

Commit e6c8279

Browse files
os-zhuangclaude
andcommitted
feat(formula): align CEL stdlib catalog with runtime (#1928)
The authoring catalog (introspectScope / CEL_STDLIB_FUNCTIONS) advertised 25 functions but only 8 were registered; 14 faulted at runtime — authors (incl. AI) were told to call functions that don't exist (daysBetween, abs, round, min, max, upper, lower, len, isEmpty, contains, startsWith, endsWith, matches, date/datetime). Register the genuinely-useful set in registerStdLib (dyn-lenient sigs + internal coercion so a Field.date string arg still works) and reconcile the catalog so every advertised entry resolves. Adds a drift-guard test evaluating every CEL_STDLIB_FUNCTIONS entry. Pure additions — no change to existing expressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9ad1198 commit e6c8279

4 files changed

Lines changed: 166 additions & 5 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/formula": minor
3+
---
4+
5+
feat(formula): register the CEL functions the authoring catalog advertises (daysBetween, abs, round, min, max, upper, lower, contains, startsWith, endsWith, matches, len, isEmpty, date, datetime)
6+
7+
`introspectScope` / `CEL_STDLIB_FUNCTIONS` advertised 25 functions to authors
8+
(incl. AI), but only 8 were registered — 14 faulted at runtime (`daysBetween`,
9+
`abs`, `round`, `min`, `max`, `upper`, `lower`, `len`, `isEmpty`, `contains`,
10+
`startsWith`, `endsWith`, `matches`, plus `date`/`datetime`). Authors were told
11+
to call functions that don't exist (e.g. `daysBetween` for "days remaining").
12+
13+
Register the genuinely-useful set in `registerStdLib` with dyn-lenient signatures
14+
(so a `Field.date` arriving as a string still works) and internal coercion, and
15+
reconcile the catalog so every advertised entry resolves — guarded by a test that
16+
evaluates every `CEL_STDLIB_FUNCTIONS` entry. Pure additions; no behavior change
17+
to existing expressions.

packages/formula/src/cel-engine.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22

33
import { celEngine } from './cel-engine';
4+
import { CEL_STDLIB_FUNCTIONS } from './validate';
45
import type { Expression } from '@objectstack/spec';
56

67
const cel = (source: string): Expression => ({ dialect: 'cel', source });
@@ -296,4 +297,77 @@ describe('celEngine', () => {
296297
expect(r).toEqual({ ok: true, value: true });
297298
});
298299
});
300+
301+
// #1928 follow-up — the authoring catalog (`introspectScope`) advertised 25
302+
// functions but only 8 were registered; 14 faulted at runtime. These cover the
303+
// newly-registered stdlib, plus a drift-guard that every advertised function resolves.
304+
describe('stdlib catalog (registered functions)', () => {
305+
const now = new Date('2026-06-16T00:00:00Z');
306+
307+
it('daysBetween counts whole days (negative when reversed)', () => {
308+
expect(celEngine.evaluate(cel('daysBetween(today(), daysFromNow(7))'), { now }))
309+
.toEqual({ ok: true, value: 7 });
310+
expect(celEngine.evaluate(cel('daysBetween(today(), daysAgo(3))'), { now }))
311+
.toEqual({ ok: true, value: -3 });
312+
});
313+
314+
it('daysBetween coerces a date-string field arg (no manual hydration)', () => {
315+
const r = celEngine.evaluate(cel('daysBetween(today(), record.due)'), {
316+
now, record: { due: '2026-06-26' },
317+
});
318+
expect(r).toEqual({ ok: true, value: 10 });
319+
});
320+
321+
it('date / datetime parse ISO strings to a timestamp', () => {
322+
const r = celEngine.evaluate(cel('date("2026-03-15") < datetime("2026-03-16T08:00:00Z")'), {});
323+
expect(r).toEqual({ ok: true, value: true });
324+
});
325+
326+
it('abs / round / min / max', () => {
327+
expect(celEngine.evaluate(cel('abs(record.x)'), { record: { x: -3.5 } })).toEqual({ ok: true, value: 3.5 });
328+
expect(celEngine.evaluate(cel('round(2.6)'), {})).toEqual({ ok: true, value: 3 });
329+
expect(celEngine.evaluate(cel('min(record.a, record.b)'), { record: { a: 3, b: 7 } })).toEqual({ ok: true, value: 3 });
330+
expect(celEngine.evaluate(cel('max(record.a, record.b)'), { record: { a: 3, b: 7 } })).toEqual({ ok: true, value: 7 });
331+
});
332+
333+
it('string ops: upper / lower / contains / startsWith / endsWith / matches', () => {
334+
expect(celEngine.evaluate(cel('upper(record.s)'), { record: { s: 'hi' } })).toEqual({ ok: true, value: 'HI' });
335+
expect(celEngine.evaluate(cel('lower("HI")'), {})).toEqual({ ok: true, value: 'hi' });
336+
expect(celEngine.evaluate(cel('contains(record.s, "ell")'), { record: { s: 'hello' } })).toEqual({ ok: true, value: true });
337+
expect(celEngine.evaluate(cel('startsWith("hello", "he")'), {})).toEqual({ ok: true, value: true });
338+
expect(celEngine.evaluate(cel('endsWith("hello", "lo")'), {})).toEqual({ ok: true, value: true });
339+
expect(celEngine.evaluate(cel('matches("a1", "a.")'), {})).toEqual({ ok: true, value: true });
340+
});
341+
342+
it('len / isEmpty over strings and lists', () => {
343+
expect(celEngine.evaluate(cel('len(record.items)'), { record: { items: [1, 2, 3] } })).toEqual({ ok: true, value: 3 });
344+
expect(celEngine.evaluate(cel('isEmpty("")'), {})).toEqual({ ok: true, value: true });
345+
expect(celEngine.evaluate(cel('isEmpty(record.items)'), { record: { items: [] } })).toEqual({ ok: true, value: true });
346+
expect(celEngine.evaluate(cel('isEmpty(record.items)'), { record: { items: [1] } })).toEqual({ ok: true, value: false });
347+
});
348+
349+
// Drift guard: introspectScope promises these to authors; every one must resolve.
350+
it('every advertised CEL_STDLIB_FUNCTIONS entry resolves at runtime', () => {
351+
const call: Record<string, string> = {
352+
now: 'now()', today: 'today()', daysFromNow: 'daysFromNow(30)', daysAgo: 'daysAgo(7)',
353+
daysBetween: 'daysBetween(today(), daysFromNow(7))', date: 'date("2026-03-15")',
354+
datetime: 'datetime("2026-03-15T08:00:00Z")', abs: 'abs(-3.5)', round: 'round(2.6)',
355+
min: 'min(1, 2)', max: 'max(1, 2)', upper: 'upper("hi")', lower: 'lower("HI")',
356+
trim: 'trim(" x ")', contains: 'contains("hello", "ell")', startsWith: 'startsWith("hi", "h")',
357+
endsWith: 'endsWith("hi", "i")', matches: 'matches("a1", "a.")', joinNonEmpty: 'joinNonEmpty(["a", "b"], "-")',
358+
isBlank: 'isBlank("")', isEmpty: 'isEmpty([])', coalesce: 'coalesce(null, "x")', len: 'len("ab")',
359+
size: 'size([1, 2])', has: 'has(record.a)', int: 'int("3")', string: 'string(3)',
360+
bool: 'bool("true")', double: 'double("3.5")', timestamp: 'timestamp("2026-01-01T00:00:00Z")',
361+
duration: 'duration("3600s")',
362+
};
363+
const unresolved: string[] = [];
364+
for (const fn of CEL_STDLIB_FUNCTIONS) {
365+
const src = call[fn];
366+
expect(src, `no probe call defined for advertised function \`${fn}\``).toBeTruthy();
367+
const r = celEngine.evaluate(cel(src), { now, record: { a: 1 } });
368+
if (!r.ok) unresolved.push(`${fn}: ${r.error.message.split('\n')[0]}`);
369+
}
370+
expect(unresolved, `advertised functions that fault at runtime:\n${unresolved.join('\n')}`).toEqual([]);
371+
});
372+
});
299373
});

packages/formula/src/stdlib.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ function startOfDayUtc(d: Date): Date {
2222
return out;
2323
}
2424

25+
/** Coerce a CEL value (Date | ISO string | epoch number) to a Date. */
26+
function toDate(v: unknown): Date {
27+
if (v instanceof Date) return v;
28+
if (typeof v === 'number' || typeof v === 'bigint') return new Date(Number(v));
29+
return new Date(String(v));
30+
}
31+
32+
/** One UTC day in milliseconds. */
33+
const MS_PER_DAY = 86_400_000;
34+
2535
/** Add `n` days to a Date in UTC; returns a new Date. */
2636
function addDaysUtc(d: Date, n: number): Date {
2737
const out = new Date(d.getTime());
@@ -101,9 +111,58 @@ export function registerStdLib(
101111
}
102112
return parts.join(separator);
103113
},
114+
)
115+
// ── Dates ────────────────────────────────────────────────────────────
116+
// Whole days from `a` to `b` (negative if `b` is before `a`). The common
117+
// shape is `daysBetween(today(), record.due)` for "days remaining". Args are
118+
// coerced (Date | ISO string | epoch) so a `Field.date` that arrives as a
119+
// string still works without the caller hydrating it.
120+
.registerFunction(
121+
'daysBetween(dyn, dyn): int',
122+
(a: unknown, b: unknown) =>
123+
BigInt(Math.round((toDate(b).getTime() - toDate(a).getTime()) / MS_PER_DAY)),
124+
)
125+
// Parse an ISO date / date-time string to a Timestamp. `date` and `datetime`
126+
// are aliases — both accept either form (the field's own type decides the
127+
// intent); kept distinct because authors reach for whichever reads clearer.
128+
.registerFunction('date(dyn): google.protobuf.Timestamp', (s: unknown) => toDate(s))
129+
.registerFunction('datetime(dyn): google.protobuf.Timestamp', (s: unknown) => toDate(s))
130+
// ── Numbers ──────────────────────────────────────────────────────────
131+
.registerFunction('abs(dyn): double', (x: unknown) => Math.abs(Number(x)))
132+
.registerFunction('round(dyn): int', (x: unknown) => BigInt(Math.round(Number(x))))
133+
// min/max return the smaller/larger operand verbatim (type preserved) rather
134+
// than a coerced copy, so `min(record.a, record.b)` keeps int-ness when both
135+
// are ints. Comparison is numeric.
136+
.registerFunction('min(dyn, dyn): dyn', (a: unknown, b: unknown) => (Number(a) <= Number(b) ? a : b))
137+
.registerFunction('max(dyn, dyn): dyn', (a: unknown, b: unknown) => (Number(a) >= Number(b) ? a : b))
138+
// ── Strings ──────────────────────────────────────────────────────────
139+
// Free-function forms of the common string ops. CEL also exposes some as
140+
// receiver methods (`s.contains(x)`), but the authoring catalog advertises
141+
// the bare-call form, so register it to match what authors are told to use.
142+
.registerFunction('upper(dyn): string', (s: unknown) => String(s ?? '').toUpperCase())
143+
.registerFunction('lower(dyn): string', (s: unknown) => String(s ?? '').toLowerCase())
144+
.registerFunction('contains(dyn, dyn): bool', (s: unknown, sub: unknown) => String(s ?? '').includes(String(sub ?? '')))
145+
.registerFunction('startsWith(dyn, dyn): bool', (s: unknown, p: unknown) => String(s ?? '').startsWith(String(p ?? '')))
146+
.registerFunction('endsWith(dyn, dyn): bool', (s: unknown, p: unknown) => String(s ?? '').endsWith(String(p ?? '')))
147+
.registerFunction('matches(dyn, dyn): bool', (s: unknown, re: unknown) => new RegExp(String(re ?? '')).test(String(s ?? '')))
148+
// ── Collections ──────────────────────────────────────────────────────
149+
// `len` mirrors CEL's built-in `size()` for strings/lists/maps; `isEmpty` is
150+
// the inverse-of-non-empty companion to `isBlank` (true for null, '', []).
151+
.registerFunction('len(dyn): int', (v: unknown) => BigInt(lengthOf(v)))
152+
.registerFunction(
153+
'isEmpty(dyn): bool',
154+
(v: unknown) => v === null || v === undefined || lengthOf(v) === 0,
104155
);
105156
}
106157

158+
/** Length of a string / list / map (0 for scalars and null). */
159+
function lengthOf(v: unknown): number {
160+
if (v === null || v === undefined) return 0;
161+
if (typeof v === 'string' || Array.isArray(v)) return v.length;
162+
if (typeof v === 'object') return Object.keys(v as Record<string, unknown>).length;
163+
return 0;
164+
}
165+
107166
/**
108167
* Register mixed `double <op> int` / `int <op> double` arithmetic overloads.
109168
*

packages/formula/src/validate.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -256,10 +256,21 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
256256
};
257257
}
258258

259-
/** Public catalog of CEL stdlib functions available in expressions. */
259+
/**
260+
* Public catalog of CEL functions available in expressions — what `introspectScope`
261+
* advertises to authors (incl. AI). Every entry MUST actually resolve at runtime:
262+
* either registered in `registerStdLib` or a verified cel-js built-in. Drifting this
263+
* list ahead of the runtime tells the author to call functions that fault (#1928).
264+
*/
260265
export const CEL_STDLIB_FUNCTIONS: string[] = [
261-
'now', 'today', 'daysFromNow', 'daysBetween', 'date', 'datetime', 'timestamp',
262-
'isBlank', 'isEmpty', 'coalesce', 'len', 'size', 'int', 'float', 'string', 'bool',
263-
'upper', 'lower', 'trim', 'contains', 'startsWith', 'endsWith', 'matches',
264-
'has', 'min', 'max', 'abs', 'round',
266+
// Dates (registered stdlib)
267+
'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'date', 'datetime',
268+
// Numbers (registered stdlib)
269+
'abs', 'round', 'min', 'max',
270+
// Strings (registered stdlib)
271+
'upper', 'lower', 'trim', 'contains', 'startsWith', 'endsWith', 'matches', 'joinNonEmpty',
272+
// Collections / null-ish (registered stdlib)
273+
'isBlank', 'isEmpty', 'coalesce', 'len',
274+
// cel-js built-ins (verified to resolve)
275+
'size', 'has', 'int', 'string', 'bool', 'double', 'timestamp', 'duration',
265276
];

0 commit comments

Comments
 (0)