@objectstack/formula 9.6.0 → 9.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cel-engine.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  import { Environment } from '@marcbachmann/cel-js';
17
17
  import type { Expression } from '@objectstack/spec';
18
18
 
19
- import { buildScope, registerStdLib } from './stdlib';
19
+ import { buildScope, registerNumericCoercions, registerStdLib } from './stdlib';
20
20
  import type { DialectEngine, EvalContext, EvalResult } from './types';
21
21
 
22
22
  /**
@@ -38,7 +38,91 @@ function buildEnv(now: () => Date): Environment {
38
38
  enableOptionalTypes: true,
39
39
  limits: DEFAULT_LIMITS,
40
40
  });
41
- return registerStdLib(env, now);
41
+ return registerNumericCoercions(registerStdLib(env, now));
42
+ }
43
+
44
+ /**
45
+ * Namespace roots that a `record`-scoped CEL site may legitimately reference.
46
+ * Declared as `map` (dyn values) so member access (`record.foo`) and any
47
+ * arithmetic/comparison on it defers to runtime — the strict env faults ONLY on
48
+ * an *undeclared* top-level identifier, i.e. a bare field reference. Generous on
49
+ * purpose: an unknown root is a missed catch, a missing root is a false positive
50
+ * that would break the build, so we err toward declaring more.
51
+ */
52
+ const SCOPE_ROOTS = [
53
+ 'record', 'previous', 'input', 'output', 'os', 'vars', 'variables',
54
+ 'automation', 'context', 'args', 'item', 'env', 'user', 'step', 'result',
55
+ 'trigger', 'event', 'payload', 'data', 'params', 'config', 'settings',
56
+ ] as const;
57
+
58
+ /**
59
+ * A `record`-scoped environment (`unlistedVariablesAreDyn: false`) for detecting
60
+ * bare field references. It reuses the real stdlib so function calls don't fault;
61
+ * only undeclared *variables* do. Built once — `parse`/`check` do not mutate it.
62
+ */
63
+ function buildScopedEnv(knownFields: readonly string[]): Environment {
64
+ const env = new Environment({
65
+ unlistedVariablesAreDyn: false,
66
+ enableOptionalTypes: true,
67
+ limits: DEFAULT_LIMITS,
68
+ });
69
+ registerStdLib(env, () => new Date(0));
70
+ for (const root of SCOPE_ROOTS) {
71
+ try { env.registerVariable(root, 'map'); } catch { /* duplicate — ignore */ }
72
+ }
73
+ // `knownFields` are declared as `dyn` so they (and member/arith/compare on
74
+ // them) never fault — only a genuinely-undeclared top-level identifier does.
75
+ // Empty for a record-scope site (any bare field is a bug); the trigger
76
+ // object's fields for a flattened flow condition (only a NON-field bare ref —
77
+ // a typo or flow variable — is then interesting).
78
+ for (const field of knownFields) {
79
+ try { env.registerVariable(field, 'dyn'); } catch { /* duplicate / reserved — ignore */ }
80
+ }
81
+ return env;
82
+ }
83
+
84
+ // Roots-only env reused for the common record-scope check (no per-call rebuild).
85
+ let recordScopeEnv: Environment | undefined;
86
+
87
+ /**
88
+ * In a `record`-scoped CEL site — a `Field.formula` or an object validation
89
+ * predicate — the evaluation scope binds only the `record`/`previous`/… *namespaces*
90
+ * (no field flattening). A bare top-level identifier like `amount` or `status`
91
+ * therefore resolves to nothing and the expression silently evaluates to `null`
92
+ * / never fires (#1928, the class behind #1927's broken formulas). Returns the
93
+ * first such bare reference, or `null`.
94
+ *
95
+ * Acts ONLY on cel-js's `Unknown variable: X` fault, so it cannot false-positive
96
+ * on arithmetic/comparison overloads — and it must NOT be applied to flow /
97
+ * automation conditions, where the record's fields ARE flattened to top-level
98
+ * and bare references are correct.
99
+ */
100
+ export function firstUndeclaredReference(
101
+ source: string,
102
+ knownFields: readonly string[] = [],
103
+ ): string | null {
104
+ if (typeof source !== 'string' || !source.trim()) return null;
105
+ try {
106
+ const env = knownFields.length === 0
107
+ ? (recordScopeEnv ??= buildScopedEnv([]))
108
+ : buildScopedEnv(knownFields);
109
+ const result = env.parse(source).check?.() as
110
+ | { valid: boolean; error?: { message?: string } }
111
+ | undefined;
112
+ if (result && result.valid === false) {
113
+ const m = /Unknown variable:\s*([A-Za-z_$][\w$]*)/.exec(result.error?.message ?? '');
114
+ if (m) return m[1];
115
+ }
116
+ } catch {
117
+ // Parse/other faults are the syntax checker's job (celEngine.compile); this
118
+ // helper only reports the undeclared-variable case.
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /** @deprecated use {@link firstUndeclaredReference} with no fields. */
124
+ export function detectBareReference(source: string): string | null {
125
+ return firstUndeclaredReference(source);
42
126
  }
43
127
 
44
128
  /** Coerce cel-js's BigInt-flavored return into spec-friendly JS values. */
package/src/stdlib.ts CHANGED
@@ -22,6 +22,16 @@ function startOfDayUtc(d: Date): Date {
22
22
  return out;
23
23
  }
24
24
 
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
+
25
35
  /** Add `n` days to a Date in UTC; returns a new Date. */
26
36
  function addDaysUtc(d: Date, n: number): Date {
27
37
  const out = new Date(d.getTime());
@@ -101,9 +111,90 @@ export function registerStdLib(
101
111
  }
102
112
  return parts.join(separator);
103
113
  },
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,
104
155
  );
105
156
  }
106
157
 
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
+
166
+ /**
167
+ * Register mixed `double <op> int` / `int <op> double` arithmetic overloads.
168
+ *
169
+ * cel-js types a record field number as `double` and a bare integer literal as
170
+ * `int`, and ships overloads only for matching pairs (`double op double`,
171
+ * `int op int`). So a formula as ordinary as `record.amount / 100` or
172
+ * `record.price * 2` faults at runtime (`no such overload: dyn<double> / int`);
173
+ * the engine catches the fault and the formula silently evaluates to `null`
174
+ * (#1928). Authors then have to know the cel-js quirk and write `/ 100.0`.
175
+ *
176
+ * We close the gap by registering the missing mixed overloads. The result is
177
+ * always computed as a JS `double`, matching CEL's promotion rule for mixed
178
+ * numeric arithmetic. Pure `int op int` is untouched, so integer division
179
+ * (`7 / 2 == 3`) keeps its semantics — these overloads only fire when the two
180
+ * operands are genuinely a `double` and an `int`.
181
+ */
182
+ export function registerNumericCoercions(env: Environment): Environment {
183
+ const ops: Record<string, (a: number, b: number) => number> = {
184
+ '+': (a, b) => a + b,
185
+ '-': (a, b) => a - b,
186
+ '*': (a, b) => a * b,
187
+ '/': (a, b) => a / b,
188
+ '%': (a, b) => a % b,
189
+ };
190
+ for (const [op, fn] of Object.entries(ops)) {
191
+ const impl = (a: unknown, b: unknown) => fn(Number(a), Number(b));
192
+ env.registerOperator(`double ${op} int`, impl);
193
+ env.registerOperator(`int ${op} double`, impl);
194
+ }
195
+ return env;
196
+ }
197
+
107
198
  /**
108
199
  * Build the variable scope for a single evaluation. Absent fields are simply
109
200
  * not bound — CEL macros (`has(record.foo)`) handle missing-key safely.
@@ -80,6 +80,79 @@ describe('validateExpression (ADR-0032)', () => {
80
80
  });
81
81
  });
82
82
 
83
+ // #1928 — a bare top-level identifier is a silent bug in a `record`-scoped
84
+ // site (formula field / validation predicate) but correct in a `flattened`
85
+ // flow/automation condition. The validator must distinguish by `scope`.
86
+ describe('bare-reference detection by scope (#1928)', () => {
87
+ it('flags a bare field reference in a record-scoped predicate', () => {
88
+ const r = validateExpression('predicate', 'lead_score != null && lead_score > 100', { scope: 'record' });
89
+ expect(r.ok).toBe(false);
90
+ expect(r.errors[0].message).toMatch(/bare reference `lead_score`/);
91
+ expect(r.errors[0].message).toMatch(/record\.lead_score/);
92
+ });
93
+
94
+ it('flags a bare reference in a record-scoped value (formula) expression', () => {
95
+ const r = validateExpression('value', '(budget == null ? 0 : budget) - (spent == null ? 0 : spent)', { scope: 'record' });
96
+ expect(r.ok).toBe(false);
97
+ expect(r.errors[0].message).toMatch(/bare reference `(budget|spent)`/);
98
+ });
99
+
100
+ it('accepts the record-qualified form in a record-scoped site', () => {
101
+ const r = validateExpression('value', '(record.budget == null ? 0 : record.budget) - (record.spent == null ? 0 : record.spent)', { scope: 'record' });
102
+ expect(r.ok).toBe(true);
103
+ });
104
+
105
+ it('does NOT flag bare references in a flattened (flow) condition', () => {
106
+ // The record's fields are flattened to top-level for flow conditions, and
107
+ // flow variables share that namespace, so bare refs are correct here.
108
+ expect(validateExpression('predicate', 'status == "done" && previous.status != "done"', { scope: 'flattened' }).ok).toBe(true);
109
+ expect(validateExpression('predicate', 'budget > 100000', { scope: 'flattened' }).ok).toBe(true);
110
+ expect(validateExpression('predicate', 'expiring_deals.length > 0', { scope: 'flattened' }).ok).toBe(true);
111
+ });
112
+
113
+ it('defaults to flattened scope (no bare-ref flag) when scope is unset', () => {
114
+ expect(validateExpression('predicate', 'status == "done"').ok).toBe(true);
115
+ });
116
+
117
+ it('does not flag a null-guard on a record-qualified field (no type false-positive)', () => {
118
+ expect(validateExpression('predicate', 'record.lead_score != null && record.lead_score > 100', { scope: 'record' }).ok).toBe(true);
119
+ });
120
+ });
121
+
122
+ // #1928 tier 3 — flattened flow conditions reference fields bare, so a bare
123
+ // ref is not an error. A bare NON-field that is a near-miss of a known field
124
+ // is a likely typo → non-blocking warning (ok stays true).
125
+ describe('flow-condition typo warnings (#1928 tier 3)', () => {
126
+ const fields = ['stage', 'amount', 'status'] as const;
127
+
128
+ it('warns (does not error) on a likely field typo in a flattened condition', () => {
129
+ const r = validateExpression('predicate', 'stagee == "closed_won"', { objectName: 'crm_opportunity', fields, scope: 'flattened' });
130
+ expect(r.ok).toBe(true);
131
+ expect(r.errors).toHaveLength(0);
132
+ expect(r.warnings).toHaveLength(1);
133
+ expect(r.warnings[0].message).toMatch(/`stagee` is not a field/);
134
+ expect(r.warnings[0].message).toMatch(/did you mean `stage`/);
135
+ });
136
+
137
+ it('does not warn on a correct bare field reference', () => {
138
+ const r = validateExpression('predicate', 'stage == "closed_won" && previous.stage != "closed_won"', { objectName: 'crm_opportunity', fields, scope: 'flattened' });
139
+ expect(r.ok).toBe(true);
140
+ expect(r.warnings).toHaveLength(0);
141
+ });
142
+
143
+ it('does not warn on a flow variable that is far from any field name', () => {
144
+ const r = validateExpression('predicate', 'expiring_deals.length > 0', { objectName: 'crm_opportunity', fields, scope: 'flattened' });
145
+ expect(r.ok).toBe(true);
146
+ expect(r.warnings).toHaveLength(0);
147
+ });
148
+
149
+ it('emits no warnings without a field list (nothing to compare against)', () => {
150
+ const r = validateExpression('predicate', 'stagee == "x"', { scope: 'flattened' });
151
+ expect(r.ok).toBe(true);
152
+ expect(r.warnings).toHaveLength(0);
153
+ });
154
+ });
155
+
83
156
  describe('introspection', () => {
84
157
  it('reports the dialect + scope for a field role', () => {
85
158
  expect(expectedDialect('predicate')).toBe('cel');
package/src/validate.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * This validator detects that specific mistake and returns the exact fix.
18
18
  */
19
19
 
20
- import { celEngine } from './cel-engine';
20
+ import { celEngine, firstUndeclaredReference } from './cel-engine';
21
21
  import { templateEngine } from './template-engine';
22
22
 
23
23
  export type FieldRole = 'predicate' | 'value' | 'template';
@@ -36,6 +36,23 @@ export interface ExprSchemaHint {
36
36
  objectName?: string;
37
37
  /** Known top-level field names, so `record.<field>` can be checked. */
38
38
  fields?: readonly string[];
39
+ /**
40
+ * Evaluation scope of the authoring site — determines whether a bare top-level
41
+ * identifier is legal (#1928):
42
+ * - `'record'` → the record is bound only as the `record` namespace, with
43
+ * no field flattening (`Field.formula`, object validation
44
+ * predicates). A bare `amount` resolves to nothing and the
45
+ * expression silently evaluates to `null` / never fires, so
46
+ * it MUST be written `record.amount`. We flag bare refs.
47
+ * - `'flattened'` → the record's own fields are spread to top-level alongside
48
+ * flow variables (flow / automation conditions), so bare
49
+ * `status` is correct and is NOT an error. Flow variables
50
+ * are not schema-knowable, so a non-field bare identifier
51
+ * can't be soundly told apart from a typo — but when one is
52
+ * a near-miss of a known field we emit a non-blocking
53
+ * did-you-mean *warning*. (Default.)
54
+ */
55
+ scope?: 'record' | 'flattened';
39
56
  }
40
57
 
41
58
  export interface ExprValidationError {
@@ -48,6 +65,13 @@ export interface ExprValidationError {
48
65
  export interface ExprValidationResult {
49
66
  ok: boolean;
50
67
  errors: ExprValidationError[];
68
+ /**
69
+ * Non-blocking advisories (#1928 tier 3): a likely-typo'd field reference in a
70
+ * flattened flow condition. Never affects `ok` — callers surface these without
71
+ * failing the build, since a bare identifier there may legitimately be a flow
72
+ * variable.
73
+ */
74
+ warnings: ExprValidationError[];
51
75
  }
52
76
 
53
77
  /** A bare `{x}` that is NOT part of a `{{x}}` mustache hole. */
@@ -134,14 +158,15 @@ export function validateExpression(
134
158
  ): ExprValidationResult {
135
159
  const { dialect, source } = toSource(input);
136
160
  const errors: ExprValidationError[] = [];
137
- if (!source.trim()) return { ok: true, errors };
161
+ const warnings: ExprValidationError[] = [];
162
+ if (!source.trim()) return { ok: true, errors, warnings };
138
163
 
139
164
  if (role === 'template') {
140
165
  // Templates must be the `template` dialect (or untyped string). Reject a
141
166
  // CEL envelope mistakenly placed in a text field.
142
167
  if (dialect && dialect !== 'template') {
143
168
  errors.push({ source, message: `expected a text template but got a \`${dialect}\` expression.` });
144
- return { ok: false, errors };
169
+ return { ok: false, errors, warnings };
145
170
  }
146
171
  const compiled = templateEngine.compile(source);
147
172
  if (!compiled.ok) {
@@ -150,13 +175,13 @@ export function validateExpression(
150
175
  // A single `{x}` in a template is the legacy/deprecated form (ADR-0032 §3).
151
176
  const hint = SINGLE_BRACE_RE.test(source) ? bracesHintForTemplate(source) : null;
152
177
  if (hint) errors.push({ source, message: hint });
153
- return { ok: errors.length === 0, errors };
178
+ return { ok: errors.length === 0, errors, warnings };
154
179
  }
155
180
 
156
181
  // predicate | value → CEL
157
182
  if (dialect && dialect !== 'cel') {
158
183
  errors.push({ source, message: `expected a CEL expression but got a \`${dialect}\` dialect.` });
159
- return { ok: false, errors };
184
+ return { ok: false, errors, warnings };
160
185
  }
161
186
  const compiled = celEngine.compile(source);
162
187
  if (!compiled.ok) {
@@ -169,8 +194,41 @@ export function validateExpression(
169
194
  });
170
195
  } else {
171
196
  checkFieldExistence(source, schema, errors);
197
+ if (schema?.scope === 'record') {
198
+ // In a `record`-scoped site a bare top-level identifier is a silent bug —
199
+ // it must be `record.<field>` (#1928). Hard error.
200
+ const bare = firstUndeclaredReference(source);
201
+ if (bare) {
202
+ errors.push({
203
+ source,
204
+ message:
205
+ `bare reference \`${bare}\` — a formula/validation expression binds the record as the ` +
206
+ `\`record\` namespace, not at top level, so \`${bare}\` resolves to nothing and the ` +
207
+ `expression silently evaluates to null. Write \`record.${bare}\`.`,
208
+ });
209
+ }
210
+ } else if (schema?.fields && schema.fields.length > 0) {
211
+ // Flattened flow/automation condition: the record's fields ARE bound at
212
+ // top-level, so a bare ref is normally correct. But a *non-field* bare
213
+ // identifier is either a flow variable or a typo. When it is a near-miss
214
+ // of a known field, warn (did-you-mean) WITHOUT failing the build —
215
+ // a genuine flow variable won't be edit-distance-close to a field. (#1928)
216
+ const unknown = firstUndeclaredReference(source, schema.fields);
217
+ if (unknown) {
218
+ const suggestion = nearest(unknown, schema.fields);
219
+ if (suggestion) {
220
+ warnings.push({
221
+ source,
222
+ message:
223
+ `\`${unknown}\` is not a field of \`${schema.objectName ?? 'the trigger object'}\` — ` +
224
+ `did you mean \`${suggestion}\`? (flow conditions reference fields bare, e.g. \`${suggestion} == …\`). ` +
225
+ `If \`${unknown}\` is a flow variable this is safe to ignore.`,
226
+ });
227
+ }
228
+ }
229
+ }
172
230
  }
173
- return { ok: errors.length === 0, errors };
231
+ return { ok: errors.length === 0, errors, warnings };
174
232
  }
175
233
 
176
234
  function bracesHintForTemplate(source: string): string {
@@ -198,10 +256,21 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
198
256
  };
199
257
  }
200
258
 
201
- /** 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
+ */
202
265
  export const CEL_STDLIB_FUNCTIONS: string[] = [
203
- 'now', 'today', 'daysFromNow', 'daysBetween', 'date', 'datetime', 'timestamp',
204
- 'isBlank', 'isEmpty', 'coalesce', 'len', 'size', 'int', 'float', 'string', 'bool',
205
- 'upper', 'lower', 'trim', 'contains', 'startsWith', 'endsWith', 'matches',
206
- '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',
207
276
  ];