-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathvalidate.ts
More file actions
490 lines (462 loc) · 21.6 KB
/
Copy pathvalidate.ts
File metadata and controls
490 lines (462 loc) · 21.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
/**
* 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 { celEngine, firstUndeclaredReference, firstTypeMismatch, inferCelType, temporalEqualityFields, type FieldCelType } from './cel-engine';
import { templateEngine } from './template-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 `record.<field>` can be checked. */
fields?: 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.
* Only consulted for `scope: 'record'` sites (where refs are `record.<field>`).
*/
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';
/**
* 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[];
}
export interface ExprValidationError {
/** Self-correcting message: what is wrong + the correct form. */
message: string;
/** The offending source, echoed for location. */
source: string;
}
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 tier 4 — spec field type → the CEL type it is declared as for the
* type-soundness check. ONLY genuinely-scalar, non-numeric-intent types are
* pinned to a concrete type (`string` / `bool`); every other type — numbers,
* dates, selects (option values may be numeric codes), lookups, media, JSON —
* maps to `dyn` so it can never fault (the runtime rescues all of those). 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',
};
/** 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;
}
/**
* #1928 tier 4 — a NON-blocking warning for a text/boolean field used with an
* arithmetic/ordering operator against a number (a silent-null bug), or `null`
* when the expression is type-sound. `scope` selects `record.<field>` vs bare
* field binding, and shapes the referenced form in the message.
*/
function typeSoundnessWarning(
source: string,
fieldTypes: Readonly<Record<string, string>>,
scope: 'record' | 'flattened',
): ExprValidationError | null {
const mismatch = firstTypeMismatch(source, toCelFieldTypes(fieldTypes), scope);
if (!mismatch) return null;
const held = mismatch.celType === 'bool' ? 'a boolean' : 'text';
const ref = mismatch.field
? (scope === 'record' ? `\`record.${mismatch.field}\`` : `\`${mismatch.field}\``)
: null;
const subject = ref ? `${ref} holds ${held}` : `${held === 'a boolean' ? 'a boolean' : 'a text'} field`;
return {
source,
message:
`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.`,
};
}
/**
* #3183 — flag `==`/`!=` between a calendar-day (`date`) field and a temporal
* function (`today()`/`daysFromNow()`/`daysAgo()`/`now()`). A `Field.date` reads
* back as a `YYYY-MM-DD` string (ADR-0053 Phase 1), and cel-js's equality
* (`overloads.js` `isEqual`) treats a string and a timestamp as unequal without
* consulting any overload, so the comparison silently never matches. Advisory
* `warning` only — on the write/validation path the value may be a real `Date` —
* and no-op unless `schema.fieldTypes` marks the referenced field `date`. Derives
* the date fields from the shared `fieldTypes` hint (no separate plumbing).
*/
function checkTemporalDateEquality(
source: string,
schema: ExprSchemaHint | undefined,
warnings: ExprValidationError[],
): void {
const fieldTypes = schema?.fieldTypes;
if (!fieldTypes) return;
// AST-based (no regex on the raw source → no ReDoS); filter to `date` fields.
for (const field of temporalEqualityFields(source)) {
if (fieldTypes[field] !== 'date') continue;
warnings.push({
source,
message:
`\`${field}\` is a calendar-day (date) field, stored as a "YYYY-MM-DD" string, so comparing it to a ` +
`timestamp function with \`==\`/\`!=\` silently never matches (CEL treats a string and a timestamp as ` +
`unequal). Wrap the field to coerce it — \`date(record.${field}) == today()\` — or use a range ` +
`(\`record.${field} >= today() && record.${field} <= today()\`) or \`daysBetween(today(), record.${field}) == 0\`.`,
});
}
}
/** A bare `{x}` that is NOT part of a `{{x}}` mustache hole. */
const SINGLE_BRACE_RE = /(?:^|[^{])\{\s*([A-Za-z_$][\w.$]*)\s*\}(?!\})/;
/** `record.<field>` / `previous.<field>` head references for field-existence. */
const RECORD_REF_RE = /\b(?:record|previous)\.([A-Za-z_$][\w$]*)/g;
/** The dialect a field role expects (Decision 2). */
export function expectedDialect(role: FieldRole): 'cel' | 'template' {
return role === 'template' ? 'template' : 'cel';
}
function toSource(input: ExprInput): { dialect?: string; source: string } {
if (input == null) return { source: '' };
if (typeof input === 'string') return { source: input };
return { dialect: input.dialect, source: input.source ?? '' };
}
function bracesHint(source: string): string | null {
const m = SINGLE_BRACE_RE.exec(source);
if (!m) return null;
const ref = m[1];
return (
`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}\`.`
);
}
function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void {
if (!schema?.fields || schema.fields.length === 0) return;
const known = new Set(schema.fields);
const seen = new Set<string>();
let m: RegExpExecArray | null;
RECORD_REF_RE.lastIndex = 0;
while ((m = RECORD_REF_RE.exec(source)) !== null) {
const field = m[1];
if (seen.has(field) || known.has(field)) continue;
seen.add(field);
const suggestion = nearest(field, schema.fields);
errors.push({
source,
message:
`unknown field \`${field}\`${schema.objectName ? ` on \`${schema.objectName}\`` : ''}` +
(suggestion ? ` — did you mean \`${suggestion}\`?` : ''),
});
}
}
/** Cheap edit-distance suggestion for typo'd field names. */
function nearest(name: string, candidates: readonly string[]): string | undefined {
let best: string | undefined;
let bestD = Infinity;
for (const c of candidates) {
const d = levenshtein(name, c);
if (d < bestD) { bestD = d; best = c; }
}
return bestD <= Math.max(2, Math.floor(name.length / 3)) ? best : undefined;
}
function levenshtein(a: string, b: string): number {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, (_, i) => i);
for (let j = 1; j <= n; j++) {
let prev = dp[0];
dp[0] = j;
for (let i = 1; i <= m; i++) {
const tmp = dp[i];
dp[i] = Math.min(dp[i] + 1, dp[i - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
prev = tmp;
}
}
return dp[m];
}
// ADR-0068 D4 — position-membership predicate heads: a position NAME literal
// used in a membership test against a user subject's `.positions`
// (ADR-0090 D3 rename). Matched names are validated against the closed catalog.
const ROLE_IN_RE = /(['"])([a-z0-9_]+)\1\s+in\s+(?:current_user|user|ctx\.user)\.positions\b/g;
const ROLE_CONTAINS_RE = /(?:current_user|user|ctx\.user)\.positions\s*\.\s*contains\(\s*(['"])([a-z0-9_]+)\1\s*\)/g;
// Bounded quantifiers ({0,N}, not * / *?) keep this linear: a CEL `exists`
// body is tiny in practice, and unbounded greedy/lazy scanners here backtrack
// polynomially (O(n^2)) on adversarial input like repeated `user.positions.exists(`
// (ADR-0068 D4 ReDoS hardening). The pre-`==` class excludes `=` so the bounded
// run stops cleanly before the operator without a lazy quantifier.
const ROLE_EXISTS_RE = /(?:current_user|user|ctx\.user)\.positions\s*\.\s*exists\s*\([^,)]{0,64},[^)=]{0,128}==\s*(['"])([a-z0-9_]+)\1/g;
const ROLE_EQ_RE = /(?:current_user|user|ctx\.user)\.position\s*==\s*(['"])([a-z0-9_]+)\1/g;
/**
* Flag role-membership predicates referencing a role outside the closed catalog
* (ADR-0068 D4 — anti-hallucination). No-op when no `roleCatalog` is supplied.
*/
function checkRoleCatalog(
source: string,
schema: ExprSchemaHint | undefined,
errors: ExprValidationError[],
): void {
const catalog = schema?.roleCatalog;
if (!catalog || catalog.length === 0) return;
const known = new Set(catalog);
const seen = new Set<string>();
for (const re of [ROLE_IN_RE, ROLE_CONTAINS_RE, ROLE_EXISTS_RE, ROLE_EQ_RE]) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(source)) !== null) {
const name = m[2];
if (known.has(name) || seen.has(name)) continue;
seen.add(name);
const suggestion = nearest(name, catalog);
errors.push({
source,
message:
`unknown role \`${name}\` — not a defined role` +
(suggestion ? `; did you mean \`${suggestion}\`?` : '.') +
` Valid roles: ${catalog.join(', ')}.`,
});
}
}
}
/**
* Validate one expression for a given field role. Never throws — returns a
* structured result. Call sites decide whether to throw (build/registration)
* or report (agent tool).
*/
export function validateExpression(
role: FieldRole,
input: ExprInput,
schema?: ExprSchemaHint,
): ExprValidationResult {
const { dialect, source } = toSource(input);
const errors: ExprValidationError[] = [];
const warnings: ExprValidationError[] = [];
if (!source.trim()) return { ok: true, errors, warnings };
if (role === 'template') {
// Templates must be the `template` dialect (or untyped string). Reject a
// CEL envelope mistakenly placed in a text field.
if (dialect && dialect !== 'template') {
errors.push({ source, message: `expected a text template but got a \`${dialect}\` expression.` });
return { ok: false, errors, warnings };
}
const compiled = templateEngine.compile(source);
if (!compiled.ok) {
errors.push({ source, message: `invalid template: ${compiled.error.message} (holes use \`{{ path }}\`).` });
}
// A single `{x}` in a template is the legacy/deprecated form (ADR-0032 §3).
const hint = SINGLE_BRACE_RE.test(source) ? bracesHintForTemplate(source) : null;
if (hint) errors.push({ source, message: hint });
return { ok: errors.length === 0, errors, warnings };
}
// predicate | value → CEL
if (dialect && dialect !== 'cel') {
errors.push({ source, message: `expected a CEL expression but got a \`${dialect}\` dialect.` });
return { ok: false, errors, warnings };
}
const compiled = celEngine.compile(source);
if (!compiled.ok) {
const hint = bracesHint(source);
errors.push({
source,
message:
`invalid CEL ${role}: ${compiled.error.message}` +
(hint ? ` — ${hint}` : ` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`),
});
} else {
checkFieldExistence(source, schema, errors);
checkRoleCatalog(source, schema, errors);
// #3183 — date-field `==`/`!=` a temporal function silently never matches.
// Scope-independent (wrong in both record and flattened sites), so run it
// outside the scope branch below.
checkTemporalDateEquality(source, schema, warnings);
if (schema?.scope === 'record') {
// In a `record`-scoped site a bare top-level identifier is a silent bug —
// it must be `record.<field>` (#1928). Hard error.
const bare = firstUndeclaredReference(source);
if (bare) {
errors.push({
source,
message:
`bare reference \`${bare}\` — a formula/validation expression binds the record as the ` +
`\`record\` namespace, not at top level, so \`${bare}\` resolves to nothing and the ` +
`expression silently evaluates to null. Write \`record.${bare}\`.`,
});
} else if (schema.fieldTypes) {
// #1928 tier 4 — with per-field types in hand, flag a text/boolean field
// used with an arithmetic/ordering operator against a number: it faults
// the runtime overload and the expression silently evaluates to null.
// Advisory (never blocks the build): the runtime CAN succeed if a text
// value happens to be numeric, so this is a warning, not an error. Only
// runs when there is no bare-ref error (the typed check needs the
// canonical `record.<field>` form).
const w = typeSoundnessWarning(source, schema.fieldTypes, 'record');
if (w) warnings.push(w);
}
} else if (schema?.fields && schema.fields.length > 0) {
// Flattened flow/automation condition: the record's fields ARE bound at
// top-level, so a bare ref is normally correct. But a *non-field* bare
// identifier is either a flow variable or a typo. When it is a near-miss
// of a known field, warn (did-you-mean) WITHOUT failing the build —
// a genuine flow variable won't be edit-distance-close to a field. (#1928)
const unknown = firstUndeclaredReference(source, schema.fields);
if (unknown) {
const suggestion = nearest(unknown, schema.fields);
if (suggestion) {
warnings.push({
source,
message:
`\`${unknown}\` is not a field of \`${schema.objectName ?? 'the trigger object'}\` — ` +
`did you mean \`${suggestion}\`? (flow conditions reference fields bare, e.g. \`${suggestion} == …\`). ` +
`If \`${unknown}\` is a flow variable this is safe to ignore.`,
});
}
}
// #1928 tier 4 — the same type-soundness check, for bare-field conditions:
// a text/boolean field compared/arithmetic'd against a number faults at
// runtime. Flow variables stay `dyn` (never flagged); equality is
// runtime-safe (never flagged). Advisory only.
if (schema.fieldTypes) {
const w = typeSoundnessWarning(source, schema.fieldTypes, 'flattened');
if (w) warnings.push(w);
}
}
}
return { ok: errors.length === 0, errors, warnings };
}
function bracesHintForTemplate(source: string): string {
const m = SINGLE_BRACE_RE.exec(source);
const ref = m?.[1] ?? 'field';
return `single-brace \`{${ref}}\` is not a valid template hole — use double braces: \`{{ ${ref} }}\`.`;
}
/**
* Introspect what an author (esp. an agent) may use in a field (Decision 1e):
* the expected dialect, the in-scope field references, and the callable
* functions. Feeds the authoring context so the model does not guess.
*/
export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
dialect: 'cel' | 'template';
fields: string[];
roots: string[];
roles: string[];
functions: string[];
} {
return {
dialect: expectedDialect(role),
fields: [...(schema?.fields ?? [])],
roots: ['record', 'previous', 'input', 'os', 'current_user', 'user', 'vars'],
roles: [...(schema?.roleCatalog ?? [])],
functions: CEL_STDLIB_FUNCTIONS,
};
}
/**
* Coarse value categories a `value`/formula expression can compute. `'unknown'`
* means cel-js could not prove a concrete type — either a `dyn` result (an
* ambiguous expression over untyped operands) or one that does not type-check.
*/
export type InferredValueType = 'number' | 'text' | 'boolean' | 'date' | 'unknown';
/** Map a cel-js type-checker type name onto an ObjectStack field value category. */
function celTypeToValueType(celType: string | null): InferredValueType {
switch (celType) {
case 'int':
case 'uint':
case 'double':
return 'number';
case 'string':
return 'text';
case 'bool':
return 'boolean';
case 'google.protobuf.Timestamp':
return 'date';
default:
// `dyn`, `google.protobuf.Duration`, list/map, null, or un-type-checkable.
return 'unknown';
}
}
/**
* Infer the coarse value type a `value`/formula expression computes — `'number'`,
* `'text'`, `'boolean'`, `'date'`, or `'unknown'` when cel-js cannot prove a
* concrete type. `schema.fields` (the host object's field names) are declared so
* a bare `<field>` reference resolves the same as `record.<field>`.
*
* The motivating use is measure-eligibility: a dataset derives a SUM measure for
* a `formula` field ONLY when this returns `'number'`, so an ambiguous or
* non-numeric formula never yields an incoherent measure. Conservative by
* construction — see {@link inferCelType}.
*/
export function inferExpressionType(input: ExprInput, schema?: ExprSchemaHint): InferredValueType {
const { source } = toSource(input);
if (!source.trim()) return 'unknown';
return celTypeToValueType(inferCelType(source, schema?.fields));
}
/**
* Public catalog of CEL functions available in expressions — what `introspectScope`
* advertises to authors (incl. AI). Every entry MUST actually resolve at runtime:
* either registered in `registerStdLib` or a verified cel-js built-in. Drifting this
* list ahead of the runtime tells the author to call functions that fault (#1928).
*/
export const CEL_STDLIB_FUNCTIONS: string[] = [
// Dates (registered stdlib)
'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'addDays', 'addMonths', 'date', 'datetime',
// Numbers (registered stdlib)
'abs', 'round', 'min', 'max',
// Strings (registered stdlib)
'upper', 'lower', 'trim', 'contains', 'startsWith', 'endsWith', 'matches', 'joinNonEmpty',
// Collections / null-ish (registered stdlib)
'isBlank', 'isEmpty', 'coalesce', 'len',
// cel-js built-ins (verified to resolve)
'size', 'has', 'int', 'string', 'bool', 'double', 'timestamp', 'duration',
];