@objectstack/formula 9.6.0 → 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.
@@ -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 {