@objectstack/formula 9.5.1 → 9.7.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 +73 -0
- package/dist/index.d.mts +24 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +104 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +104 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/cel-engine.test.ts +74 -0
- package/src/cel-engine.ts +97 -6
- package/src/stdlib.ts +32 -0
- package/src/validate.test.ts +94 -0
- package/src/validate.ts +64 -6
package/src/validate.test.ts
CHANGED
|
@@ -26,6 +26,27 @@ describe('validateExpression (ADR-0032)', () => {
|
|
|
26
26
|
expect(validateExpression('predicate', '').ok).toBe(true);
|
|
27
27
|
expect(validateExpression('predicate', null).ok).toBe(true);
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
// #1877 — a predicate calling an UNKNOWN function (e.g. `PRIOR()`, a typo'd
|
|
31
|
+
// `isBlnk()`) must be rejected at build/registration, not silently accepted
|
|
32
|
+
// and then no-op the flow at runtime. cel-js's type checker reports these as
|
|
33
|
+
// `found no matching overload`; the engine surfaces them as an invalid CEL
|
|
34
|
+
// predicate.
|
|
35
|
+
it('rejects an unknown function call (#1877)', () => {
|
|
36
|
+
const r = validateExpression('predicate', 'PRIOR(status) != "promoted"');
|
|
37
|
+
expect(r.ok).toBe(false);
|
|
38
|
+
expect(r.errors[0].message).toMatch(/invalid CEL predicate/i);
|
|
39
|
+
expect(r.errors[0].message).toMatch(/overload|PRIOR/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects an unknown function even when guarded by a short-circuit (#1877)', () => {
|
|
43
|
+
const r = validateExpression('predicate', 'status == "promoted" && PRIOR(status) != "promoted"');
|
|
44
|
+
expect(r.ok).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('still accepts a registered stdlib function (isBlank)', () => {
|
|
48
|
+
expect(validateExpression('predicate', '!isBlank(record.target_channels)').ok).toBe(true);
|
|
49
|
+
});
|
|
29
50
|
});
|
|
30
51
|
|
|
31
52
|
describe('templates', () => {
|
|
@@ -59,6 +80,79 @@ describe('validateExpression (ADR-0032)', () => {
|
|
|
59
80
|
});
|
|
60
81
|
});
|
|
61
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
|
+
|
|
62
156
|
describe('introspection', () => {
|
|
63
157
|
it('reports the dialect + scope for a field role', () => {
|
|
64
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 {
|