Repository navigation
Expand file tree
/
Copy pathvalidate.test.ts
More file actions
1211 lines (1108 loc) · 67.6 KB
/
Copy pathvalidate.test.ts
File metadata and controls
1211 lines (1108 loc) · 67.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { describe, it, expect } from 'vitest';
import {
validateExpression,
introspectScope,
expectedDialect,
inferExpressionType,
nearestName,
CEL_STDLIB_FUNCTIONS,
} from './validate';
import { buildEnv, firstUndeclaredReference, SCOPE_ROOTS } from './cel-engine';
describe('validateExpression (ADR-0032)', () => {
describe('predicates (CEL)', () => {
it('accepts a valid bare-CEL predicate', () => {
const r = validateExpression('predicate', 'record.rating >= 4');
expect(r.ok).toBe(true);
expect(r.errors).toHaveLength(0);
});
it('rejects the #1491 brace-in-CEL form with a corrective message', () => {
const r = validateExpression('predicate', '{record.rating} >= 4');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/map literal|bare reference|template brace/i);
expect(r.errors[0].message).toContain('record.rating');
expect(r.errors[0].source).toBe('{record.rating} >= 4');
});
it('rejects a CEL envelope placed in a template-only role', () => {
const r = validateExpression('template', { dialect: 'cel', source: 'record.x' });
expect(r.ok).toBe(false);
});
it('accepts an empty/absent expression (no-op)', () => {
expect(validateExpression('predicate', '').ok).toBe(true);
expect(validateExpression('predicate', null).ok).toBe(true);
});
// #1877 — a predicate calling an UNKNOWN function (e.g. `PRIOR()`, a typo'd
// `isBlnk()`) must be rejected at build/registration, not silently accepted
// and then no-op the flow at runtime. cel-js's type checker reports these as
// `found no matching overload`; the engine surfaces them as an invalid CEL
// predicate.
it('rejects an unknown function call (#1877)', () => {
const r = validateExpression('predicate', 'PRIOR(status) != "promoted"');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/invalid CEL predicate/i);
expect(r.errors[0].message).toMatch(/overload|PRIOR/);
});
it('rejects an unknown function even when guarded by a short-circuit (#1877)', () => {
const r = validateExpression('predicate', 'status == "promoted" && PRIOR(status) != "promoted"');
expect(r.ok).toBe(false);
});
it('still accepts a registered stdlib function (isBlank)', () => {
expect(validateExpression('predicate', '!isBlank(record.target_channels)').ok).toBe(true);
});
// #13594 — the RECEIVER/member-call form of an unknown function had NO pin
// anywhere in this package. Every unknown-function pin written for #1877 —
// the two above, plus `celEngine.compile`'s and the fault-classification
// suite's — spells the call as a GLOBAL one (`PRIOR(...)`, `size(1)`). A
// regression that lost only the receiver arm of cel-js's type check would
// therefore have kept this whole suite green while `validateExpression`
// passed a predicate the runtime then faults on, and on the ObjectUI action
// surfaces that fault is fail-CLOSED and near-silent:
// `ActionEngine.getActionsForLocation` and `DeclaredActionsBar` both
// evaluate with `throwOnError: true` and HIDE the action — for every user,
// including ones who hold the grant — leaving one deduped `console.warn` as
// the only signal. A plausible-looking function name that does not exist is
// exactly what a generator invents, so the half with no pin was the half an
// AI-authored app is most likely to hit.
//
// MEASURED while writing these, on the source tree AND on the published
// `@objectstack/formula@17.2.0` / `@marcbachmann/cel-js@8.0.0` that #13594
// names: BOTH call forms are already rejected at this entry point, and
// `upper('a')` is still clean. #13594's table (`validate.ok= true` for both)
// did not reproduce here. The surface that does accept them is
// `@objectstack/lint`'s `validate-visibility-predicates`, which stays
// parse-only by a maintainer ruling and carries its own pin for that. These
// cases exist so that measured verdict cannot quietly stop being true.
it('rejects an unknown method in the receiver/member-call form (#13594)', () => {
const r = validateExpression('predicate', "record.x.nosuchmethod('a')");
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
expect(r.errors[0].message).toMatch(/invalid CEL predicate/i);
// cel-js names the RECEIVER's static type, not the source spelling, so the
// author sees `dyn.nosuchmethod`. Pinned as the author sees it — the same
// `found no matching overload` vocabulary the runtime fault uses, which is
// what makes publish time and run time read as one system.
expect(r.errors[0].message).toContain("found no matching overload for 'dyn.nosuchmethod(string)'");
expect(r.errors[0].source).toBe("record.x.nosuchmethod('a')");
expect(r.warnings).toHaveLength(0);
});
it('rejects an invented method on the canonical user root (#13594)', () => {
// RE-POINTED, not deleted (batch #147 item 5, letter A). The structural
// property is unchanged — `current_user` is a declared SCOPE_ROOT, so a
// method call hung off it is NOT caught as an unbound root and only the
// unknown call catches it — but the EXAMPLE had to move: `can` is a
// registered receiver method now, so it is no longer invented. A plausible
// neighbour that still is carries the case.
const r = validateExpression('predicate', 'current_user.canApprove(record, "read")');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain('found no matching overload');
expect(r.errors[0].message).toContain('canApprove');
});
it('ACCEPTS `current_user.can(object, verb)` — the objectui#4421 predicate, now registered', () => {
// The control that makes the re-pointing above a reading rather than a
// move: the exact authored shape the card came from validates clean.
// Until this landed the validator refused it while the publish gate for
// view predicates accepted it — a gate and a runtime disagreeing about one
// name (#13594). Both say the same thing about `can` now.
const r = validateExpression('predicate', 'current_user.can(record, "read")');
expect(r.ok).toBe(true);
expect(r.errors).toHaveLength(0);
});
it('the global form rejects and the stdlib control stays clean (#13594)', () => {
// Both directions in ONE case, deliberately. A pin asserting only the
// rejection stays green under a change that rejects EVERY call — which
// would close the hole by breaking every authored predicate instead.
const bogus = validateExpression('predicate', 'totallyBogusFn(1,2)');
expect(bogus.ok).toBe(false);
expect(bogus.errors[0].message).toContain("found no matching overload for 'totallyBogusFn(int, int)'");
const control = validateExpression('predicate', "upper('a')");
expect(control.ok).toBe(true);
expect(control.errors).toHaveLength(0);
expect(control.warnings).toHaveLength(0);
});
});
// #7073 — the trailer used to be undifferentiated: EVERY `celEngine.compile`
// refusal, bounds included, ended with the dialect prescription
// "`predicate`s are bare CEL (e.g. `record.rating >= 4`)". For a
// syntactically perfect but over-budget expression that sentence is advice
// that cannot succeed — the source IS bare CEL — and an author who follows
// the last sentence they were handed (an LLM author above all) rewrites the
// dialect and regresses.
//
// Both directions are pinned, deliberately. A test asserting only "the
// bounds message changed" would stay green on a fix that ALSO stripped the
// dialect trailer from genuine dialect faults, i.e. that shrank the refusal
// surface while appearing to widen it.
describe('bounds vs dialect: the prescription follows the fault class (#7073)', () => {
/** 80-term conjunction — the escalation's `maxAstNodes` shape (#6833's fixture). */
const OVER_AST_NODES = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && ');
/** 60-level parenthesis nest — `maxDepth`. Counts recursion that leaves no AST node. */
const OVER_DEPTH = `${'('.repeat(60)}record.a${')'.repeat(60)} == 1`;
/** 200-element list literal — `maxListElements`. */
const OVER_LIST = `record.id in [${Array.from({ length: 200 }, (_, i) => `'u${i}'`).join(',')}]`;
/** The byte-for-byte dialect trailer, per role. Must survive on dialect faults. */
const dialectTrailer = (role: 'predicate' | 'value') =>
` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`;
describe.each([
{ name: 'maxAstNodes (80-term conjunction)', source: OVER_AST_NODES, limit: 'maxAstNodes' },
{ name: 'maxDepth (60-level nest)', source: OVER_DEPTH, limit: 'maxDepth' },
{ name: 'maxListElements (200-element list)', source: OVER_LIST, limit: 'maxListElements' },
])('an over-budget but valid CEL $name', ({ source, limit }) => {
it('is refused, names the exceeded bound and its value, and never says "bare CEL"', () => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
const { message } = r.errors[0];
// The front half — cel-js's own reason — was always right; keep it.
expect(message).toMatch(/^invalid CEL predicate:/);
expect(message).toMatch(/Exceeded/);
// The bound is NAMED with the platform's value for it.
expect(message).toContain(`\`${limit}\` budget (limit `);
// …and the prescription is a size prescription, not a dialect one.
expect(message).toMatch(/SIZE fault, not a dialect mistake/);
expect(message).toMatch(/Shrink it/);
// ⛔ The defect itself: the dialect trailer must NOT reach this class.
expect(message).not.toContain(dialectTrailer('predicate'));
expect(message).not.toMatch(/bare CEL/);
});
it('applies to the `value` role too — one producer, all ~10 slots', () => {
const r = validateExpression('value', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/^invalid CEL value:/);
expect(r.errors[0].message).toMatch(/SIZE fault, not a dialect mistake/);
expect(r.errors[0].message).not.toContain(dialectTrailer('value'));
});
});
// The flipped pin. The refusal surface may never shrink: a genuine dialect
// or syntax fault keeps the ADR-0032 §1d trailer, byte for byte.
it.each([
{ name: 'an unterminated comparison', source: 'record.stage ==' },
{ name: 'a stray token', source: 'record.stage @@ "won"' },
{ name: 'a SQL-dialect predicate', source: "stage = 'won' AND rating >= 4" },
])('keeps the dialect trailer verbatim on $name', ({ source }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/SIZE fault/);
});
it('keeps the #1491 braces hint on a brace fault (it outranks no bounds fault)', () => {
const r = validateExpression('predicate', '{record.rating} >= 4');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/template brace was used inside a CEL expression/);
expect(r.errors[0].message).not.toMatch(/SIZE fault/);
});
it('offers no split remedy without a caveat — combination semantics differ per slot', () => {
// PR #6831's RLS sentence ("splitting the top-level `&&` widens the
// grant") is TRUE for a security predicate and wrong-to-meaningless for a
// formula value. This shared producer therefore ships the caveat, not the
// slot-specific claim.
const message = validateExpression('predicate', OVER_AST_NODES).errors[0].message;
expect(message).toMatch(/changes how they combine at this authoring site/);
expect(message).not.toMatch(/widen|grant|permission/i);
});
});
// #13821 — the second leg of #7073. An unknown-function refusal is graded
// `type` by the engine's own `check()`, so before this it fell through to
// `bracesHint` (null, no brace) and out to the dialect trailer: "predicates
// are bare CEL" handed to an author whose source already IS bare CEL and
// parses fine. The guard itself was and stays correct — only the sentence the
// author is told to act on was wrong.
//
// These assert the SPECIFIC prescription, not merely that an error fires; an
// error already fired before the repair. And the `bounds` control below is
// load-bearing: it proves a new class was routed rather than the shared tail
// replaced for everyone.
describe('unknown function vs dialect: the prescription follows the fault class (#13821)', () => {
const dialectTrailer = (role: 'predicate' | 'value') =>
` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`;
it.each([
{ name: 'a receiver call', source: "record.x.nosuchmethod('a')", fn: 'nosuchmethod' },
{ name: 'a bare call', source: 'totallyBogusFn(1,2)', fn: 'totallyBogusFn' },
{ name: 'a call nested in a conjunction', source: "record.a == 1 && zzzznope(record.b)", fn: 'zzzznope' },
{ name: 'a receiver-only cel-js name used bare', source: "split(record.name, ',') == ['a']", fn: 'split' },
])('names the unresolvable function and points at the callable set — $name', ({ source, fn }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
const { message } = r.errors[0];
// The front half — cel-js's own vocabulary, matching the runtime fault — is kept.
expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /);
// The prescription NAMES the function that did not resolve…
expect(message).toContain(`\`${fn}\` is not a callable name here`);
expect(message).toMatch(/NAME fault, not a dialect mistake/);
// …and points at the callable set, via the introspection API that publishes it.
expect(message).toContain('`introspectScope`');
expect(message).toContain('`CEL_STDLIB_FUNCTIONS`');
// ⛔ The defect itself: the dialect trailer must NOT reach this class.
expect(message).not.toContain(dialectTrailer('predicate'));
expect(message).not.toMatch(/bare CEL/);
});
it('applies to the `value` role too — one producer, all ~10 slots', () => {
const r = validateExpression('value', 'nosuchfn(1)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/^invalid CEL value:/);
expect(r.errors[0].message).toContain('`nosuchfn` is not a callable name here');
expect(r.errors[0].message).not.toContain(dialectTrailer('value'));
});
// ⛔ The message may never state how many functions the catalog holds: what
// the catalog contains is being adjudicated separately, and a sentence
// asserting a count would be falsified by that ruling without failing here.
it('refers to the callable set without asserting its size', () => {
const message = validateExpression('predicate', 'totallyBogusFn(1,2)').errors[0].message;
expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/);
});
// did-you-mean is measured dangerous, so both directions are pinned. The
// threshold is the whole safety argument; either case flipping is a
// regression, not a tuning.
describe('did-you-mean is thresholded (both measured cases pinned)', () => {
it('suggests on a real typo: `isBlnk` → `isBlank`', () => {
const message = validateExpression('predicate', 'isBlnk(record.name)').errors[0].message;
expect(message).toContain('`isBlnk` is not a callable name here');
expect(message).toContain('Did you mean `isBlank`?');
});
it('stays SILENT on a distant match: `can` must never be answered with `min`', () => {
// `nearestName('can', CEL_STDLIB_FUNCTIONS)` is `'min'` — two edits on a
// three-character name, a jump from a permission verb to a numeric
// function. Worse than silence: an author who takes it writes
// `min(object, verb)`.
//
// RE-POINTED from the receiver form to the BARE one (batch #147 item 5,
// letter A). `can` is registered RECEIVER-ONLY now, so
// `current_user.can(…)` type-checks and no longer reaches this arm —
// while `can(object, verb)` still faults and still lands here, which is
// precisely the case this hint's wording was written for: the name is
// not callable HERE (it is one of the receiver-only names the catalog
// does not advertise), and the catalog is still the wrong place to look
// for a neighbour. The hazard is identical and the sentence is now
// literally true instead of merely useful.
const message = validateExpression('predicate', 'can(object, verb)').errors[0].message;
expect(message).toContain('`can` is not a callable name here');
expect(message).not.toMatch(/Did you mean/);
expect(message).not.toMatch(/`min`/);
});
it('…and the receiver form it moved OFF is clean — the control for that move', () => {
// Without this the case above would keep passing if `can` had never been
// registered at all, which is the whole thing the re-pointing asserts.
expect(validateExpression('predicate', 'current_user.can(object, verb)').ok).toBe(true);
});
it('leaves the shared `nearestName` budget alone — this class narrows locally', () => {
// The hazard is this catalog's, not the heuristic's: field-name
// suggestions keep the shared budget. If this ever stops answering
// `'min'`, the local threshold is no longer the thing protecting the
// message and the pin above has quietly become vacuous.
expect(nearestName('can', CEL_STDLIB_FUNCTIONS)).toBe('min');
expect(nearestName('isBlnk', CEL_STDLIB_FUNCTIONS)).toBe('isBlank');
});
});
// The flipped pins. The refusal surface may never shrink, and this arm may
// never speak for faults it cannot name.
it('keeps the dialect trailer on a real function given arguments no overload accepts', () => {
// Same cel-js message SHAPE, different fault: `upper` exists. Calling it
// "not a callable name" would replace a useless sentence with a false one.
const r = validateExpression('predicate', 'upper(1, 2)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/found no matching overload for 'upper\(int, int\)'/);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});
it.each([
{ name: 'an operator type mismatch', source: "1 + 'a'" },
{ name: 'a ternary branch mismatch', source: "record.rating >= 4 ? 1 : 'x'" },
])('keeps the dialect trailer on a `type` fault that names no call — $name', ({ source }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});
it('leaves the `bounds` prescription untouched — a new class was routed, not the shared tail replaced', () => {
const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && ');
const message = validateExpression('predicate', overBudget).errors[0].message;
expect(message).toMatch(/SIZE fault, not a dialect mistake/);
expect(message).toMatch(/Shrink it/);
expect(message).not.toMatch(/NAME fault/);
});
});
// #14203 — the third leg of #7073 / #13821. A bare-callable stdlib name
// written as a METHOD (`record.name.upper()`) is refused correctly and then
// handed the dialect trailer, which cannot succeed: the source already IS
// bare CEL and parses fine. #13821's arm stays silent here by design — the
// name is right, so calling `upper` "not a callable name" would replace a
// useless sentence with a false one — which left this class with no
// prescription at all until now.
//
// The fence is catalog membership PLUS the environment's own record of the
// receiver form, never the call shape alone. Each negative below is one class
// that keying on shape alone would have swallowed, and the `upper(record.name)`
// control makes the pair a pair: the prescription this arm hands out has to
// type-check, or the repair is another sentence that cannot succeed.
describe('receiver call vs dialect: a bare-callable name written as a method (#14203)', () => {
const dialectTrailer = (role: 'predicate' | 'value') =>
` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`;
it.each([
{ name: "the card's own shape", source: 'record.name.upper()', fn: 'upper', spelling: 'upper(record.name)' },
{ name: 'a nested receiver chain', source: 'record.owner.name.lower()', fn: 'lower', spelling: 'lower(record.owner.name)' },
{ name: 'a call inside a conjunction', source: "record.a == 1 && record.name.upper() == 'X'", fn: 'upper', spelling: 'upper(record.name)' },
{ name: 'a non-string stdlib function', source: 'record.due.daysFromNow()', fn: 'daysFromNow', spelling: 'daysFromNow(record.due)' },
])('prescribes the bare call assembled from the SOURCE — $name', ({ source, fn, spelling }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
const { message } = r.errors[0];
// The front half — cel-js's own vocabulary, matching the runtime fault — is kept.
expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /);
expect(message).toContain(`\`${fn}\` is callable bare, not as a method`);
expect(message).toMatch(/CALL-SHAPE fault, not a dialect mistake/);
// The spelling comes from the SOURCE: cel-js's message names the receiver's
// TYPE (`dyn.upper()`), so `record.name` exists nowhere in the fault text.
expect(message).toContain(`Write \`${spelling}\` instead.`);
// ⛔ The defect itself: the dialect trailer must NOT reach this class…
expect(message).not.toContain(dialectTrailer('predicate'));
expect(message).not.toMatch(/bare CEL/);
// …and this is not #13821's class, whose sentence would be false here.
expect(message).not.toMatch(/NAME fault/);
});
it('applies to the `value` role too — one producer, all ~10 slots', () => {
const r = validateExpression('value', 'record.name.lower()');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/^invalid CEL value:/);
expect(r.errors[0].message).toContain('Write `lower(record.name)` instead.');
expect(r.errors[0].message).not.toContain(dialectTrailer('value'));
});
// A prescription that cannot be DERIVED must not be invented — inventing one
// would repeat, one level up, the defect this card is about. The receiver
// still gets NAMED (the function and its call shape), just not spelled.
it.each([
{ name: 'an indexed receiver', source: 'record.tags[0].upper()' },
{ name: 'a parenthesised expression', source: '(record.a + record.b).upper()' },
{ name: 'a string literal', source: "'literal'.upper()" },
{ name: 'a chain that starts with an index — the near-miss', source: 'record.x[0].name.upper()' },
])('falls back to the call SHAPE when the receiver is not a plain chain — $name', ({ source }) => {
const message = validateExpression('predicate', source).errors[0].message;
expect(message).toContain('`upper` is callable bare, not as a method');
expect(message).toContain('Write `upper(…)` with the receiver as its first argument instead.');
// ⛔ No fabricated spelling. `record.x[0].name.upper()` is the measured
// near-miss: `name` is a plain chain, and it is not the receiver.
expect(message).not.toMatch(/Write `[A-Za-z_$][\w$]*\([^…)]/);
expect(message).not.toContain(dialectTrailer('predicate'));
});
// The exclusion is MEASURED, not remembered. Catalog membership alone would
// answer `record.name.contains()` — an arity fault on a legitimate receiver
// call — with "write `contains(record.name)`", which faults just as hard.
it('pins the advertised names the environment ALSO registers as receiver methods', () => {
const definitions = (
buildEnv(() => new Date(0)) as unknown as {
getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> };
}
).getDefinitions().functions;
const receiverRegistered = new Set(
definitions.filter((fn) => fn.receiverType != null).map((fn) => fn.name),
);
expect(
CEL_STDLIB_FUNCTIONS.filter((fn) => receiverRegistered.has(fn)).sort(),
'a name crossing this line moves it between the two classes below — ' +
'the arm keys on it, so re-derive both before making this green',
).toEqual(['contains', 'endsWith', 'matches', 'size', 'startsWith', 'string', 'trim']);
// …and the arm's own class is real on the other side of the line.
expect(receiverRegistered.has('upper')).toBe(false);
});
// ⛔ Negative, class 1: what must stay VALID. `split`/`map`/`getFullYear`
// are correct ONLY after a dot, so they never reach this arm at all — and
// the last row proves the prescription itself type-checks.
it.each([
["record.name.split(',')", 'a receiver-ONLY name, correctly written as a method'],
['record.dates.map(d, d)', 'a comprehension macro'],
['record.created.getFullYear()', 'a receiver-only date accessor'],
["record.name.contains('x')", 'a BOTH-forms name, correctly written as a method'],
['record.name.trim()', 'the same, with no arguments'],
['upper(record.name)', 'THE PRESCRIPTION THIS ARM HANDS OUT — it must type-check'],
])('%s stays valid and never reaches this arm (%s)', (source) => {
expect(validateExpression('predicate', source).ok).toBe(true);
});
// ⛔ Negative, class 2: a `type` fault this arm may not speak for keeps the
// trailer it has today, byte for byte.
it.each([
{ name: 'a BOTH-forms name faulting on ARITY, not on shape', source: 'record.name.contains()' },
{ name: 'the same for `startsWith`', source: 'record.name.startsWith()' },
{ name: 'the same for `matches`', source: 'record.n.matches()' },
{ name: 'a bare-callable name called BARE with wrong arguments', source: 'upper(1, 2)' },
{ name: 'an operator type mismatch — no call in it at all', source: "1 + 'a'" },
])('keeps the dialect trailer — $name', ({ source }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/CALL-SHAPE fault/);
});
// ⛔ Negative, class 3: #13821's arm answers exactly the sources it answered
// before, in both call forms.
it.each([
{ name: 'an unknown METHOD', source: 'record.name.nosuchmethod()', fn: 'nosuchmethod' },
{ name: 'a receiver-only name used BARE', source: "split(record.name, ',')", fn: 'split' },
{ name: 'an unknown bare name', source: 'nosuchfn(record.name)', fn: 'nosuchfn' },
])("keeps #13821's unknown-name arm — $name", ({ source, fn }) => {
const message = validateExpression('predicate', source).errors[0].message;
expect(message).toContain(`\`${fn}\` is not a callable name here`);
expect(message).toMatch(/NAME fault, not a dialect mistake/);
expect(message).not.toMatch(/CALL-SHAPE fault/);
});
it('leaves the `bounds` prescription untouched — a class was routed, not the shared tail replaced', () => {
const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && ');
const message = validateExpression('predicate', overBudget).errors[0].message;
expect(message).toMatch(/SIZE fault, not a dialect mistake/);
expect(message).not.toMatch(/CALL-SHAPE fault/);
});
// ⛔ Same discipline as #13821's arm: what the catalog CONTAINS is being
// adjudicated separately, so a sentence asserting a count would be falsified
// by that ruling without failing here.
it('points at the callable set without asserting its size', () => {
const message = validateExpression('predicate', 'record.name.upper()').errors[0].message;
expect(message).toContain('`introspectScope`');
expect(message).toContain('`CEL_STDLIB_FUNCTIONS`');
expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/);
});
});
describe('templates', () => {
it('accepts a valid {{ path }} template', () => {
const r = validateExpression('template', 'Hot lead: {{ record.full_name }}');
expect(r.ok).toBe(true);
});
it('flags single-brace {x} in a template and suggests {{ }}', () => {
const r = validateExpression('template', 'Hi {record.name}');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/\{\{ record\.name \}\}|double braces/);
});
});
describe('schema-aware field existence (v1)', () => {
it('flags an unknown record field with a did-you-mean', () => {
const r = validateExpression('predicate', 'record.raitng >= 4', { objectName: 'crm_lead', fields: ['rating', 'status'] });
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/unknown field `raitng`/);
expect(r.errors[0].message).toMatch(/did you mean `rating`/);
});
it('passes when fields exist', () => {
const r = validateExpression('predicate', 'record.rating >= 4 && record.status == "new"', { fields: ['rating', 'status'] });
expect(r.ok).toBe(true);
});
it('skips field checks when no schema is provided', () => {
expect(validateExpression('predicate', 'record.anything > 1').ok).toBe(true);
});
});
// #1928 — a bare top-level identifier is a silent bug in a `record`-scoped
// site (formula field / validation predicate) but correct in a `flattened`
// flow/automation condition. The validator must distinguish by `scope`.
describe('bare-reference detection by scope (#1928)', () => {
it('flags a bare field reference in a record-scoped predicate', () => {
const r = validateExpression('predicate', 'lead_score != null && lead_score > 100', { scope: 'record' });
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/bare reference `lead_score`/);
expect(r.errors[0].message).toMatch(/record\.lead_score/);
});
it('flags a bare reference in a record-scoped value (formula) expression', () => {
const r = validateExpression('value', '(budget == null ? 0 : budget) - (spent == null ? 0 : spent)', { scope: 'record' });
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/bare reference `(budget|spent)`/);
});
it('accepts the record-qualified form in a record-scoped site', () => {
const r = validateExpression('value', '(record.budget == null ? 0 : record.budget) - (record.spent == null ? 0 : record.spent)', { scope: 'record' });
expect(r.ok).toBe(true);
});
it('does NOT flag bare references in a flattened (flow) condition', () => {
// The record's fields are flattened to top-level for flow conditions, and
// flow variables share that namespace, so bare refs are correct here.
expect(validateExpression('predicate', 'status == "done" && previous.status != "done"', { scope: 'flattened' }).ok).toBe(true);
expect(validateExpression('predicate', 'budget > 100000', { scope: 'flattened' }).ok).toBe(true);
expect(validateExpression('predicate', 'expiring_deals.length > 0', { scope: 'flattened' }).ok).toBe(true);
});
it('defaults to flattened scope (no bare-ref flag) when scope is unset', () => {
expect(validateExpression('predicate', 'status == "done"').ok).toBe(true);
});
it('does not flag a null-guard on a record-qualified field (no type false-positive)', () => {
expect(validateExpression('predicate', 'record.lead_score != null && record.lead_score > 100', { scope: 'record' }).ok).toBe(true);
});
/**
* ── #16412: a bare ref BEHIND a `SCOPE_ROOTS` name is judged ───────
*
* `firstUndeclaredReference` reads the ONE error cel-js's checker returns,
* and acts only on `Unknown variable: X`. The strict env used to declare
* `SCOPE_ROOTS` as `map` while the permissive compile env leaves the same
* names `dyn`, so `data == 'x'` — a root, or an object field sharing a
* root's name — compiled clean and then faulted `no such overload:
* map<dyn, dyn> == string` HERE. That fault took the single error slot and
* every bare reference behind it in the same source went unjudged: the
* helper answered `null`, which is also the value meaning "every reference
* is rooted", and this hard error was downgraded to silence.
*
* The roots are now `dyn` in both environments, so the class cannot arise.
* These pin the VERDICT, not the mechanism: they go red if the strict env's
* root declaration regresses to `map`, whatever the message then says.
*/
describe('a bare ref behind a `SCOPE_ROOTS` name is still an error (#16412)', () => {
it.each([
['data', "data == 'x' && status == 'qualified'"],
['config', "config != null && status == 'qualified'"],
['record', "record == 'x' && status == 'qualified'"],
['result', "result > 1 && status == 'qualified'"],
['item', "item == 'x' && status == 'qualified'"],
])('%s as the FIRST operand does not mask `status`', (_root, source) => {
const r = validateExpression('predicate', source, { scope: 'record' });
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
expect(r.errors[0].message).toMatch(/bare reference `status`/);
expect(r.errors[0].message).toMatch(/record\.status/);
});
it('gives the same verdict in both operand orders — the masking was POSITIONAL', () => {
// The pre-fix asymmetry, and the whole reason the defect was invisible:
// one order reported, the other published clean. Both must report now,
// and the second is the control that was already correct.
const masked = validateExpression('predicate', "data == 'x' && status == 'qualified'", { scope: 'record' });
const control = validateExpression('predicate', "status == 'qualified' && data == 'x'", { scope: 'record' });
expect(masked.ok).toBe(false);
expect(control.ok).toBe(false);
expect(masked.errors[0].message).toBe(control.errors[0].message);
});
it('reports the BARE name, never the root that used to mask it', () => {
const r = validateExpression('predicate', "config != null && status == 'qualified'", { scope: 'record' });
expect(r.errors[0].message).not.toMatch(/bare reference `config`/);
expect(r.errors[0].message).toMatch(/bare reference `status`/);
});
it('the flattened did-you-mean reaches a typo behind a root name too', () => {
// The same masking, one severity down: `validateExpression`'s flattened
// arm never saw the typo, so a misspelled field shipped unwarned.
const r = validateExpression('predicate', "config == 'y' && amont == 'x'", {
objectName: 'crm_opportunity', fields: ['amount', 'status'], scope: 'flattened',
});
expect(r.ok).toBe(true);
expect(r.warnings).toHaveLength(1);
expect(r.warnings[0].message).toMatch(/`amont` is not a field/);
expect(r.warnings[0].message).toMatch(/did you mean `amount`/);
});
/**
* ⛔ The narrowing this helper is built on is NOT relaxed. Every source
* here has every reference rooted, or is legitimate CEL the overload
* message cannot be told apart from — none may start reporting. This is
* the property a widening of the regex onto the overload message would
* destroy, and it is the one most easily broken while closing a false
* negative.
*/
it.each([
['record.status == "x"', 'the canonical dotted spelling'],
['type(record.x) == string', 'legitimate CEL an overload-message widening would reject'],
['record.a + record.b > 1', 'arithmetic over two rooted members'],
['previous.status != record.status', 'two roots in one source'],
['parent.type == "grid" && record.status == "x"', 'a declared root, then a rooted term'],
['current_user.id != null', 'the ADR-0068 canonical user root'],
["record.tags.all(t, t != '')", 'a comprehension macro'],
['record.lines.exists(status, status.ok)', 'a macro variable shadowing a field name'],
['size(record.tags) > 0', 'a cel-js built-in'],
['record.?name.orValue("x") == "x"', 'optional chaining'],
])('%s stays clean (%s)', (source) => {
const r = validateExpression('predicate', source, { scope: 'record' });
expect(r.ok).toBe(true);
expect(r.errors).toEqual([]);
});
});
/**
* ⛔ #16412 closes the `SCOPE_ROOTS` class ONLY. Every other first-error
* class still masks what is behind it, and pinning that keeps the next
* reader from mistaking a narrowed fix for a general one.
*
* - a CEL TYPE name (`type`, `string`, `int`, …) is declared by CEL itself,
* so no declaration this package makes can reach it. Measured on the
* strict env: `type == 'grid' && …` faults `no such overload: type ==
* string` under BOTH a `map` and a `dyn` root declaration — byte-identical
* messages — which is why this row is out of the fix's reach by
* construction rather than by omission.
* - `has()` handed a non-select argument faults `has() invalid argument`.
* `@objectstack/lint`'s visibility rule masks `has(…)` spans at its own
* call site (#16118); nothing here does.
*/
describe('the first-error classes #16412 does NOT close', () => {
it.each([
["type == 'grid' && status == 'qualified'", 'a CEL type name — CEL declares it, not `SCOPE_ROOTS`'],
["string == 'x' && status == 'qualified'", 'the same class, another type name'],
['has(status) && other == "x"', 'the `has()` class — #16118 masks this at the lint call site'],
])('%s is still masked (%s)', (source) => {
expect(firstUndeclaredReference(source)).toBeNull();
});
it('`type` is not a `SCOPE_ROOTS` member — read from the list, not copied', () => {
// Reads the published baseline so the claim above cannot go stale
// silently if the list ever gains the name.
expect(SCOPE_ROOTS as readonly string[]).not.toContain('type');
});
/**
* #16412's own five-row probe table, pinned whole rather than by its one
* interesting row. Rows 1 and 5 are what the helper already got right and
* must keep; rows 2 and 3 are the `has()` class, unchanged by this fix;
* row 4 is the positional control that made the defect legible (rows 3
* and 4 are the same two sub-expressions in the other order).
*/
it.each([
['status == "qualified"', 'status'],
['has(status) && status == "qualified"', null],
['has(status) && other == "x"', null],
['other == "x" && has(status)', 'other'],
['has(record.status) && status == "qualified"', 'status'],
])('%s -> %s', (source, expected) => {
expect(firstUndeclaredReference(source)).toBe(expected);
});
});
});
// #1928 tier 3 — flattened flow conditions reference fields bare, so a bare
// ref is not an error. A bare NON-field that is a near-miss of a known field
// is a likely typo → non-blocking warning (ok stays true).
describe('flow-condition typo warnings (#1928 tier 3)', () => {
const fields = ['stage', 'amount', 'status'] as const;
it('warns (does not error) on a likely field typo in a flattened condition', () => {
const r = validateExpression('predicate', 'stagee == "closed_won"', { objectName: 'crm_opportunity', fields, scope: 'flattened' });
expect(r.ok).toBe(true);
expect(r.errors).toHaveLength(0);
expect(r.warnings).toHaveLength(1);
expect(r.warnings[0].message).toMatch(/`stagee` is not a field/);
expect(r.warnings[0].message).toMatch(/did you mean `stage`/);
});
it('does not warn on a correct bare field reference', () => {
const r = validateExpression('predicate', 'stage == "closed_won" && previous.stage != "closed_won"', { objectName: 'crm_opportunity', fields, scope: 'flattened' });
expect(r.ok).toBe(true);
expect(r.warnings).toHaveLength(0);
});
it('does not warn on a flow variable that is far from any field name', () => {
const r = validateExpression('predicate', 'expiring_deals.length > 0', { objectName: 'crm_opportunity', fields, scope: 'flattened' });
expect(r.ok).toBe(true);
expect(r.warnings).toHaveLength(0);
});
it('emits no warnings without a field list (nothing to compare against)', () => {
const r = validateExpression('predicate', 'stagee == "x"', { scope: 'flattened' });
expect(r.ok).toBe(true);
expect(r.warnings).toHaveLength(0);
});
});
// #1928 tier 4 — a text/boolean field used with an arithmetic or ordering
// operator against a number faults at runtime (silent null). With per-field
// types the validator surfaces this as a NON-blocking warning, and — the
// design law — never flags a case the runtime tolerates (number/date fields,
// equality, string concat, null-guards).
describe('type-soundness warnings (#1928 tier 4)', () => {
const schema = {
objectName: 'crm_opportunity',
fields: ['name', 'amount', 'is_active', 'due', 'priority', 'title'] as const,
fieldTypes: {
name: 'text', title: 'textarea', amount: 'currency',
is_active: 'boolean', due: 'date', priority: 'select',
},
scope: 'record',
} as const;
it('warns (does not error) on a text field used in arithmetic against a number', () => {
const r = validateExpression('value', 'record.name * 2', schema);
expect(r.ok).toBe(true);
expect(r.errors).toHaveLength(0);
expect(r.warnings).toHaveLength(1);
expect(r.warnings[0].message).toMatch(/type mismatch/i);
expect(r.warnings[0].message).toMatch(/record\.name/);
expect(r.warnings[0].message).toMatch(/evaluates to null/);
});
// [#29904] The caller declares a fail-closed site; there the fault is a
// refused write, not a null, and the verdict is an error saying so.
it('errors on the same comparison where the caller declares the site fail-closed (#29904)', () => {
const r = validateExpression('predicate', 'record.name > 5', { ...schema, faultOutcome: 'fail-closed' });
expect(r.ok).toBe(false);
expect(r.warnings).toHaveLength(0);
expect(r.errors).toHaveLength(1);
// [#29926] Its own code: the fail-closed sentence is not the warning's.
expect(r.errors[0]).toEqual({
source: 'record.name > 5',
message: expect.any(String),
code: 'type-mismatch-fail-closed',
params: { operands: 'string > int', operator: '>', held: 'text', reference: 'record.name' },
});
expect(r.errors[0].message).not.toMatch(/evaluates to null/);
const bool = validateExpression('predicate', 'record.is_active > 0', { ...schema, faultOutcome: 'fail-closed' });
expect(bool.errors.map((e) => e.code)).toEqual(['type-mismatch-fail-closed']);
// The conversions the message names validate clean in this dialect.
for (const fixed of ['size(record.name) > 5', 'double(record.name) > 5']) {
const ok = validateExpression('predicate', fixed, { ...schema, faultOutcome: 'fail-closed' });
expect([ok.errors, ok.warnings], fixed).toEqual([[], []]);
}
});
// [#29926] The two other outcomes a caller can declare, each an error
// under its own code, worded for what that runtime does.
it('errors with the run-fails wording where the caller declares a flow condition (#29926)', () => {
const flat = { ...schema, scope: 'flattened' as const, faultOutcome: 'run-fails' as const };
const r = validateExpression('predicate', 'name > 5', flat);
expect(r.warnings).toHaveLength(0);
expect(r.errors.map((e) => [e.code, e.params])).toEqual([
['type-mismatch-run-fails', { operands: 'string > int', operator: '>', held: 'text', reference: 'name' }],
]);
expect(r.errors[0].message).toContain('fails the run');
expect(r.errors[0].message).toContain('the write that fired it still lands');
expect(r.errors[0].message).not.toMatch(/evaluates to null|is refused/);
for (const fixed of ['size(name) > 5', 'double(name) > 5']) {
const ok = validateExpression('predicate', fixed, flat);
expect([ok.errors, ok.warnings], fixed).toEqual([[], []]);
}
});
// [#29959] A flow condition reads the field bare AND as `record.<field>`
// (`seedRunVariables` binds both to the trigger record), so the record
// read gets the bare name's verdict: here the run-fails error.
it('judges a flattened condition\'s record.<field> and previous.<field> reads as its bare name (#29959)', () => {
const flat = { ...schema, scope: 'flattened' as const, faultOutcome: 'run-fails' as const };
for (const [source, reference] of [
['record.name > 5', 'record.name'],
['previous.name * 2 > 1', 'previous.name'],
['record.is_active > 0', 'record.is_active'],
] as const) {
const r = validateExpression('predicate', source, flat);
expect(r.warnings, source).toHaveLength(0);
expect(r.errors.map((e) => [e.code, (e.params as { reference?: string }).reference]), source).toEqual([['type-mismatch-run-fails', reference]]);
expect(r.errors[0].message, source).toContain(`\`${reference}\``);
}
// Controls: a well-typed record read, a number field, and the
// conversions the message names stay clean.
for (const fixed of ['size(record.name) > 5', 'double(record.name) > 5', 'record.amount > 5', "record.name == 'x'", 'record.is_active == true']) {
const ok = validateExpression('predicate', fixed, flat);
expect([ok.errors, ok.warnings], fixed).toEqual([[], []]);
}
});
// [#29959] A bare name the run binds as a flow variable (`flowVariables`)
// holds the variable — `seedRunVariables` seeds it before the flattening,
// which skips a bound name — so it is not typed as the field it shadows.
// `record.<name>` still reads the field and keeps its verdict.
it('does not type a bare name a flow variable holds, and keeps its record.<name> read typed (#29959)', () => {
const flat = { ...schema, scope: 'flattened' as const, faultOutcome: 'run-fails' as const, flowVariables: ['name', 'is_active'] };
for (const source of ['name > 5', 'is_active > 0', 'name * 2 > 1']) {
const r = validateExpression('predicate', source, flat);
expect([r.errors, r.warnings], source).toEqual([[], []]);
}
for (const [source, reference] of [['record.name > 5', 'record.name'], ['previous.is_active > 0', 'previous.is_active']] as const) {
const r = validateExpression('predicate', source, flat);
expect(r.errors.map((e) => [e.code, (e.params as { reference?: string }).reference]), source).toEqual([['type-mismatch-run-fails', reference]]);
}
// A field the hint does not name is judged as before, in the same source.
const other = validateExpression('predicate', 'name > 5 && title > 5', { ...flat, flowVariables: ['name'] });
expect(other.errors.map((e) => (e.params as { reference?: string }).reference)).toEqual(['title']);
// The hint is the flattened scope's: a `record`-scoped site ignores it.
expect(validateExpression('predicate', 'record.name > 5', { ...schema, flowVariables: ['name'] }).warnings.map((w) => w.code))
.toEqual(['type-mismatch']);
});
// [#30025] Where the run can bind a bare name no reader sees (an open
// binder node, a contract-less screen), a bare-name mismatch is not proven
// to be the field's: it takes no declared outcome and is the run-may-fail
// warning. A `record.<field>` read still is the field and keeps the error,
// also when a bare mismatch comes first in the same source.
it('drops a bare-name mismatch to the run-may-fail warning where bare names are unproven, keeping record.<field> refused (#30025)', () => {
const flat = { ...schema, scope: 'flattened' as const, faultOutcome: 'run-fails' as const, bareNamesUnproven: true };
for (const source of ['name > 5', 'is_active > 0', 'name * 2 > 1']) {
const r = validateExpression('predicate', source, flat);
expect(r.errors, source).toEqual([]);
expect(r.warnings.map((w) => w.code), source).toEqual(['type-mismatch-run-may-fail']);
expect(r.warnings[0].message, source).toContain('which this check cannot prove here');
}
for (const [source, reference] of [
['record.name > 5', 'record.name'],
['previous.is_active > 0', 'previous.is_active'],
['name > 5 && record.title > 5', 'record.title'],
] as const) {
const r = validateExpression('predicate', source, flat);
expect(r.warnings, source).toEqual([]);
expect(r.errors.map((e) => [e.code, (e.params as { reference?: string }).reference]), source)
.toEqual([['type-mismatch-run-fails', reference]]);
}
// Controls: the key absent is the error as before; a bare name the run
// binds as a variable is still untyped; a `record`-scoped site ignores it.
expect(validateExpression('predicate', 'name > 5', { ...flat, bareNamesUnproven: undefined }).errors.map((e) => e.code))
.toEqual(['type-mismatch-run-fails']);
const shadowed = validateExpression('predicate', 'name > 5', { ...flat, flowVariables: ['name'] });
expect([shadowed.errors, shadowed.warnings]).toEqual([[], []]);
expect(validateExpression('predicate', 'record.name > 5', { ...schema, faultOutcome: 'fail-closed' as const, bareNamesUnproven: true }).errors.map((e) => e.code))
.toEqual(['type-mismatch-fail-closed']);
});
// [#29959] With no declared outcome a flattened condition is a flow's
// that is not proven to bind the field: a warning, but one saying the run
// fails on the fault, since a flow condition never reads null.
it('warns with the run-may-fail wording on a flattened condition with no declared outcome (#29959)', () => {
const flat = { ...schema, scope: 'flattened' as const };
for (const source of ['name > 5', 'record.name > 5']) {
const r = validateExpression('predicate', source, flat);
expect(r.errors, source).toHaveLength(0);
expect(r.warnings.map((w) => w.code), source).toEqual(['type-mismatch-run-may-fail']);
expect(r.warnings[0].message, source).toContain('fails the run');
expect(r.warnings[0].message, source).not.toMatch(/evaluates to null|still lands|is refused/);
}
// The record scope's warning keeps its own code and its true text there.
expect(validateExpression('predicate', 'record.name > 5', schema).warnings.map((w) => w.code)).toEqual(['type-mismatch']);
});
it('errors with the skipped-advisory wording where the caller declares fail-open (#29926)', () => {
const r = validateExpression('predicate', 'record.name > 5', { ...schema, faultOutcome: 'fail-open' });
expect(r.warnings).toHaveLength(0);
expect(r.errors.map((e) => e.code)).toEqual(['type-mismatch-fail-open']);
expect(r.errors[0].message).toContain('the write proceeds');
expect(r.errors[0].message).toContain('its advisory never fires');
expect(r.errors[0].message).not.toMatch(/evaluates to null|is refused/);
});
it('warns on a text field ordered against a number', () => {
const r = validateExpression('predicate', 'record.title >= 5', schema);
expect(r.ok).toBe(true);
expect(r.warnings).toHaveLength(1);
expect(r.warnings[0].message).toMatch(/record\.title/);
});
it('warns on a boolean field used in arithmetic (always faults at runtime)', () => {
const r = validateExpression('value', 'record.is_active + 1', schema);
expect(r.ok).toBe(true);
expect(r.warnings).toHaveLength(1);
expect(r.warnings[0].message).toMatch(/boolean/i);
expect(r.warnings[0].message).toMatch(/record\.is_active/);
});
it('does NOT warn on number/currency arithmetic with an int literal (#1930 runtime fix)', () => {
// currency → dyn, so `amount / 100`, `amount * 2 - 50` never fault.
expect(validateExpression('value', 'record.amount / 100', schema).warnings).toHaveLength(0);
expect(validateExpression('value', 'record.amount * 2 - 50', schema).warnings).toHaveLength(0);
});
it('does NOT warn on a date field with an ORDERING comparison (they hydrate at runtime)', () => {
// Ordering ops fault → the engine's string-hydration retry fires → they work.
// (Equality `==`/`!=` does NOT — that is the #3183 silent-miss, covered in its
// own block below; this tier-4 check leaves it to the #3183 guardrail.)
expect(validateExpression('predicate', 'record.due <= daysFromNow(30)', schema).warnings).toHaveLength(0);
expect(validateExpression('predicate', 'record.due >= today()', schema).warnings).toHaveLength(0);
});
it('does NOT warn on a select field ordered against a number (option values may be numeric codes)', () => {
// select → dyn, so `priority >= 3` (a numeric-coded picklist) is not flagged.
expect(validateExpression('predicate', 'record.priority >= 3', schema).warnings).toHaveLength(0);
});
it('does NOT warn on heterogeneous equality (runtime-safe, returns false)', () => {
expect(validateExpression('predicate', 'record.name == 5', schema).warnings).toHaveLength(0);
expect(validateExpression('predicate', 'record.name != 5', schema).warnings).toHaveLength(0);
});
it('does NOT warn on string concatenation or a null-guard', () => {
expect(validateExpression('value', 'record.name + record.title', schema).warnings).toHaveLength(0);
expect(validateExpression('predicate', 'record.amount != null && record.amount > 0', schema).warnings).toHaveLength(0);
});
it('does not run without field types', () => {
// No fieldTypes → nothing to check.
expect(validateExpression('value', 'record.name * 2', { objectName: 'crm_opportunity', fields: schema.fields, scope: 'record' }).warnings).toHaveLength(0);
});
});
// #1928 tier 4 (flattened) — the same soundness check for bare-field flow /
// automation conditions. Fields are bound bare (`status - 1`); flow variables
// stay `dyn` and are never flagged.
describe('type-soundness warnings — flattened flow conditions (#1928 tier 4)', () => {
const schema = {
objectName: 'crm_opportunity',
fields: ['stage', 'amount', 'is_active', 'title'] as const,
fieldTypes: { stage: 'select', amount: 'currency', is_active: 'boolean', title: 'text' },
scope: 'flattened',
} as const;
it('warns on a bare text field used in arithmetic against a number', () => {
const r = validateExpression('predicate', 'title - 1 > 0', schema);
expect(r.ok).toBe(true);
expect(r.warnings).toHaveLength(1);
expect(r.warnings[0].message).toMatch(/type mismatch/i);
// Bare form — not `record.title`.
expect(r.warnings[0].message).toMatch(/`title`/);
expect(r.warnings[0].message).not.toMatch(/record\.title/);
});
it('warns on a bare boolean field used in arithmetic', () => {
const r = validateExpression('predicate', 'is_active + 1 > 0', schema);
expect(r.ok).toBe(true);
expect(r.warnings).toHaveLength(1);
expect(r.warnings[0].message).toMatch(/boolean/i);
});
it('does NOT flag a flow variable (unlisted → dyn) or number/date fields', () => {
// `expiring_count` is not a schema field → dyn → no fault.
expect(validateExpression('predicate', 'expiring_count * 2 > 10', schema).warnings).toHaveLength(0);
expect(validateExpression('predicate', 'amount / 100 > 5', schema).warnings).toHaveLength(0);
});
it('does NOT flag a correct bare condition, equality, or a select comparison', () => {
expect(validateExpression('predicate', 'stage == "closed_won" && amount > 1000', schema).warnings).toHaveLength(0);
expect(validateExpression('predicate', 'title == "VIP"', schema).warnings).toHaveLength(0);
});
});