Repository navigation
Expand file tree
/
Copy pathvalidate.ts
More file actions
1626 lines (1581 loc) · 79.3 KB
/
Copy pathvalidate.ts
File metadata and controls
1626 lines (1581 loc) · 79.3 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
/**
* Shared expression validator (ADR-0032 §Decision 1/5).
*
* One validator, used by every author surface — `objectstack build`,
* `registerFlow`/metadata registration, and the agent-callable
* `validate_expression` tool — so a malformed expression is caught the same
* way everywhere, with a message written for **self-correction** (Decision 1d):
* it states what is wrong AND the correct form.
*
* Field roles map to dialects (Decision 2):
* - `predicate` → bare CEL returning bool (`record.rating >= 4`)
* - `value` → bare CEL of any type (`daysFromNow(3)`)
* - `template` → text with `{{ path }}` holes (`Hot lead: {{ record.name }}`)
*
* The #1 author error (human or LLM) is wrapping a field reference in single
* `{…}` braces inside a CEL field — `{x}` parses as a CEL map literal and fails.
* This validator detects that specific mistake and returns the exact fix.
*/
import {
compileCel,
firstUndeclaredReference,
firstDateTextVerdict,
firstTypeMismatch,
inferCelType,
parseCelToAst,
parseCelToAstWithReason,
type CelAstNode,
type FieldCelType,
} from './cel-engine';
import { templateEngine } from './template-engine';
import { findTraversalConflicts, readRootMembers, traversalsOf, type RootMemberRead } from './relationship-traversal';
import { REFERENCE_VALUE_TYPES } from '@objectstack/spec/data';
// #13594 — the one reader of cel-js's `found no matching overload for '…'`
// template. Both this module (which asks whether the name is ADVERTISED, to word
// a hint) and `firstUnknownFunctionCall` (which asks whether the environment
// REGISTERS it, to give the `@objectstack/lint` gate an existence verdict) must
// agree on WHICH token cel-js was talking about, so the extraction is shared and
// the pattern has one home.
import {
callNameFromNoOverload,
isReceiverRegistered,
receiverCallNameFromNoOverload,
} from './unknown-function';
import { DEFAULT_TRAVERSAL_ROOT, type TraversalConflictKind } from './relationship-traversal';
import { optionLabelFieldOf, optionlessLabelSentence } from './stdlib';
import type {
CelFieldRole,
ExprValidationCode,
ExpressionRefusal,
ExpressionRefusalParams,
ExpressionSourceKind,
} from './expression-refusal';
import type { CelLimitKey } from './cel-engine';
export type FieldRole = 'predicate' | 'value' | 'template';
/**
* Loose input accepted by the validator: a bare string, or any object exposing
* `dialect`/`source` (the Expression envelope, or a not-yet-narrowed value from
* a `config.condition` / `edge.condition` field). Kept structural so call sites
* need not pre-narrow to the strict {@link Expression} dialect union.
*/
export type ExprInput = string | { dialect?: string; source?: string } | null | undefined;
/** Optional schema context for field-existence checks (Decision 1b, v1). */
export interface ExprSchemaHint {
/** Object the expression is authored against (for error text). */
objectName?: string;
/**
* Known top-level field names, so each member read on `record` / `previous`
* can be checked — in every spelling that names a member: `record.f`,
* `record.?f`, `record['f']`, `record[?'f']`, and the same inside `has(…)`.
* A computed key (`record[someVar]`) names no member before evaluation and
* gets no verdict.
*/
fields?: readonly string[];
/**
* The object's read attachments (`ObjectSchema.attachedOnRead`) — block name
* → the leaf keys that block declares. A block is computed per caller and
* attached to each served row, never stored, so it is not a field; this map
* lets the field-existence pass judge the SECOND segment of
* `record.<block>.<leaf>` (and `previous.…`, in every member spelling): a
* leaf the block does not declare is refused under the same `unknown-field`
* code, and the refusal names the leaves the block does declare.
*
* It only ADDS the second-segment judgement. Whether `record.<block>`
* resolves at all is still {@link fields}' question, so a caller lists each
* block name there too (`@objectstack/lint`'s field index does). Absent or
* empty ⇒ only first segments are judged, exactly as before this key.
*/
attachedOnRead?: Readonly<Record<string, readonly string[]>>;
/**
* #1928 tier 4 — field name → spec field type (`'text'`, `'currency'`,
* `'boolean'`, `'date'`, …). Enables the advisory type-soundness check: a
* text or boolean field used with an arithmetic/ordering operator against a
* number faults at runtime and the expression silently evaluates to `null`,
* so it is surfaced as a NON-blocking warning. Absent ⇒ the check is skipped.
* Consulted at both scopes: `record.<field>` at a `'record'` site, the bare
* `<field>` and its `record.<field>` / `previous.<field>` read at a
* `'flattened'` one (#29959).
*/
fieldTypes?: Readonly<Record<string, string>>;
/**
* Evaluation scope of the authoring site — determines whether a bare top-level
* identifier is legal (#1928):
* - `'record'` → the record is bound only as the `record` namespace, with
* no field flattening (`Field.formula`, object validation
* predicates). A bare `amount` resolves to nothing and the
* expression silently evaluates to `null` / never fires, so
* it MUST be written `record.amount`. We flag bare refs.
* - `'flattened'` → the record's own fields are spread to top-level alongside
* flow variables (flow / automation conditions), so bare
* `status` is correct and is NOT an error. Flow variables
* are not schema-knowable, so a non-field bare identifier
* can't be soundly told apart from a typo — but when one is
* a near-miss of a known field we emit a non-blocking
* did-you-mean *warning*. (Default.)
*/
scope?: 'record' | 'flattened';
/**
* Binding roots THIS authoring surface mounts **beyond** the platform
* baseline (`SCOPE_ROOTS`) — the surface naming the roots it binds, so the
* validator keeps refusing every other one instead of standing down.
*
* ## What it is for
*
* `scope` answers "are the record's fields flattened here?". It cannot answer
* "what else is mounted here?", and some surfaces mount something else. A
* page component's `visibleWhen` is the worked case: `page.zod.ts`'s describe
* states its contract-bound roots as `record`, `current_user` **and page
* state as `page.<var>`**, and ends with the example `page.selectedProjectId
* != ''`. `page` is not in the platform baseline and has no business being
* there — a hook condition or a validation rule binds nothing of the sort —
* so before this key the two faces of `scope` were the only choices, and
* neither is correct for that surface:
*
* - `'record'` refuses `page.selectedProjectId != ''` — the spec's own
* worked example — and prescribes `record.page`, which
* names nothing on any layer;
* - `'flattened'` accepts it, and accepts a bare `status` with it, which is
* the narrowing the surface wanted in the first place.
*
* Declaring `roots: ['page']` keeps the narrowing and stops the false
* refusal: `page.selectedProjectId != ''` resolves, `status == 'done'` is
* still the hard error it should be.
*
* ## Direction — this key only ADDS, and that is deliberate
*
* A root listed here is declared alongside the baseline, never instead of it:
* passing `roots` can only turn a refusal into an acceptance, never the
* reverse, so a caller that adopts it cannot silently lose a check it has
* today. The opposite direction — a surface that binds a **closed** set and
* must refuse a baseline root (`os`, `vars`) it never mounts — is NOT this
* key's job and must not be bolted onto it: that surface says so with
* `collectCelRootIdentifiers`, which reads the AST and is independent of any
* declaration here. `SCOPE_ROOTS`'s own docblock is the authority on why
* those are two mechanisms rather than one.
*
* ## Shape
*
* Plain declarative data — a list of names the caller already knows, not a
* resolver callback the validator would have to invoke. `@objectstack/lint`'s
* view/page gate already supplies exactly such a list to
* {@link firstUndeclaredReference} (`VIEW_PAGE_EXTRA_ROOTS`); this key is the
* same vocabulary reaching the shared validator, so a surface can declare its
* roots without dropping out of `validateExpression` — and losing the compile
* check, the braces hint, field existence, the role catalog and the
* type-soundness pass with it.
*/
roots?: readonly string[];
/**
* ADR-0068 D4 — the closed catalog of valid role names (built-in + declared).
* When supplied, a role-membership predicate testing a role NOT in this set
* (e.g. `'org_admni' in current_user.positions`) is flagged as an error. Closes
* the AI-hallucination hole where a model invents a plausible-but-nonexistent
* role that then silently never matches. Absent => role checks are skipped.
*/
roleCatalog?: readonly string[];
/**
* [#18682] Opt IN to the relationship-traversal conflict checks.
*
* ⭐ Deliberately opt-in rather than derived from `fieldTypes`, because the
* prescription these checks hand out is only TRUE where something hydrates.
* `record.<reference field>.<field>` resolves the related record at exactly
* one seam — the object validation rules (`script` / `cross_field`) evaluated
* by ObjectQL's `checkPredicate` — and, since #24197, a formula field's
* `expression` (role `value`), whose read path ObjectQL preloads one hop for.
* Nothing hydrates at a field `requiredWhen` /
* `readonlyWhen`, an option `visibleWhen`, an action's `visible` / `disabled`,
* a sharing-rule (RLS) condition, a hook condition, or a flow node or edge
* condition. Telling an author at one of those sites to
* "write `record.account.id` instead" produces an expression that faults at
* evaluation — and on the fail-OPEN seams among them, a faulting predicate
* stops enforcing altogether. So the refusal would trade a working rule for a
* dead one, at the seams the traversal capability never covered.
*
* Honoured for the `predicate` and `value` roles only; on `value` the
* conflicts are worded for a formula (it reads null; there is no write to
* refuse). Only a formula field's `expression` is a `value` site the engine
* hydrates, so only that caller should set it on `value`.
*
* Set it only where the traversal is actually served. Absent ⇒ the checks do
* not run, which is every caller's behaviour until it opts in.
*/
traversalHydration?: boolean;
/**
* [#28659] The fields of {@link fields} that declare options once served —
* the fields `label(record.<field>)` can answer on: inline `options`, or a
* `picklist` the registry resolves onto the served field. Read an authored
* field through `declaresOptionsOnceServed` (`stdlib.ts`), whose served-form
* half is `declaresOptions`, the predicate the engine builds its label map
* with, so this list and the evaluator agree.
*
* When supplied, every `label(record.<field>)` call naming a field that IS
* declared ({@link fields}) but is not in this list is refused under
* `label-field-no-options`: the evaluator faults on that call on every row,
* so the formula reads null forever. A field {@link fields} does not declare
* keeps its `unknown-field` refusal and gets no second one.
*
* Opt-in, like {@link traversalHydration}: absent ⇒ the check does not run,
* because a caller that does not have the object's options in hand cannot
* tell an optionless field from one it was not told about. Needs
* {@link fields}; without it nothing is judged.
*/
optionFields?: readonly string[];
/**
* [#29904, #29926] What the site's runtime does when this expression FAULTS,
* as declared by the caller that knows the site — the validator cannot tell a
* validation rule's `predicate` from a flow condition's. The tokens are the
* null-guard verdict's `NullGuardOutcome` (`@objectstack/lint`), so one site
* names one outcome to both verdicts.
*
* `'fail-closed'`: an expression that cannot be evaluated refuses the write
* (or aborts the operation) it guards instead of being skipped — a
* `severity: 'error'` object validation rule, a field `requiredWhen` /
* `readonlyWhen`, an option `visibleWhen` on the write path, a hook
* `condition`.
*
* `'run-fails'`: a record-triggered flow's start, edge or `decision` branch condition — the
* engine's `evaluateCondition` throws on the fault (ADR-0032 §1c), so the
* run fails there, while the record-change trigger isolates the write that
* fired it: the write lands, the flow's work is not done.
*
* `'fail-open'`: an advisory (`severity: 'warning' | 'info'`) validation
* rule — a rule that cannot be evaluated is skipped and logged, the write
* proceeds, and the rule never gives its advisory (#24782).
*
* With any of the three, a `type-mismatch` (text or a boolean ordered /
* arithmetic'd against a number) is an ERROR under that outcome's own code
* and wording — the severity the nullable-comparison verdict already has at
* the same sites. Absent at a `record`-scoped site: a fault reads as null (a
* formula field), and `type-mismatch` stays the advisory warning whose
* "evaluates to null" text is true there. Absent at a `'flattened'` (flow)
* site: `type-mismatch-run-may-fail`, a warning saying the run fails where
* the condition faults (#29959) — a flow condition never reads null.
*/
faultOutcome?: ExprFaultOutcome;
/**
* [#29959] `'flattened'` scope only: the names the flow's run binds BEFORE it
* flattens the record's fields — its declared variables and node ids
* (`@objectstack/lint`'s `collectFlowVariableNames`), and the names the
* engine binds over the flattening. `seedRunVariables` seeds those first and
* the flattening skips a name already bound, so a bare read of one holds the
* variable, not the field: the type-soundness check does not type it as the
* field. `record.<name>` / `previous.<name>` still read the field and stay
* typed. Absent ⇒ every bare field name is typed, exactly as before this key.
*/
flowVariables?: readonly string[];
/**
* [#30025] `'flattened'` scope only: the flow's run can bind a name no static
* reader sees — a node that hands out the live variable map or folds a
* resume bag in (`script`, `connector_action`, `wait`, `subflow`, `map`, a
* screen with no input contract; `@objectstack/lint`'s `flowCelRootScope`
* over the flow's nodes, `provable: false`). A bare name
* is then not proven to hold the field, so a bare-name `type-mismatch` takes
* no declared {@link faultOutcome}: it is the `type-mismatch-run-may-fail`
* warning. A `record.<name>` / `previous.<name>` read still reads the field
* and keeps the outcome. Absent ⇒ every verdict is exactly as before this key.
*/
bareNamesUnproven?: boolean;
}
/**
* [#29926] What a site's runtime does once an expression there faults — see
* {@link ExprSchemaHint.faultOutcome}.
*/
export type ExprFaultOutcome = 'fail-closed' | 'run-fails' | 'fail-open';
/**
* One refusal (in `errors[]`) or advisory (in `warnings[]`).
*
* `code` and `params` say the same thing as `message`, for a consumer that
* renders its own words: `code` is one of the closed set in
* `expression-refusal.ts` and `params` are the values `message` interpolates.
* `message` is the English sentence, unchanged.
*/
export type ExprValidationError = {
/** Self-correcting message: what is wrong + the correct form. */
message: string;
/** The offending source, echoed for location. */
source: string;
} & ExpressionRefusal<ExprValidationCode>;
/**
* Build one {@link ExprValidationError}. The single place the correlation
* between `code` and `params` is asserted; every call site is checked against
* {@link ExpressionRefusalParams} for its own code.
*/
function refusal<Code extends ExprValidationCode>(
code: Code,
params: ExpressionRefusalParams[Code],
source: string,
message: string,
): ExprValidationError {
return { source, message, code, params } as ExprValidationError;
}
/** A traversal conflict's kind → the refusal code it is reported under. */
const TRAVERSAL_CONFLICT_CODE: Readonly<Record<TraversalConflictKind, 'traversal-bare-and-traversed' | 'traversal-multi-hop'>> = {
'bare-and-traversed': 'traversal-bare-and-traversed',
'multi-hop': 'traversal-multi-hop',
};
export interface ExprValidationResult {
ok: boolean;
errors: ExprValidationError[];
/**
* Non-blocking advisories (#1928 tier 3): a likely-typo'd field reference in a
* flattened flow condition. Never affects `ok` — callers surface these without
* failing the build, since a bare identifier there may legitimately be a flow
* variable.
*/
warnings: ExprValidationError[];
}
/**
* #1928 / #3306 — spec field type → the CEL type it is declared as for the
* type-soundness check. Pinned to a concrete type ONLY where a specific misuse
* always faults the runtime; numbers/currency/percent, selects (option values
* may be numeric codes), lookups, media, JSON stay `dyn` because the runtime
* rescues them. Any field type absent from this map is treated as `dyn`. Keeping
* the map narrow is the source of the check's near-zero false-positive rate.
*/
const SPEC_TYPE_TO_CEL: Readonly<Record<string, FieldCelType>> = {
// Free text — arithmetic / ordering against a number is (almost) always a bug.
text: 'string', textarea: 'string', email: 'string', url: 'string',
phone: 'string', markdown: 'string', html: 'string', richtext: 'string',
// Booleans — arithmetic / ordering against a number ALWAYS faults at runtime.
boolean: 'bool', toggle: 'bool',
// Dates — ARITHMETIC against a number always nulls (`date − date + 1`, `date + n`,
// #3306). Only arithmetic is flagged; ordering / equality / concatenation of a
// date field are runtime-tolerated (see `firstTypeMismatch`).
date: 'timestamp', datetime: 'timestamp',
};
/** Map an object's field-type hints onto the CEL types the soundness check uses. */
function toCelFieldTypes(fieldTypes: Readonly<Record<string, string>>): Record<string, FieldCelType> {
const out: Record<string, FieldCelType> = {};
for (const [name, specType] of Object.entries(fieldTypes)) {
out[name] = SPEC_TYPE_TO_CEL[specType] ?? 'dyn';
}
return out;
}
/**
* [#29926] The `type-mismatch` verdict at a site that declares its fault
* outcome ({@link ExprSchemaHint.faultOutcome}): the code each outcome is
* served under, and the clause that says what the runtime really does. Keyed
* by the outcome, so a new outcome without its own code and wording does not
* compile.
*/
const TYPE_MISMATCH_FAULT_ARMS: {
readonly [O in ExprFaultOutcome]: {
readonly code: 'type-mismatch-fail-closed' | 'type-mismatch-run-fails' | 'type-mismatch-fail-open';
readonly clause: string;
};
} = {
'fail-closed': {
code: 'type-mismatch-fail-closed',
clause:
'an expression that cannot be evaluated fails closed: the write (or the operation a hook guards) ' +
'is refused, not skipped.',
},
'run-fails': {
code: 'type-mismatch-run-fails',
clause:
'a flow condition that cannot be evaluated fails the run: the flow stops at this condition, the ' +
'write that fired it still lands, and the work the flow exists to do is not done.',
},
'fail-open': {
code: 'type-mismatch-fail-open',
clause:
'the rule is advisory, so a rule that cannot be evaluated is skipped, not refused: the write ' +
'proceeds, the rule is never applied (its advisory never fires), and the only trace is a log line ' +
'saying it could not be evaluated.',
},
};
/**
* #1928 / #3306 — a type-soundness verdict for an expression, or `null` when
* sound. Two categories, with different severities:
* - `date-arith` (**error**): arithmetic on a date field against a number
* (`date − date + 1`, `today() + 30`) — always nulls at runtime and never
* recovers, so it blocks the build.
* - `type-mismatch` (**warning**): a text/boolean field used with an
* arithmetic/ordering operator against a number — nulls unless the text value
* happens to be numeric, so it stays advisory. An **error** where the caller
* declares the site's fault outcome (`faultOutcome`, #29904 / #29926):
* there the fault is not a null but a refused write, a failed flow run or a
* rule that never applies, each under its own code
* ({@link TYPE_MISMATCH_FAULT_ARMS}). In a flattened (flow) condition with
* no declared outcome it is `type-mismatch-run-may-fail`, still a warning,
* worded for the failed run a fault there is (#29959); a flattened
* condition's `record.<field>` / `previous.<field>` read is judged as its
* bare name is.
* - `date-text-arithmetic` / `date-duration-value` (**error**, #28944): what
* the Timestamp typing passes, re-checked with a date field declared as the
* text the runtime holds — see {@link dateTextIssue}.
* `scope` selects `record.<field>` vs bare field binding, shaping the message.
*/
function typeSoundnessIssue(
source: string,
fieldTypes: Readonly<Record<string, string>>,
scope: 'record' | 'flattened',
role: 'predicate' | 'value',
faultOutcome: ExprSchemaHint['faultOutcome'],
flowVariables?: readonly string[],
bareNamesUnproven?: boolean,
): { issue: ExprValidationError; severity: 'error' | 'warning' } | null {
const celFieldTypes = toCelFieldTypes(fieldTypes);
// [#29959] A bare name a flow variable holds is not the field (see
// `ExprSchemaHint.flowVariables`), for every bare-name verdict here.
const shadowed = scope === 'flattened' && flowVariables && flowVariables.length > 0
? new Set(flowVariables)
: undefined;
let mismatch = firstTypeMismatch(source, celFieldTypes, scope, shadowed);
// [#30025] Where the run can bind a bare name no reader sees
// (`ExprSchemaHint.bareNamesUnproven`), a bare-name mismatch is not proven to
// be the field's, so it takes no declared outcome. A `record.<field>` /
// `previous.<field>` mismatch in the same source still does: with every bare
// name left `dyn`, only the record-root pass can answer, and its answer wins.
let outcome = faultOutcome;
if (
mismatch && scope === 'flattened' && bareNamesUnproven && faultOutcome !== undefined
&& mismatch.category === 'type-mismatch' && mismatch.root === undefined
) {
const rootRead = firstTypeMismatch(source, celFieldTypes, scope, new Set(Object.keys(celFieldTypes)));
if (rootRead && rootRead.root !== undefined) mismatch = rootRead;
else outcome = undefined;
}
if (!mismatch) {
const bareCelTypes = shadowed
? Object.fromEntries(Object.entries(celFieldTypes).filter(([name]) => !shadowed.has(name)))
: celFieldTypes;
return dateTextIssue(source, bareCelTypes, scope, role);
}
const reference = mismatch.field
? (mismatch.root !== undefined
? `${mismatch.root}.${mismatch.field}`
: scope === 'record' ? `record.${mismatch.field}` : mismatch.field)
: undefined;
const ref = reference !== undefined ? `\`${reference}\`` : null;
if (mismatch.category === 'date-arith') {
const subject = ref ? `${ref} is a date` : 'a date field';
return {
severity: 'error',
issue: refusal(
'date-arithmetic',
{ operands: mismatch.operands, ...(reference !== undefined ? { reference } : {}) },
source,
`date arithmetic \`${mismatch.operands}\` — ${subject}, and CEL can't do arithmetic on ` +
`dates: this faults at runtime, so the field silently evaluates to null. Use ` +
`\`daysBetween(a, b)\` for the span in whole days, and \`daysFromNow(n)\` / ` +
`\`addDays(d, n)\` / \`addMonths(d, n)\` to shift a date.`,
),
};
}
const heldKind: 'boolean' | 'text' = mismatch.celType === 'bool' ? 'boolean' : 'text';
const held = heldKind === 'boolean' ? 'a boolean' : 'text';
const subject = ref ? `${ref} holds ${held}` : `${held === 'a boolean' ? 'a boolean' : 'a text'} field`;
const params = {
operands: mismatch.operands,
operator: mismatch.operator,
held: heldKind,
...(reference !== undefined ? { reference } : {}),
};
// [#29904] Measured on the evaluator: `record.name > 5` faults on `"abc"`
// and on null, and the ADR-0032 §1c rescue answers only numeric text
// (`"7"`). `size()` answers text; `double()` / `int()` validate clean but
// fault on text that is not a number — so the hint says so.
const operand = reference ?? (heldKind === 'boolean' ? 'the field' : 'the text');
const convert = heldKind === 'boolean'
? 'Compare a boolean with `true` / `false`, or use a number field.'
: `Compare like with like: use a number field, compare text with text, or convert ` +
`explicitly — \`size(${operand})\` for its length, \`double(${operand})\` for its value ` +
`(which itself faults on text that is not a number).`;
const faults =
`type mismatch \`${mismatch.operands}\` — ${subject} but is used with \`${mismatch.operator}\` ` +
`against a number. CEL has no such operation, so wherever the value is not numeric text ` +
`(an empty value included) the expression faults`;
if (outcome !== undefined) {
// [#29926] One code per outcome: a code names ONE message template
// (`expression-refusal.ts`), and the three outcomes are three sentences.
const arm = TYPE_MISMATCH_FAULT_ARMS[outcome];
return {
severity: 'error',
issue: refusal(arm.code, params, source, `${faults}, and here ${arm.clause} ${convert}`),
};
}
if (scope === 'flattened') {
// [#29959] A flattened condition is a flow's, and the engine's
// `evaluateCondition` throws on a fault (ADR-0032 §1c) on every flow: it
// never reads null. The caller declared no outcome because what the run
// binds under the name is not proven to be the field's value there — a
// flow whose entrance hands over an API request body, an action's partial
// record, a parent's record or nothing; a node's inert `condition` — so
// the mismatch stays a warning, worded for the run without naming the
// flow's kind (the engine's registration log serves it on every flow).
return {
severity: 'warning',
issue: refusal(
'type-mismatch-run-may-fail',
params,
source,
`${faults}, and a flow condition that faults when it is evaluated fails the run: the flow stops ` +
`at this condition and the work it exists to do is not done (it does not read null). Whether ` +
`${ref ?? 'the field'} holds ${held} there depends on what the run binds, which this check cannot ` +
`prove here, so this is a warning, not a refusal. ${convert}`,
),
};
}
return {
severity: 'warning',
issue: refusal(
'type-mismatch',
{
operands: mismatch.operands,
operator: mismatch.operator,
held: heldKind,
...(reference !== undefined ? { reference } : {}),
},
source,
`type mismatch \`${mismatch.operands}\` — ${subject} but is used with \`${mismatch.operator}\` ` +
`against a number. This faults at runtime, so the expression silently evaluates to null ` +
`(unless the value happens to be numeric). Use a number field, or drop the arithmetic/comparison.`,
),
};
}
/**
* #28944 — the date shapes the Timestamp typing of {@link firstTypeMismatch}
* passes and the runtime cannot answer, judged by {@link firstDateTextVerdict}
* with every `date`/`datetime` field declared as the text the runtime holds:
* - `date-text-arithmetic`: arithmetic on that text the evaluator faults on —
* `record.end_date - record.start_date` (either field kind), `record.d -
* duration('24h')` — so the expression reads null. Any role.
* - `date-duration-value`: a formula (`value` role) that computes a
* `google.protobuf.Duration` — `today() - record.start_date` — which a field
* serialises as `{}`. A predicate that COMPARES a duration
* (`today() - record.d > duration('72h')`) evaluates, and is not touched.
* Runs only when there is something to find: a date field to retype, or a
* `value` whose computed type is the question.
*/
function dateTextIssue(
source: string,
celFieldTypes: Readonly<Record<string, FieldCelType>>,
scope: 'record' | 'flattened',
role: 'predicate' | 'value',
): { issue: ExprValidationError; severity: 'error' | 'warning' } | null {
if (role !== 'value' && !Object.values(celFieldTypes).includes('timestamp')) return null;
const verdict = firstDateTextVerdict(source, celFieldTypes, scope);
if (!verdict) return null;
const reference = verdict.field
? (scope === 'record' ? `record.${verdict.field}` : verdict.field)
: undefined;
const ref = reference !== undefined ? `\`${reference}\`` : null;
const shift = `\`daysFromNow(n)\` / \`addDays(d, n)\` / \`addMonths(d, n)\` to shift a date.`;
if (verdict.kind === 'fault') {
const subject = ref ? `${ref} is a date field` : 'a date field is an operand';
return {
severity: 'error',
issue: refusal(
'date-text-arithmetic',
{ operands: verdict.operands, ...(reference !== undefined ? { reference } : {}) },
source,
`date arithmetic \`${verdict.operands}\` — ${subject}, and the runtime holds a date field as ` +
`its text (\`YYYY-MM-DD\`, or an ISO-8601 instant for a date-time), which CEL can't do ` +
`arithmetic on: this faults at runtime, so the field silently evaluates to null. Use ` +
`\`daysBetween(a, b)\` for the span in whole days, and ${shift}`,
),
};
}
if (role !== 'value' || verdict.type !== 'google.protobuf.Duration') return null;
return {
severity: 'error',
issue: refusal(
'date-duration-value',
reference !== undefined ? { reference } : {},
source,
`date arithmetic — this computes a \`google.protobuf.Duration\`${ref ? ` from ${ref}` : ''}, and a ` +
`field can't hold a duration: it reads \`{}\` at runtime. Use \`daysBetween(a, b)\` for the ` +
`span in whole days, and ${shift}`,
),
};
}
/** A bare `{x}` that is NOT part of a `{{x}}` mustache hole. */
const SINGLE_BRACE_RE = /(?:^|[^{])\{\s*([A-Za-z_$][\w.$]*)\s*\}(?!\})/;
/** The roots whose members the field-existence pass judges against `schema.fields`. */
const FIELD_EXISTENCE_ROOTS = ['record', 'previous'] as const;
/** The dialect a field role expects (Decision 2). */
export function expectedDialect(role: FieldRole): 'cel' | 'template' {
return role === 'template' ? 'template' : 'cel';
}
/** `array` / `object` / `number` — never called with a string or nullish. */
function nonStringSourceKind(value: unknown): ExpressionSourceKind {
if (Array.isArray(value)) return 'array';
return typeof value as ExpressionSourceKind;
}
/** `an array` / `an object` / `a number` — the kind as the message renders it. */
function describeNonStringSource(kind: ExpressionSourceKind): string {
if (kind === 'array') return 'an array';
if (kind === 'object') return 'an object';
return `a ${kind}`;
}
/**
* Normalize the three authorable spellings — bare text, an envelope, absent —
* into the dialect and the source text the checks below read.
*
* `nonStringSource` is the fourth population, which is not authorable at all: an
* envelope whose `source` is present but is NOT a string. `ExprInput` declares
* `source?: string`, so this cannot arrive through a typed call — but every
* production call site casts, because the value comes out of METADATA, where the
* declaration is a claim and not a guarantee. Read unguarded it reached
* `source.trim()` and threw a bare `TypeError: source.trim is not a function`
* out of a validator whose whole contract is that it never throws: callers that
* exist to collect LOCATED findings (`AutomationEngine.validateFlowExpressions`,
* `@objectstack/lint`'s stack walk) were bypassed entirely, so `registerFlow`
* died on an internal message naming neither the flow nor the node, and
* `objectstack validate` died naming neither the hook nor the sharing rule.
*
* The refusal is reported HERE, once, at the entry every predicate/value slot in
* the platform shares — never by each caller wrapping the call in a try/catch,
* which is the tolerant-consumer shape Prime Directive #12 forbids.
*
* ⚠️ Only a PRESENT non-string qualifies. `null` / `undefined` / a missing
* `source` still normalize to `''` and still read as "not authored" (`ok: true`),
* unchanged — including the `{ ast }` envelope, which carries no `source` at all
* and whose admission is `ExpressionSchema`'s rule, not this function's.
*/
function toSource(input: ExprInput): { dialect?: string; source: string; nonStringSource?: ExpressionSourceKind } {
if (input == null) return { source: '' };
if (typeof input === 'string') return { source: input };
const raw = (input as { source?: unknown }).source;
if (raw == null) return { dialect: input.dialect, source: '' };
if (typeof raw !== 'string') {
return { dialect: input.dialect, source: '', nonStringSource: nonStringSourceKind(raw) };
}
return { dialect: input.dialect, source: raw };
}
/**
* The refusal text for a non-string envelope `source`, shared by the message
* every role composes. Deliberately NOT exported: the published surface of
* `@objectstack/formula` does not move for this fix — the refusal travels the
* `errors[]` channel that already exists, so no consumer needs a new symbol to
* read it.
*/
const NON_STRING_SOURCE_REFUSAL =
'an expression envelope carries its expression as a string `source`';
function bracesHint(source: string): { text: string; ref: string } | null {
const m = SINGLE_BRACE_RE.exec(source);
if (!m) return null;
const ref = m[1];
return {
ref,
text:
`it looks like a \`{${ref}}\` template brace was used inside a CEL expression — ` +
`\`{…}\` parses as a CEL map literal and fails. Write the bare reference instead, e.g. \`${ref}\`.`,
};
}
/**
* The prescription for a **bounds** refusal — an expression that is perfectly
* good CEL and merely too big for the platform's parse budget (#7073).
*
* Until #7073 every `celEngine.compile` refusal got the same dialect trailer
* ("`predicate`s are bare CEL (e.g. `record.rating >= 4`)"), byte for byte,
* including this class. That sentence is actively wrong here: the source is
* already bare CEL, so an author who obeys the last sentence they were given —
* an LLM author above all — rewrites the dialect, learns nothing, and comes
* back with the same 80-clause conjunction. The front half of the message
* (cel-js's own `Exceeded maxAstNodes (256)`) was right all along; only the
* prescription lied.
*
* The class comes from `celEngine.compile`'s own `kind: 'bounds'`; WHICH bound
* and its value come from {@link parseCelToAstWithReason}, the same
* reason-carrying entrance `@objectstack/lint`'s RLS gate reads (#6778 /
* PR #6831 — the consumer-side instance of this same defect family). Called
* WITHOUT `admitOverLimit`, so it takes neither the unbounded parse nor the
* overrun measurement: this path needs only the bound's NAME, and a refusal
* must not pay to re-parse a source it has just judged too large.
*
* ### Why the remedies are generic
*
* `validateExpression` is ADR-0032's shared validator: one message serves all
* ~10 expression slots (flow/automation conditions, `Field.formula`, validation
* rules, `visibleWhen`, `sharingRules[].condition`, the `validate_expression`
* tool …). Their COMBINATION semantics differ, so PR #6831's RLS-specific
* sentence ("splitting the top-level `&&` widens the grant") is not portable —
* it is true for a security predicate and false, or merely meaningless, for a
* formula value. Shrinking and denormalising are safe everywhere; splitting is
* offered only with the caveat that the site decides what splitting means.
*/
function boundsHint(source: string): { text: string; limit?: CelLimitKey; limitValue?: number } | null {
const parsed = parseCelToAstWithReason(source);
// `celEngine.compile` said `bounds`, and both verdicts are graded by the same
// `classifyCelFault`, so this holds — but a narrowing that ever stopped
// holding must degrade to the old trailer, never to a wrong bound name.
if (parsed.ok || parsed.kind !== 'bounds') return null;
const { limit, limitValue } = parsed.overrun;
const known = limit && limitValue != null ? { limit, limitValue } : null;
const bound = known
? `the \`${known.limit}\` budget (limit ${known.limitValue})`
: "one of the platform's parse budgets";
return {
...(known ?? {}),
text:
`this is valid CEL that exceeds ${bound} — a SIZE fault, not a dialect mistake, so ` +
`re-spelling the expression will not fix it. Shrink it (fewer clauses, shallower nesting, ` +
`fewer list elements), or precompute the heavy part into a stored field and reference that ` +
`field instead. Splitting it into several expressions changes how they combine at this ` +
`authoring site, so check that site's semantics before doing that.`,
};
}
/**
* The nearest advertised callable to `name`, or `undefined` when nothing is
* close enough that a suggestion beats silence.
*
* Deliberately STRICTER than {@link nearestName}'s shared budget, and that
* difference is the entire reason this function exists rather than a call to
* the shared one. `nearest` spends `max(2, floor(name.length / 3))` edits —
* right for a field name checked against the handful of fields on one object,
* measurably wrong against this catalog: `nearestName('can', CEL_STDLIB_FUNCTIONS)`
* answers `'min'`. Two edits on a three-character name, jumping from a
* permission verb to a numeric function — a confident suggestion across an
* unrelated namespace, which is worse than silence. An author who takes it (an
* LLM author above all, following the last sentence it was handed) writes
* `min(object, verb)` and is further from working than before it asked.
*
* The budget here is proportional rather than floored: at most one edit per
* three characters of the LONGER name, so at least two thirds of a suggestion
* must already be typed. That keeps the case that makes suggesting worthwhile
* (`isBlnk` → `isBlank`, one edit in seven) and refuses the measured hazard
* (`can` → `min`, two edits in three). Both are pinned in `validate.test.ts`;
* a change to this budget that loses either is a regression, not a tuning.
*
* The distance metric stays the module's one {@link levenshtein} — only the
* acceptance budget is class-specific, which is what the hazard is about.
*/
function nearestCallable(name: string): string | undefined {
let best: string | undefined;
let bestDistance = Infinity;
for (const candidate of CEL_STDLIB_FUNCTIONS) {
const distance = levenshtein(name, candidate);
if (distance > Math.floor(Math.max(name.length, candidate.length) / 3)) continue;
if (distance >= bestDistance) continue;
bestDistance = distance;
best = candidate;
}
return best;
}
/**
* The prescription for the **unknown-name** arm of a `type` refusal — an
* expression that is perfectly good CEL and merely calls something by a name
* that resolves to nothing in this position.
*
* The second leg of the repair {@link boundsHint} made for the `bounds` class
* (#7073). Until this hint, every unknown-function refusal ended with the same
* dialect trailer ("`predicate`s are bare CEL (e.g. `record.rating >= 4`)"),
* and that sentence is actively wrong here for exactly the reason it was wrong
* for `bounds`: the source already IS bare CEL and parses fine. An author who
* obeys the last sentence they were given rewrites the dialect, learns nothing,
* and comes back with the same unresolvable name. The front half (cel-js's own
* `found no matching overload for '…'`) was right all along and is kept
* verbatim; only the prescription lied.
*
* ### Why the name must be checked against the advertised catalog first
*
* cel-js emits ONE message shape for two different faults: a name that resolves
* to nothing (`upperr(record.name)`) and a real function handed arguments no
* overload accepts (`upper(1, 2)` → `found no matching overload for
* 'upper(int, int)'`). Telling the second author that `upper` "is not a
* callable name" would be a fresh false statement in place of a merely useless
* one, so an advertised name falls through to the existing trailer untouched.
*
* ### Why the wording is "not a callable name HERE"
*
* {@link CEL_STDLIB_FUNCTIONS} is a curated bare-callable subset, not an oracle
* for existence — 33 further names cel-js registers are callable only on a
* receiver (`record.name.split(',')` works; bare `split(…)` faults here and
* lands in this arm). So the message may say the name cannot be called in this
* position, and may point at what IS advertised, but must not claim the name
* does not exist. For the same reason it names no size: what the catalog
* contains is being adjudicated separately, and a message asserting a count
* would be falsified by that ruling.
*/
function unknownFunctionHint(celMessage: string): { text: string; name: string; suggestion?: string } | null {
const name = callNameFromNoOverload(celMessage);
if (!name || CEL_STDLIB_FUNCTIONS.includes(name)) return null;
const suggestion = nearestCallable(name);
return {
name,
...(suggestion ? { suggestion } : {}),
text:
`\`${name}\` is not a callable name here — a NAME fault, not a dialect mistake, so ` +
`re-spelling the expression will not fix it.` +
(suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
` The callable names this platform advertises for authoring are the \`functions\` list ` +
`\`introspectScope\` returns (\`CEL_STDLIB_FUNCTIONS\`) — pick one of those, or precompute ` +
`the value in a stored field and reference that field instead.`,
};
}
/**
* A plain dotted identifier chain written immediately left of `.name(` in
* `source`, or `undefined` when the receiver is anything else.
*
* cel-js's message names the receiver's TYPE (`dyn.upper()`), never the
* author's expression, so the only place the real receiver exists is the source
* — and only the plain-chain shape can be lifted out of it safely. Anything
* else (`record.tags[0].upper()`, `(a + b).upper()`, `'lit'.upper()`) returns
* `undefined` so the caller prints a shape instead of a spelling: a
* prescription that cannot be DERIVED must not be invented, or the repair
* repeats the defect it fixes one level up.
*
* `name` reaches here having matched `[A-Za-z_$][\w$]*` AND been found in
* {@link CEL_STDLIB_FUNCTIONS}, so it carries no regex metacharacter.
*
* Deliberately whitespace-intolerant around the dots: `record . name . upper()`
* is legal CEL that nobody writes, and admitting it costs a quantifier that
* backtracks (the ReDoS concern the role scanners below document). It falls
* through to the generic sentence, which is correct, just less specific.
*/
function receiverChainInSource(source: string, name: string): string | undefined {
const call = new RegExp(`[.]${name}\\s*\\(`).exec(source);
if (!call) return undefined;
const before = source.slice(0, call.index);
const chain = /[A-Za-z_$][\w$]*(?:[.][A-Za-z_$][\w$]*)*$/.exec(before);
if (!chain) return undefined;
// The chain must START the receiver. A character that continues an expression
// leftwards means the real receiver is larger than what matched — e.g.
// `record.tags[0].name.upper()`, where the chain is `name` and the receiver
// is not.
if (chain.index > 0 && /[\w$.)\]'"]/.test(before[chain.index - 1])) return undefined;
return chain[0];
}
/**
* The prescription for the **receiver-call** arm of a `type` refusal — a
* bare-callable stdlib function written as a method, `record.name.upper()`
* (#14203).
*
* The third leg of the repair {@link boundsHint} (#7073) and
* {@link unknownFunctionHint} (#13821) made for their own classes, and the one
* neither could cover: the name is right, so the unknown-name arm stays silent
* by design (calling `upper` "not a callable name" would be a fresh false
* statement), and the fault is not size, so the bounds arm never sees it. What
* was left was the dialect trailer — "`predicate`s are bare CEL" — handed to an
* author whose source already IS bare CEL and parses fine. Advice that cannot
* succeed, for the third time in the same shape.
*
* It is a high-frequency AI-author mistake rather than an exotic one: method
* call syntax is what almost every other language uses for string operations,
* so a generator that knows `upper` exists reaches for `record.name.upper()`
* before `upper(record.name)`. And the correct spelling is derivable from the
* fault itself, which is what makes a prescription honest here.
*
* ### The two keys, and why one of them is not enough
*
* **Membership of {@link CEL_STDLIB_FUNCTIONS}**, never the call shape alone.
* The 33 receiver-only names cel-js registers (`split`, `map`,
* `getFullYear`) are correct ONLY after a dot — `record.name.split(',')`
* type-checks and never reaches here at all, and telling anyone to write
* `split(record.name, ',')` would break working authoring.
*
* **Plus the environment's own record of the receiver form.** Seven advertised
* names hold definitions of BOTH kinds — `contains`, `endsWith`, `matches`,
* `size`, `startsWith`, `string`, `trim` — so catalog membership by itself
* would answer `record.name.contains()` (a real receiver call with the wrong
* arity) with "write `contains(record.name)`", which faults just as hard. That
* class keeps the existing trailer: its fault is the ARGUMENTS, and grading
* those is the blind spot #13594 deliberately keeps blind.
*/
function receiverCallHint(celMessage: string, source: string): { text: string; name: string; receiver?: string } | null {
const name = receiverCallNameFromNoOverload(celMessage);
if (!name || !CEL_STDLIB_FUNCTIONS.includes(name)) return null;
if (isReceiverRegistered(name)) return null;
const receiver = receiverChainInSource(source, name);
return {
name,
...(receiver ? { receiver } : {}),
text:
`\`${name}\` is callable bare, not as a method — a CALL-SHAPE fault, not a dialect ` +
`mistake, so re-spelling the expression will not fix it. ` +
(receiver
? `Write \`${name}(${receiver})\` instead.`
: `Write \`${name}(…)\` with the receiver as its first argument instead.`) +
` The callable names this platform advertises for authoring (the \`functions\` list ` +
`\`introspectScope\` returns, \`CEL_STDLIB_FUNCTIONS\`) take their subject as an ` +
`argument; only cel-js's own receiver methods (\`record.name.split(',')\`) are written ` +
`after a dot.`,
};
}
/**
* [#28659] Every `label(record.<field>)` call in `source` whose field is
* declared but declares no options — one `label-field-no-options` refusal per
* such field, worded as the evaluator's fault on the same call
* (`optionlessLabelSentence`), so the door says at author time what the log
* line would say on every row.
*
* The field a call names is read through `optionLabelFieldOf`, the reader the
* `label` macro itself uses, so a call shape the macro refuses at the type
* check (a computed argument, `record.?kind`) names no field here and is left
* to that refusal. Runs only when the caller supplied
* {@link ExprSchemaHint.optionFields} and {@link ExprSchemaHint.fields}; a
* field outside `fields` is `checkFieldExistence`'s to refuse.
*/
function checkOptionLabelTargets(
ast: () => CelAstNode | null,
source: string,
schema: ExprSchemaHint | undefined,
errors: ExprValidationError[],
): void {
if (!schema?.optionFields || !schema.fields || schema.fields.length === 0) return;
// A source that never spells the call cannot hold one (CEL has no comments),
// so the common formula pays one scan and no tree walk.
if (!/\blabel\s*\(/.test(source)) return;
const tree = ast();
if (tree == null) return;
const declared = new Set(schema.fields);
const withOptions = new Set(schema.optionFields);
const seen = new Set<string>();
const visited = new Set<object>();
const walk = (node: unknown): void => {
if (!node || typeof node !== 'object' || visited.has(node)) return;
visited.add(node);
if (Array.isArray(node)) {
for (const child of node) walk(child);
return;
}
const { op, args } = node as { op?: unknown; args?: unknown };
if (op === 'call' && Array.isArray(args) && args[0] === 'label' && Array.isArray(args[1]) && args[1].length === 1) {
const field = optionLabelFieldOf(args[1][0] as { op: string; args: unknown } | undefined);
if (field !== undefined && declared.has(field) && !withOptions.has(field) && !seen.has(field)) {
seen.add(field);
errors.push(refusal('label-field-no-options', { field }, source, optionlessLabelSentence(field)));
}
}
for (const value of Object.values(node as Record<string, unknown>)) walk(value);
};
walk(tree);
}
/**
* Every member `source` reads on `record` / `previous`, judged against
* `schema.fields` — one `unknown-field` refusal per undeclared name.
*
* The members come from `readRootMembers`, the AST member reader the
* relationship-traversal analysis is built on, so every spelling that names a
* member is judged once and the same way: `record.f`, `record.?f`,
* `record['f']`, `record[?'f']`, and any of them inside `has(…)`. A reader that
* saw only the dot spelling let a typo written any other way reach the
* evaluator with no authoring verdict, to fault there on every row instead.
* A computed key (`record[someVar]`) names no member before evaluation, so it
* gets no verdict here.
*
* `ast` is null only for a source the canonical front end will not parse; the
* caller reaches this after `celEngine.compile` accepted the source, which owns
* that verdict, so there is nothing to judge.
*/
function checkFieldExistence(
ast: () => CelAstNode | null,
source: string,
schema: ExprSchemaHint | undefined,
errors: ExprValidationError[],
): void {
if (!schema?.fields || schema.fields.length === 0) return;
const tree = ast();
if (tree == null) return;
const known = new Set(schema.fields);