import { Environment } from '@marcbachmann/cel-js'; import { registerBaseFunctions } from './functions'; export interface CelFunctionParam { name: string; description?: string; } export interface CelFunctionRegistration { /** cel-js signature string, e.g. 'assetJson(string): dyn' */ signature: string; // never[] rest params make any typed handler assignable without casts // (parameter contravariance); unknown[] would reject e.g. (id: string) => x. handler: (...args: never[]) => unknown; description?: string; /** Parameter metadata surfaced by env.getDefinitions() (e.g. for UI palettes). */ params?: CelFunctionParam[]; } export interface EvaluateCelOptions { /** Extra functions registered for this evaluation only. */ functions?: CelFunctionRegistration[]; } // cel-js locks an Environment's registry the first time it is cloned: after // that, registerFunction on the base env throws "Cannot modify frozen // registry". The base env is built and populated exactly once (??= guard); // every createCelEnvironment() call clones the locked base, so base // registrations can never change after the first clone. let baseEnvironment: Environment | undefined; function getBaseEnvironment(): Environment { baseEnvironment ??= registerBaseFunctions(new Environment({ unlistedVariablesAreDyn: true })); return baseEnvironment; } /** * A fresh, extensible clone of the shared base CEL environment (trim, slice, * random, randomSample). Consumers may register additional functions on it. * Note: `exists()` is only available through `evaluateCel`, because it must * be bound to the evaluation's variables. */ export function createCelEnvironment(): Environment { return getBaseEnvironment().clone(); } /** * Canonical CEL evaluation entry point shared by the backend, frontend, * and MCP server. Registers `exists(name)` bound to `variables`, plus any * per-call extra functions, then evaluates. * * If a registered handler is async, cel-js returns a Promise — use * `await evaluateCel>(...)` in that case. * * `options.functions` must not redeclare a base function (trim, slice, * random, randomSample) or `exists` — cel-js throws on overlapping * signatures rather than overriding. */ export function evaluateCel( expression: string, variables: Record = {}, options: EvaluateCelOptions = {}, ): T { const env = createCelEnvironment(); env.registerFunction( 'exists(string): bool', (name: string) => name in variables && variables[name] != null, { description: 'True when the named variable was provided and is not null.', params: [{ name: 'name', description: 'Variable name to check' }], }, ); for (const fn of options.functions ?? []) { env.registerFunction(fn.signature, fn.handler as (...args: unknown[]) => unknown, { ...(fn.description !== undefined ? { description: fn.description } : {}), ...(fn.params !== undefined ? { params: fn.params } : {}), }); } return env.evaluate(expression, variables) as T; }