@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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/formula@9.5.1 build /home/runner/work/framework/framework/packages/formula
2
+ > @objectstack/formula@9.7.0 build /home/runner/work/framework/framework/packages/formula
3
3
  > tsup --config ../../tsup.config.ts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- CJS dist/index.js 25.06 KB
14
- CJS dist/index.js.map 62.33 KB
15
- CJS ⚡️ Build success in 111ms
16
- ESM dist/index.mjs 23.34 KB
17
- ESM dist/index.mjs.map 60.97 KB
18
- ESM ⚡️ Build success in 115ms
13
+ CJS dist/index.js 27.85 KB
14
+ CJS dist/index.js.map 73.20 KB
15
+ CJS ⚡️ Build success in 103ms
16
+ ESM dist/index.mjs 26.12 KB
17
+ ESM dist/index.mjs.map 71.83 KB
18
+ ESM ⚡️ Build success in 104ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 4438ms
21
- DTS dist/index.d.mts 14.02 KB
22
- DTS dist/index.d.ts 14.02 KB
20
+ DTS ⚡️ Build success in 5303ms
21
+ DTS dist/index.d.mts 15.44 KB
22
+ DTS dist/index.d.ts 15.44 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # @objectstack/formula
2
2
 
3
+ ## 9.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ff0a87a: feat(validate): flag bare field references in record-scoped CEL sites at build time
8
+
9
+ > **Heads-up for downstream:** this adds a NEW build-time error. A `Field.formula`
10
+ > or validation predicate that references a field bare (`amount` instead of
11
+ > `record.amount`) now fails `objectstack compile`. These expressions were already
12
+ > silently broken at runtime (they evaluated to `null` / never fired), so this is a
13
+ > fix that surfaces a latent bug — but a stack carrying one will go from
14
+ > "builds, silently wrong" to "fails the build" on upgrade. The error message
15
+ > states the exact correction (`write record.<field>`).
16
+
17
+ A `Field.formula` and an object validation predicate evaluate against the
18
+ `record` namespace only — there is no field flattening — so a bare top-level
19
+ identifier (`amount`, `status`) resolves to nothing and the expression silently
20
+ evaluates to `null` / never fires. This is the silent-at-runtime class behind
21
+ the broken example-crm formulas (#1927) and is exactly what AI authors get wrong.
22
+
23
+ `validateExpression` now takes an evaluation `scope` and, for `scope: 'record'`,
24
+ reports a bare reference with the corrective form (`write record.<field>`). The
25
+ check is schema-free and acts only on cel-js's `Unknown variable` fault, so it
26
+ cannot false-positive on arithmetic/comparison/null-guard type overloads. Flow
27
+ and automation conditions keep the default `scope: 'flattened'` — the record's
28
+ fields ARE spread to top-level there (alongside flow variables), so bare refs
29
+ are correct and are NOT flagged. `objectstack compile` wires `record` scope for
30
+ field formulas and validation predicates; flow conditions stay flattened.
31
+
32
+ ### Patch Changes
33
+
34
+ - 82c7438: fix(formula): register mixed `double <op> int` arithmetic overloads so number-field formulas compute
35
+
36
+ cel-js types a record field number as `double` and a bare integer literal as
37
+ `int`, and ships overloads only for matching numeric pairs. So an everyday
38
+ formula like `record.amount / 100` or `record.price * 2` faulted at runtime
39
+ (`no such overload: dyn<double> / int`); the engine caught the fault and the
40
+ formula silently evaluated to `null` — passing build, empty at runtime (#1928).
41
+
42
+ The CEL engine now registers the missing `double <op> int` / `int <op> double`
43
+ overloads for `+ - * / %`, computing the result as a `double` (CEL's mixed-numeric
44
+ promotion). Pure `int op int` is untouched, so integer division (`7 / 2 == 3`)
45
+ keeps its semantics — the overloads fire only when the operands are genuinely a
46
+ `double` and an `int`. Authors no longer need the `/ 100.0` float-literal workaround.
47
+
48
+ - 417b6ac: feat(validate): advisory did-you-mean warnings for likely field typos in flow conditions
49
+
50
+ Adds a non-blocking warning channel to build-time expression validation (#1928
51
+ tier 3). Flow / automation conditions flatten the record's fields to top-level,
52
+ so a bare `status` is correct — but a bare NON-field identifier is either a flow
53
+ variable or a typo. When it is a near-miss of a known field (edit distance), the
54
+ build now emits a `did you mean \`status\`?`warning instead of staying silent,
55
+ WITHOUT failing the build (a genuine flow variable won't be close to a field
56
+ name, so it stays quiet).`ExprValidationResult`gains a`warnings`array and`ExprIssue`a`severity`; `objectstack compile` prints warnings and only fails on
57
+ errors. This closes the silent-skip gap for misspelled trigger-condition fields
58
+ (the #1877 family) without the false-positive risk of a hard gate.
59
+
60
+ - @objectstack/spec@9.7.0
61
+
62
+ ## 9.6.0
63
+
64
+ ### Patch Changes
65
+
66
+ - bb00a50: fix(formula): catch unknown functions in CEL conditions at build (#1877)
67
+
68
+ `compile()` discarded cel-js's type-check verdict because `check()` returns a `TypeCheckResult` object (`{ valid, error }`), not an array — so the `Array.isArray(checkErrors)` guard never matched. A condition calling an unknown function (`PRIOR(status)`, a typo'd `isBlnk(...)`) type-checks as `found no matching overload`, but that result never surfaced, so `objectstack compile`, `registerFlow`, and the `validate_expression` tool all accepted the predicate, which then silently no-op'd the flow at runtime. Now reads the documented `{ valid, error }` shape, closing the gap for flow conditions, validation rules, and field formulas at once.
69
+
70
+ - Updated dependencies [d1e930a]
71
+ - Updated dependencies [71578f2]
72
+ - Updated dependencies [5e3a301]
73
+ - Updated dependencies [5db2742]
74
+ - @objectstack/spec@9.6.0
75
+
3
76
  ## 9.5.1
4
77
 
5
78
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -310,6 +310,23 @@ interface ExprSchemaHint {
310
310
  objectName?: string;
311
311
  /** Known top-level field names, so `record.<field>` can be checked. */
312
312
  fields?: readonly string[];
313
+ /**
314
+ * Evaluation scope of the authoring site — determines whether a bare top-level
315
+ * identifier is legal (#1928):
316
+ * - `'record'` → the record is bound only as the `record` namespace, with
317
+ * no field flattening (`Field.formula`, object validation
318
+ * predicates). A bare `amount` resolves to nothing and the
319
+ * expression silently evaluates to `null` / never fires, so
320
+ * it MUST be written `record.amount`. We flag bare refs.
321
+ * - `'flattened'` → the record's own fields are spread to top-level alongside
322
+ * flow variables (flow / automation conditions), so bare
323
+ * `status` is correct and is NOT an error. Flow variables
324
+ * are not schema-knowable, so a non-field bare identifier
325
+ * can't be soundly told apart from a typo — but when one is
326
+ * a near-miss of a known field we emit a non-blocking
327
+ * did-you-mean *warning*. (Default.)
328
+ */
329
+ scope?: 'record' | 'flattened';
313
330
  }
314
331
  interface ExprValidationError {
315
332
  /** Self-correcting message: what is wrong + the correct form. */
@@ -320,6 +337,13 @@ interface ExprValidationError {
320
337
  interface ExprValidationResult {
321
338
  ok: boolean;
322
339
  errors: ExprValidationError[];
340
+ /**
341
+ * Non-blocking advisories (#1928 tier 3): a likely-typo'd field reference in a
342
+ * flattened flow condition. Never affects `ok` — callers surface these without
343
+ * failing the build, since a bare identifier there may legitimately be a flow
344
+ * variable.
345
+ */
346
+ warnings: ExprValidationError[];
323
347
  }
324
348
  /** The dialect a field role expects (Decision 2). */
325
349
  declare function expectedDialect(role: FieldRole): 'cel' | 'template';
package/dist/index.d.ts CHANGED
@@ -310,6 +310,23 @@ interface ExprSchemaHint {
310
310
  objectName?: string;
311
311
  /** Known top-level field names, so `record.<field>` can be checked. */
312
312
  fields?: readonly string[];
313
+ /**
314
+ * Evaluation scope of the authoring site — determines whether a bare top-level
315
+ * identifier is legal (#1928):
316
+ * - `'record'` → the record is bound only as the `record` namespace, with
317
+ * no field flattening (`Field.formula`, object validation
318
+ * predicates). A bare `amount` resolves to nothing and the
319
+ * expression silently evaluates to `null` / never fires, so
320
+ * it MUST be written `record.amount`. We flag bare refs.
321
+ * - `'flattened'` → the record's own fields are spread to top-level alongside
322
+ * flow variables (flow / automation conditions), so bare
323
+ * `status` is correct and is NOT an error. Flow variables
324
+ * are not schema-knowable, so a non-field bare identifier
325
+ * can't be soundly told apart from a typo — but when one is
326
+ * a near-miss of a known field we emit a non-blocking
327
+ * did-you-mean *warning*. (Default.)
328
+ */
329
+ scope?: 'record' | 'flattened';
313
330
  }
314
331
  interface ExprValidationError {
315
332
  /** Self-correcting message: what is wrong + the correct form. */
@@ -320,6 +337,13 @@ interface ExprValidationError {
320
337
  interface ExprValidationResult {
321
338
  ok: boolean;
322
339
  errors: ExprValidationError[];
340
+ /**
341
+ * Non-blocking advisories (#1928 tier 3): a likely-typo'd field reference in a
342
+ * flattened flow condition. Never affects `ok` — callers surface these without
343
+ * failing the build, since a bare identifier there may legitimately be a flow
344
+ * variable.
345
+ */
346
+ warnings: ExprValidationError[];
323
347
  }
324
348
  /** The dialect a field role expects (Decision 2). */
325
349
  declare function expectedDialect(role: FieldRole): 'cel' | 'template';
package/dist/index.js CHANGED
@@ -98,6 +98,21 @@ function registerStdLib(env, now) {
98
98
  }
99
99
  );
100
100
  }
101
+ function registerNumericCoercions(env) {
102
+ const ops = {
103
+ "+": (a, b) => a + b,
104
+ "-": (a, b) => a - b,
105
+ "*": (a, b) => a * b,
106
+ "/": (a, b) => a / b,
107
+ "%": (a, b) => a % b
108
+ };
109
+ for (const [op, fn] of Object.entries(ops)) {
110
+ const impl = (a, b) => fn(Number(a), Number(b));
111
+ env.registerOperator(`double ${op} int`, impl);
112
+ env.registerOperator(`int ${op} double`, impl);
113
+ }
114
+ return env;
115
+ }
101
116
  function buildScope(ctx) {
102
117
  const scope = {};
103
118
  if (ctx.record !== void 0) scope.record = ctx.record;
@@ -126,7 +141,66 @@ function buildEnv(now) {
126
141
  enableOptionalTypes: true,
127
142
  limits: DEFAULT_LIMITS
128
143
  });
129
- return registerStdLib(env, now);
144
+ return registerNumericCoercions(registerStdLib(env, now));
145
+ }
146
+ var SCOPE_ROOTS = [
147
+ "record",
148
+ "previous",
149
+ "input",
150
+ "output",
151
+ "os",
152
+ "vars",
153
+ "variables",
154
+ "automation",
155
+ "context",
156
+ "args",
157
+ "item",
158
+ "env",
159
+ "user",
160
+ "step",
161
+ "result",
162
+ "trigger",
163
+ "event",
164
+ "payload",
165
+ "data",
166
+ "params",
167
+ "config",
168
+ "settings"
169
+ ];
170
+ function buildScopedEnv(knownFields) {
171
+ const env = new import_cel_js.Environment({
172
+ unlistedVariablesAreDyn: false,
173
+ enableOptionalTypes: true,
174
+ limits: DEFAULT_LIMITS
175
+ });
176
+ registerStdLib(env, () => /* @__PURE__ */ new Date(0));
177
+ for (const root of SCOPE_ROOTS) {
178
+ try {
179
+ env.registerVariable(root, "map");
180
+ } catch {
181
+ }
182
+ }
183
+ for (const field of knownFields) {
184
+ try {
185
+ env.registerVariable(field, "dyn");
186
+ } catch {
187
+ }
188
+ }
189
+ return env;
190
+ }
191
+ var recordScopeEnv;
192
+ function firstUndeclaredReference(source, knownFields = []) {
193
+ if (typeof source !== "string" || !source.trim()) return null;
194
+ try {
195
+ const env = knownFields.length === 0 ? recordScopeEnv ?? (recordScopeEnv = buildScopedEnv([])) : buildScopedEnv(knownFields);
196
+ const result = env.parse(source).check?.();
197
+ if (result && result.valid === false) {
198
+ const m = /Unknown variable:\s*([A-Za-z_$][\w$]*)/.exec(result.error?.message ?? "");
199
+ if (m) return m[1];
200
+ }
201
+ } catch {
202
+ }
203
+ return null;
130
204
  }
131
205
  function coerce(value) {
132
206
  if (typeof value === "bigint") {
@@ -185,11 +259,11 @@ var celEngine = {
185
259
  try {
186
260
  const env = buildEnv(() => /* @__PURE__ */ new Date(0));
187
261
  const compiled = env.parse(source);
188
- const checkErrors = compiled.check?.();
189
- if (checkErrors && Array.isArray(checkErrors) && checkErrors.length > 0) {
262
+ const checkResult = compiled.check?.();
263
+ if (checkResult && checkResult.valid === false) {
190
264
  return {
191
265
  ok: false,
192
- error: { kind: "type", message: checkErrors.join("; ") }
266
+ error: { kind: "type", message: checkResult.error?.message ?? "expression failed type checking" }
193
267
  };
194
268
  }
195
269
  return { ok: true, value: compiled.ast };
@@ -715,11 +789,12 @@ function levenshtein(a, b) {
715
789
  function validateExpression(role, input, schema) {
716
790
  const { dialect, source } = toSource(input);
717
791
  const errors = [];
718
- if (!source.trim()) return { ok: true, errors };
792
+ const warnings = [];
793
+ if (!source.trim()) return { ok: true, errors, warnings };
719
794
  if (role === "template") {
720
795
  if (dialect && dialect !== "template") {
721
796
  errors.push({ source, message: `expected a text template but got a \`${dialect}\` expression.` });
722
- return { ok: false, errors };
797
+ return { ok: false, errors, warnings };
723
798
  }
724
799
  const compiled2 = templateEngine.compile(source);
725
800
  if (!compiled2.ok) {
@@ -727,11 +802,11 @@ function validateExpression(role, input, schema) {
727
802
  }
728
803
  const hint = SINGLE_BRACE_RE.test(source) ? bracesHintForTemplate(source) : null;
729
804
  if (hint) errors.push({ source, message: hint });
730
- return { ok: errors.length === 0, errors };
805
+ return { ok: errors.length === 0, errors, warnings };
731
806
  }
732
807
  if (dialect && dialect !== "cel") {
733
808
  errors.push({ source, message: `expected a CEL expression but got a \`${dialect}\` dialect.` });
734
- return { ok: false, errors };
809
+ return { ok: false, errors, warnings };
735
810
  }
736
811
  const compiled = celEngine.compile(source);
737
812
  if (!compiled.ok) {
@@ -742,8 +817,28 @@ function validateExpression(role, input, schema) {
742
817
  });
743
818
  } else {
744
819
  checkFieldExistence(source, schema, errors);
820
+ if (schema?.scope === "record") {
821
+ const bare = firstUndeclaredReference(source);
822
+ if (bare) {
823
+ errors.push({
824
+ source,
825
+ message: `bare reference \`${bare}\` \u2014 a formula/validation expression binds the record as the \`record\` namespace, not at top level, so \`${bare}\` resolves to nothing and the expression silently evaluates to null. Write \`record.${bare}\`.`
826
+ });
827
+ }
828
+ } else if (schema?.fields && schema.fields.length > 0) {
829
+ const unknown = firstUndeclaredReference(source, schema.fields);
830
+ if (unknown) {
831
+ const suggestion = nearest(unknown, schema.fields);
832
+ if (suggestion) {
833
+ warnings.push({
834
+ source,
835
+ message: `\`${unknown}\` is not a field of \`${schema.objectName ?? "the trigger object"}\` \u2014 did you mean \`${suggestion}\`? (flow conditions reference fields bare, e.g. \`${suggestion} == \u2026\`). If \`${unknown}\` is a flow variable this is safe to ignore.`
836
+ });
837
+ }
838
+ }
839
+ }
745
840
  }
746
- return { ok: errors.length === 0, errors };
841
+ return { ok: errors.length === 0, errors, warnings };
747
842
  }
748
843
  function bracesHintForTemplate(source) {
749
844
  const m = SINGLE_BRACE_RE.exec(source);