-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathvalidate.ts
More file actions
276 lines (257 loc) · 11.4 KB
/
Copy pathvalidate.ts
File metadata and controls
276 lines (257 loc) · 11.4 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
/**
* 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 } 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[];
/**
* 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';
}
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[];
}
/** 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];
}
/**
* 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);
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?.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.`,
});
}
}
}
}
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[];
functions: string[];
} {
return {
dialect: expectedDialect(role),
fields: [...(schema?.fields ?? [])],
roots: ['record', 'previous', 'input', 'os', 'vars'],
functions: CEL_STDLIB_FUNCTIONS,
};
}
/**
* 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',
];