@objectstack/formula 7.5.0 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,19 @@
1
1
  /**
2
- * Template dialect engine — strict Mustache subset.
2
+ * Template dialect engine — strict Mustache subset with a formatter whitelist.
3
3
  *
4
- * Supports `{{path.to.value}}` interpolation only. No conditionals, no loops,
5
- * no helpers. The variable scope is the same as CEL (`record`, `previous`,
6
- * `input`, `os.user`, `os.org`, `os.env`, plus `extra`), so authors can move
7
- * fluidly between a CEL formula and a template body without re-learning a
8
- * second variable namespace.
4
+ * Holes are `{{ path }}` or `{{ path | formatter[:'arg'] }}` (ADR-0032 §3).
5
+ * Holes are restricted to a **field/variable path** plus a **whitelisted
6
+ * formatter** never arbitrary CEL logic so the grammar stays small (low
7
+ * author/agent error surface), GUI-pickable (path + formatter dropdown), and
8
+ * display strings stay declarative. Real logic belongs in `Predicate`/`Expr`
9
+ * (CEL) fields, where it is validated and visible.
9
10
  *
10
- * Why a separate dialect from CEL: templates produce strings (notification
11
- * subjects, prompt bodies, titleFormat). CEL is a value-typed expression
12
- * language. Routing them through the same envelope (`{ dialect: 'template' }`)
13
- * keeps the AI author rule simple — "anything templated or computed is an
14
- * Expression" without conflating the two semantics.
11
+ * The variable scope is the same as CEL (`record`, `previous`, `input`,
12
+ * `os.user/org/env`, plus `extra`), so authors move fluidly between a CEL
13
+ * formula and a template body without re-learning a namespace.
14
+ *
15
+ * Value→string semantics are explicit and defined per formatter (numbers,
16
+ * dates, money, percent, null), instead of implicit coercion.
15
17
  */
16
18
 
17
19
  import type { Expression } from '@objectstack/spec';
@@ -19,21 +21,110 @@ import type { Expression } from '@objectstack/spec';
19
21
  import { buildScope } from './stdlib';
20
22
  import type { DialectEngine, EvalContext, EvalResult } from './types';
21
23
 
22
- const PATH_RE = /\{\{\s*([\w.[\]]+?)\s*\}\}/g;
24
+ /**
25
+ * A hole: capture the full inner content (no `}` allowed inside). Uses a single
26
+ * greedy `[^}]*` (not `\s*…\s*` around a lazy group) so the pattern is linear —
27
+ * `\s` is a subset of `[^}]`, and wrapping a lazy group in `\s*` creates an
28
+ * ambiguous (polynomial-ReDoS) matcher. Surrounding whitespace is stripped in
29
+ * `parseHole` instead.
30
+ */
31
+ const HOLE_RE = /\{\{([^}]*)\}\}/g;
23
32
 
24
- function resolvePath(scope: Record<string, unknown>, path: string): unknown {
25
- // Support `a.b.c` and `a[0].b` style. Bracket notation collapses to dotted.
26
- const normalized = path.replace(/\[(\w+)\]/g, '.$1');
27
- const segments = normalized.split('.').filter(Boolean);
28
- let cursor: unknown = scope;
29
- for (const seg of segments) {
30
- if (cursor == null || typeof cursor !== 'object') return undefined;
31
- cursor = (cursor as Record<string, unknown>)[seg];
33
+ // ───────────────────────── formatter whitelist (ADR-0032 §3) ──────────────
34
+
35
+ type Formatter = (value: unknown, arg: string | undefined, locale: string) => string;
36
+
37
+ function asNumber(v: unknown): number | undefined {
38
+ if (typeof v === 'number') return v;
39
+ if (typeof v === 'bigint') return Number(v);
40
+ if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) return Number(v);
41
+ return undefined;
42
+ }
43
+
44
+ function asDate(v: unknown): Date | undefined {
45
+ if (v instanceof Date) return v;
46
+ if (typeof v === 'number') return new Date(v);
47
+ if (typeof v === 'string') {
48
+ const d = new Date(v);
49
+ if (!Number.isNaN(d.getTime())) return d;
32
50
  }
33
- return cursor;
51
+ return undefined;
34
52
  }
35
53
 
36
- function stringify(value: unknown): string {
54
+ const FORMATTERS: Record<string, Formatter> = {
55
+ upper: (v) => baseString(v).toUpperCase(),
56
+ lower: (v) => baseString(v).toLowerCase(),
57
+ trim: (v) => baseString(v).trim(),
58
+ // number | number:2 → grouped, optional fixed decimals
59
+ number: (v, arg, locale) => {
60
+ const n = asNumber(v);
61
+ if (n === undefined) return baseString(v);
62
+ const digits = arg !== undefined ? Number(arg) : undefined;
63
+ return new Intl.NumberFormat(locale, digits !== undefined && !Number.isNaN(digits)
64
+ ? { minimumFractionDigits: digits, maximumFractionDigits: digits } : {}).format(n);
65
+ },
66
+ // currency | currency:EUR → defaults to USD
67
+ currency: (v, arg, locale) => {
68
+ const n = asNumber(v);
69
+ if (n === undefined) return baseString(v);
70
+ const code = (arg && arg.trim()) || 'USD';
71
+ try {
72
+ return new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format(n);
73
+ } catch {
74
+ return new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(n);
75
+ }
76
+ },
77
+ // percent | percent:1 → 0.42 → "42%" (value is a 0..1 ratio)
78
+ percent: (v, arg, locale) => {
79
+ const n = asNumber(v);
80
+ if (n === undefined) return baseString(v);
81
+ const digits = arg !== undefined ? Number(arg) : 0;
82
+ return new Intl.NumberFormat(locale, {
83
+ style: 'percent',
84
+ minimumFractionDigits: Number.isNaN(digits) ? 0 : digits,
85
+ maximumFractionDigits: Number.isNaN(digits) ? 0 : digits,
86
+ }).format(n);
87
+ },
88
+ // date | date:long | date:iso → date-only
89
+ date: (v, arg, locale) => {
90
+ const d = asDate(v);
91
+ if (!d) return baseString(v);
92
+ if (arg === 'iso') return d.toISOString().slice(0, 10);
93
+ const style = arg === 'long' ? 'long' : arg === 'medium' ? 'medium' : 'short';
94
+ return new Intl.DateTimeFormat(locale, { dateStyle: style as 'short' | 'medium' | 'long' }).format(d);
95
+ },
96
+ // datetime | datetime:long | datetime:iso
97
+ datetime: (v, arg, locale) => {
98
+ const d = asDate(v);
99
+ if (!d) return baseString(v);
100
+ if (arg === 'iso') return d.toISOString();
101
+ const style = arg === 'long' ? 'long' : arg === 'medium' ? 'medium' : 'short';
102
+ return new Intl.DateTimeFormat(locale, {
103
+ dateStyle: style as 'short' | 'medium' | 'long',
104
+ timeStyle: style as 'short' | 'medium' | 'long',
105
+ }).format(d);
106
+ },
107
+ // truncate:80 → cut with an ellipsis
108
+ truncate: (v, arg) => {
109
+ const s = baseString(v);
110
+ const len = arg !== undefined ? Number(arg) : 80;
111
+ if (Number.isNaN(len) || s.length <= len) return s;
112
+ return s.slice(0, Math.max(0, len - 1)) + '…';
113
+ },
114
+ // default:'N/A' → fallback when the value is null/undefined/empty
115
+ default: (v, arg) => {
116
+ const s = baseString(v);
117
+ return s === '' ? (arg ?? '') : s;
118
+ },
119
+ json: (v) => {
120
+ try { return JSON.stringify(v); } catch { return String(v); }
121
+ },
122
+ };
123
+
124
+ /** Public list of whitelisted template formatters (for introspection/docs). */
125
+ export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS);
126
+
127
+ function baseString(value: unknown): string {
37
128
  if (value === null || value === undefined) return '';
38
129
  if (value instanceof Date) return value.toISOString();
39
130
  if (typeof value === 'string') return value;
@@ -46,23 +137,76 @@ function stringify(value: unknown): string {
46
137
  }
47
138
  }
48
139
 
49
- function compileTemplate(source: string): EvalResult<string[]> {
50
- // Compile is only a structural validity check — no helpers, no balanced
51
- // open/close beyond what the regex enforces.
52
- const matches = source.match(/\{\{|\}\}/g) ?? [];
53
- if (matches.length % 2 !== 0) {
54
- return {
55
- ok: false,
56
- error: { kind: 'parse', message: 'template has unbalanced {{ }} delimiters' },
57
- };
140
+ function resolvePath(scope: Record<string, unknown>, path: string): unknown {
141
+ const normalized = path.replace(/\[(\w+)\]/g, '.$1');
142
+ const segments = normalized.split('.').filter(Boolean);
143
+ let cursor: unknown = scope;
144
+ for (const seg of segments) {
145
+ if (cursor == null || typeof cursor !== 'object') return undefined;
146
+ cursor = (cursor as Record<string, unknown>)[seg];
58
147
  }
59
- const refs: string[] = [];
148
+ return cursor;
149
+ }
150
+
151
+ interface ParsedHole {
152
+ path: string;
153
+ filter?: { name: string; arg?: string };
154
+ }
155
+
156
+ const PATH_ONLY_RE = /^[\w.[\]]+$/;
157
+
158
+ /**
159
+ * Parse a hole's inner content into a path + optional single formatter.
160
+ * Returns null when the inner content is not a valid path[+formatter] form
161
+ * (e.g. arbitrary CEL was written into a hole — rejected, ADR-0032 §3).
162
+ */
163
+ function parseHole(inner: string): ParsedHole | null {
164
+ const pipe = inner.indexOf('|');
165
+ if (pipe === -1) {
166
+ const path = inner.trim();
167
+ return PATH_ONLY_RE.test(path) ? { path } : null;
168
+ }
169
+ const path = inner.slice(0, pipe).trim();
170
+ if (!PATH_ONLY_RE.test(path)) return null;
171
+ const filterPart = inner.slice(pipe + 1).trim();
172
+ // `name` or `name:arg` or `name:'arg'`
173
+ const colon = filterPart.indexOf(':');
174
+ let name = filterPart;
175
+ let arg: string | undefined;
176
+ if (colon !== -1) {
177
+ name = filterPart.slice(0, colon).trim();
178
+ arg = filterPart.slice(colon + 1).trim().replace(/^['"]|['"]$/g, '');
179
+ }
180
+ if (!FORMATTERS[name]) return null;
181
+ return { path, filter: { name, arg } };
182
+ }
183
+
184
+ function compileTemplate(source: string): EvalResult<ParsedHole[]> {
185
+ const open = (source.match(/\{\{/g) ?? []).length;
186
+ const close = (source.match(/\}\}/g) ?? []).length;
187
+ if (open !== close) {
188
+ return { ok: false, error: { kind: 'parse', message: 'template has unbalanced {{ }} delimiters' } };
189
+ }
190
+ const holes: ParsedHole[] = [];
60
191
  let m: RegExpExecArray | null;
61
- PATH_RE.lastIndex = 0;
62
- while ((m = PATH_RE.exec(source)) !== null) {
63
- refs.push(m[1]);
192
+ HOLE_RE.lastIndex = 0;
193
+ while ((m = HOLE_RE.exec(source)) !== null) {
194
+ const parsed = parseHole(m[1]);
195
+ if (!parsed) {
196
+ return {
197
+ ok: false,
198
+ error: {
199
+ kind: 'parse',
200
+ message:
201
+ `invalid template hole \`{{ ${m[1]} }}\` — holes are a field path with an optional ` +
202
+ `formatter (\`{{ record.amount | currency }}\`), not arbitrary logic. ` +
203
+ `Move logic into a CEL field. Known formatters: ${TEMPLATE_FORMATTERS.join(', ')}.`,
204
+ },
205
+ };
206
+ }
207
+ holes.push(parsed);
64
208
  }
65
- return { ok: true, value: refs };
209
+ return { ok: true, value: holes };
66
210
  }
67
211
 
68
212
  export const templateEngine: DialectEngine = {
@@ -80,17 +224,25 @@ export const templateEngine: DialectEngine = {
80
224
  };
81
225
  }
82
226
  if (typeof expr.source !== 'string') {
83
- return {
84
- ok: false,
85
- error: { kind: 'parse', message: 'template Expression.source required' },
86
- };
227
+ return { ok: false, error: { kind: 'parse', message: 'template Expression.source required' } };
87
228
  }
88
229
  const check = compileTemplate(expr.source);
89
230
  if (!check.ok) return check as EvalResult<T>;
90
231
 
91
232
  const scope = buildScope(ctx);
92
- const out = expr.source.replace(PATH_RE, (_match, path) => {
93
- return stringify(resolvePath(scope, path));
233
+ const locale =
234
+ (ctx.extra && typeof ctx.extra.locale === 'string' && ctx.extra.locale) ||
235
+ (typeof (ctx as { locale?: string }).locale === 'string' && (ctx as { locale?: string }).locale) ||
236
+ 'en-US';
237
+
238
+ const out = expr.source.replace(HOLE_RE, (_match, inner) => {
239
+ const parsed = parseHole(String(inner));
240
+ if (!parsed) return _match; // compile already validated; defensive
241
+ const value = resolvePath(scope, parsed.path);
242
+ if (parsed.filter) {
243
+ return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale as string);
244
+ }
245
+ return baseString(value);
94
246
  });
95
247
  return { ok: true, value: out as unknown as T };
96
248
  },
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { templateEngine, TEMPLATE_FORMATTERS } from './template-engine';
3
+
4
+ function render(source: string, record: Record<string, unknown>): string {
5
+ const r = templateEngine.evaluate<string>({ dialect: 'template', source }, { record, extra: { locale: 'en-US' } });
6
+ if (!r.ok) throw new Error(r.error.message);
7
+ return r.value;
8
+ }
9
+
10
+ describe('template formatters (ADR-0032 §3)', () => {
11
+ it('keeps plain {{ path }} back-compatible (identity stringify)', () => {
12
+ expect(render('Hi {{ record.name }}', { name: 'Jane' })).toBe('Hi Jane');
13
+ });
14
+
15
+ it('currency (default USD + explicit code)', () => {
16
+ expect(render('{{ record.amt | currency }}', { amt: 1234.5 })).toBe('$1,234.50');
17
+ expect(render('{{ record.amt | currency:EUR }}', { amt: 1000 })).toContain('1,000');
18
+ });
19
+
20
+ it('number with fixed decimals', () => {
21
+ expect(render('{{ record.n | number:2 }}', { n: 1234.5 })).toBe('1,234.50');
22
+ });
23
+
24
+ it('percent (ratio → %)', () => {
25
+ expect(render('{{ record.r | percent }}', { r: 0.42 })).toBe('42%');
26
+ expect(render('{{ record.r | percent:1 }}', { r: 0.425 })).toBe('42.5%');
27
+ });
28
+
29
+ it('date:iso', () => {
30
+ expect(render('{{ record.d | date:iso }}', { d: '2026-06-02T10:00:00Z' })).toBe('2026-06-02');
31
+ });
32
+
33
+ it('truncate + upper + default', () => {
34
+ expect(render('{{ record.s | truncate:5 }}', { s: 'abcdefgh' })).toBe('abcd…');
35
+ expect(render('{{ record.s | upper }}', { s: 'hi' })).toBe('HI');
36
+ expect(render("{{ record.missing | default:'N/A' }}", {})).toBe('N/A');
37
+ });
38
+
39
+ it('rejects arbitrary logic in a hole (not a path+formatter)', () => {
40
+ const r = templateEngine.compile('{{ record.a > 5 ? "x" : "y" }}');
41
+ expect(r.ok).toBe(false);
42
+ if (!r.ok) expect(r.error.message).toMatch(/field path with an optional formatter|arbitrary logic/);
43
+ });
44
+
45
+ it('rejects an unknown formatter', () => {
46
+ const r = templateEngine.compile('{{ record.a | bogus }}');
47
+ expect(r.ok).toBe(false);
48
+ });
49
+
50
+ it('exposes the formatter catalog', () => {
51
+ expect(TEMPLATE_FORMATTERS).toEqual(
52
+ expect.arrayContaining(['currency', 'number', 'percent', 'date', 'datetime', 'truncate', 'upper', 'lower', 'default']),
53
+ );
54
+ });
55
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateExpression, introspectScope, expectedDialect } from './validate';
3
+
4
+ describe('validateExpression (ADR-0032)', () => {
5
+ describe('predicates (CEL)', () => {
6
+ it('accepts a valid bare-CEL predicate', () => {
7
+ const r = validateExpression('predicate', 'record.rating >= 4');
8
+ expect(r.ok).toBe(true);
9
+ expect(r.errors).toHaveLength(0);
10
+ });
11
+
12
+ it('rejects the #1491 brace-in-CEL form with a corrective message', () => {
13
+ const r = validateExpression('predicate', '{record.rating} >= 4');
14
+ expect(r.ok).toBe(false);
15
+ expect(r.errors[0].message).toMatch(/map literal|bare reference|template brace/i);
16
+ expect(r.errors[0].message).toContain('record.rating');
17
+ expect(r.errors[0].source).toBe('{record.rating} >= 4');
18
+ });
19
+
20
+ it('rejects a CEL envelope placed in a template-only role', () => {
21
+ const r = validateExpression('template', { dialect: 'cel', source: 'record.x' });
22
+ expect(r.ok).toBe(false);
23
+ });
24
+
25
+ it('accepts an empty/absent expression (no-op)', () => {
26
+ expect(validateExpression('predicate', '').ok).toBe(true);
27
+ expect(validateExpression('predicate', null).ok).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe('templates', () => {
32
+ it('accepts a valid {{ path }} template', () => {
33
+ const r = validateExpression('template', 'Hot lead: {{ record.full_name }}');
34
+ expect(r.ok).toBe(true);
35
+ });
36
+
37
+ it('flags single-brace {x} in a template and suggests {{ }}', () => {
38
+ const r = validateExpression('template', 'Hi {record.name}');
39
+ expect(r.ok).toBe(false);
40
+ expect(r.errors[0].message).toMatch(/\{\{ record\.name \}\}|double braces/);
41
+ });
42
+ });
43
+
44
+ describe('schema-aware field existence (v1)', () => {
45
+ it('flags an unknown record field with a did-you-mean', () => {
46
+ const r = validateExpression('predicate', 'record.raitng >= 4', { objectName: 'crm_lead', fields: ['rating', 'status'] });
47
+ expect(r.ok).toBe(false);
48
+ expect(r.errors[0].message).toMatch(/unknown field `raitng`/);
49
+ expect(r.errors[0].message).toMatch(/did you mean `rating`/);
50
+ });
51
+
52
+ it('passes when fields exist', () => {
53
+ const r = validateExpression('predicate', 'record.rating >= 4 && record.status == "new"', { fields: ['rating', 'status'] });
54
+ expect(r.ok).toBe(true);
55
+ });
56
+
57
+ it('skips field checks when no schema is provided', () => {
58
+ expect(validateExpression('predicate', 'record.anything > 1').ok).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe('introspection', () => {
63
+ it('reports the dialect + scope for a field role', () => {
64
+ expect(expectedDialect('predicate')).toBe('cel');
65
+ expect(expectedDialect('template')).toBe('template');
66
+ const scope = introspectScope('predicate', { fields: ['rating'] });
67
+ expect(scope.dialect).toBe('cel');
68
+ expect(scope.fields).toContain('rating');
69
+ expect(scope.roots).toContain('record');
70
+ expect(scope.functions).toContain('daysFromNow');
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Shared expression validator (ADR-0032 §Decision 1/5).
3
+ *
4
+ * One validator, used by every author surface — `objectstack build`,
5
+ * `registerFlow`/metadata registration, and the agent-callable
6
+ * `validate_expression` tool — so a malformed expression is caught the same
7
+ * way everywhere, with a message written for **self-correction** (Decision 1d):
8
+ * it states what is wrong AND the correct form.
9
+ *
10
+ * Field roles map to dialects (Decision 2):
11
+ * - `predicate` → bare CEL returning bool (`record.rating >= 4`)
12
+ * - `value` → bare CEL of any type (`daysFromNow(3)`)
13
+ * - `template` → text with `{{ path }}` holes (`Hot lead: {{ record.name }}`)
14
+ *
15
+ * The #1 author error (human or LLM) is wrapping a field reference in single
16
+ * `{…}` braces inside a CEL field — `{x}` parses as a CEL map literal and fails.
17
+ * This validator detects that specific mistake and returns the exact fix.
18
+ */
19
+
20
+ import { celEngine } from './cel-engine';
21
+ import { templateEngine } from './template-engine';
22
+
23
+ export type FieldRole = 'predicate' | 'value' | 'template';
24
+
25
+ /**
26
+ * Loose input accepted by the validator: a bare string, or any object exposing
27
+ * `dialect`/`source` (the Expression envelope, or a not-yet-narrowed value from
28
+ * a `config.condition` / `edge.condition` field). Kept structural so call sites
29
+ * need not pre-narrow to the strict {@link Expression} dialect union.
30
+ */
31
+ export type ExprInput = string | { dialect?: string; source?: string } | null | undefined;
32
+
33
+ /** Optional schema context for field-existence checks (Decision 1b, v1). */
34
+ export interface ExprSchemaHint {
35
+ /** Object the expression is authored against (for error text). */
36
+ objectName?: string;
37
+ /** Known top-level field names, so `record.<field>` can be checked. */
38
+ fields?: readonly string[];
39
+ }
40
+
41
+ export interface ExprValidationError {
42
+ /** Self-correcting message: what is wrong + the correct form. */
43
+ message: string;
44
+ /** The offending source, echoed for location. */
45
+ source: string;
46
+ }
47
+
48
+ export interface ExprValidationResult {
49
+ ok: boolean;
50
+ errors: ExprValidationError[];
51
+ }
52
+
53
+ /** A bare `{x}` that is NOT part of a `{{x}}` mustache hole. */
54
+ const SINGLE_BRACE_RE = /(?:^|[^{])\{\s*([A-Za-z_$][\w.$]*)\s*\}(?!\})/;
55
+ /** `record.<field>` / `previous.<field>` head references for field-existence. */
56
+ const RECORD_REF_RE = /\b(?:record|previous)\.([A-Za-z_$][\w$]*)/g;
57
+
58
+ /** The dialect a field role expects (Decision 2). */
59
+ export function expectedDialect(role: FieldRole): 'cel' | 'template' {
60
+ return role === 'template' ? 'template' : 'cel';
61
+ }
62
+
63
+ function toSource(input: ExprInput): { dialect?: string; source: string } {
64
+ if (input == null) return { source: '' };
65
+ if (typeof input === 'string') return { source: input };
66
+ return { dialect: input.dialect, source: input.source ?? '' };
67
+ }
68
+
69
+ function bracesHint(source: string): string | null {
70
+ const m = SINGLE_BRACE_RE.exec(source);
71
+ if (!m) return null;
72
+ const ref = m[1];
73
+ return (
74
+ `it looks like a \`{${ref}}\` template brace was used inside a CEL expression — ` +
75
+ `\`{…}\` parses as a CEL map literal and fails. Write the bare reference instead, e.g. \`${ref}\`.`
76
+ );
77
+ }
78
+
79
+ function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void {
80
+ if (!schema?.fields || schema.fields.length === 0) return;
81
+ const known = new Set(schema.fields);
82
+ const seen = new Set<string>();
83
+ let m: RegExpExecArray | null;
84
+ RECORD_REF_RE.lastIndex = 0;
85
+ while ((m = RECORD_REF_RE.exec(source)) !== null) {
86
+ const field = m[1];
87
+ if (seen.has(field) || known.has(field)) continue;
88
+ seen.add(field);
89
+ const suggestion = nearest(field, schema.fields);
90
+ errors.push({
91
+ source,
92
+ message:
93
+ `unknown field \`${field}\`${schema.objectName ? ` on \`${schema.objectName}\`` : ''}` +
94
+ (suggestion ? ` — did you mean \`${suggestion}\`?` : ''),
95
+ });
96
+ }
97
+ }
98
+
99
+ /** Cheap edit-distance suggestion for typo'd field names. */
100
+ function nearest(name: string, candidates: readonly string[]): string | undefined {
101
+ let best: string | undefined;
102
+ let bestD = Infinity;
103
+ for (const c of candidates) {
104
+ const d = levenshtein(name, c);
105
+ if (d < bestD) { bestD = d; best = c; }
106
+ }
107
+ return bestD <= Math.max(2, Math.floor(name.length / 3)) ? best : undefined;
108
+ }
109
+
110
+ function levenshtein(a: string, b: string): number {
111
+ const m = a.length, n = b.length;
112
+ const dp = Array.from({ length: m + 1 }, (_, i) => i);
113
+ for (let j = 1; j <= n; j++) {
114
+ let prev = dp[0];
115
+ dp[0] = j;
116
+ for (let i = 1; i <= m; i++) {
117
+ const tmp = dp[i];
118
+ dp[i] = Math.min(dp[i] + 1, dp[i - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
119
+ prev = tmp;
120
+ }
121
+ }
122
+ return dp[m];
123
+ }
124
+
125
+ /**
126
+ * Validate one expression for a given field role. Never throws — returns a
127
+ * structured result. Call sites decide whether to throw (build/registration)
128
+ * or report (agent tool).
129
+ */
130
+ export function validateExpression(
131
+ role: FieldRole,
132
+ input: ExprInput,
133
+ schema?: ExprSchemaHint,
134
+ ): ExprValidationResult {
135
+ const { dialect, source } = toSource(input);
136
+ const errors: ExprValidationError[] = [];
137
+ if (!source.trim()) return { ok: true, errors };
138
+
139
+ if (role === 'template') {
140
+ // Templates must be the `template` dialect (or untyped string). Reject a
141
+ // CEL envelope mistakenly placed in a text field.
142
+ if (dialect && dialect !== 'template') {
143
+ errors.push({ source, message: `expected a text template but got a \`${dialect}\` expression.` });
144
+ return { ok: false, errors };
145
+ }
146
+ const compiled = templateEngine.compile(source);
147
+ if (!compiled.ok) {
148
+ errors.push({ source, message: `invalid template: ${compiled.error.message} (holes use \`{{ path }}\`).` });
149
+ }
150
+ // A single `{x}` in a template is the legacy/deprecated form (ADR-0032 §3).
151
+ const hint = SINGLE_BRACE_RE.test(source) ? bracesHintForTemplate(source) : null;
152
+ if (hint) errors.push({ source, message: hint });
153
+ return { ok: errors.length === 0, errors };
154
+ }
155
+
156
+ // predicate | value → CEL
157
+ if (dialect && dialect !== 'cel') {
158
+ errors.push({ source, message: `expected a CEL expression but got a \`${dialect}\` dialect.` });
159
+ return { ok: false, errors };
160
+ }
161
+ const compiled = celEngine.compile(source);
162
+ if (!compiled.ok) {
163
+ const hint = bracesHint(source);
164
+ errors.push({
165
+ source,
166
+ message:
167
+ `invalid CEL ${role}: ${compiled.error.message}` +
168
+ (hint ? ` — ${hint}` : ` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`),
169
+ });
170
+ } else {
171
+ checkFieldExistence(source, schema, errors);
172
+ }
173
+ return { ok: errors.length === 0, errors };
174
+ }
175
+
176
+ function bracesHintForTemplate(source: string): string {
177
+ const m = SINGLE_BRACE_RE.exec(source);
178
+ const ref = m?.[1] ?? 'field';
179
+ return `single-brace \`{${ref}}\` is not a valid template hole — use double braces: \`{{ ${ref} }}\`.`;
180
+ }
181
+
182
+ /**
183
+ * Introspect what an author (esp. an agent) may use in a field (Decision 1e):
184
+ * the expected dialect, the in-scope field references, and the callable
185
+ * functions. Feeds the authoring context so the model does not guess.
186
+ */
187
+ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
188
+ dialect: 'cel' | 'template';
189
+ fields: string[];
190
+ roots: string[];
191
+ functions: string[];
192
+ } {
193
+ return {
194
+ dialect: expectedDialect(role),
195
+ fields: [...(schema?.fields ?? [])],
196
+ roots: ['record', 'previous', 'input', 'os', 'vars'],
197
+ functions: CEL_STDLIB_FUNCTIONS,
198
+ };
199
+ }
200
+
201
+ /** Public catalog of CEL stdlib functions available in expressions. */
202
+ export const CEL_STDLIB_FUNCTIONS: string[] = [
203
+ 'now', 'today', 'daysFromNow', 'daysBetween', 'date', 'datetime', 'timestamp',
204
+ 'isBlank', 'isEmpty', 'coalesce', 'len', 'size', 'int', 'float', 'string', 'bool',
205
+ 'upper', 'lower', 'trim', 'contains', 'startsWith', 'endsWith', 'matches',
206
+ 'has', 'min', 'max', 'abs', 'round',
207
+ ];