@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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +83 -0
- package/dist/index.d.mts +30 -1
- package/dist/index.d.ts +30 -1
- package/dist/index.js +141 -20
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +141 -20
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/cel-engine.test.ts +132 -0
- package/src/cel-engine.ts +86 -2
- package/src/stdlib.ts +91 -0
- package/src/validate.test.ts +73 -0
- package/src/validate.ts +80 -11
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.
|
package/src/validate.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
204
|
-
'
|
|
205
|
-
|
|
206
|
-
'
|
|
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
|
];
|