@objectstack/formula 7.5.0 → 7.7.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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +57 -0
- package/dist/index.d.mts +84 -12
- package/dist/index.d.ts +84 -12
- package/dist/index.js +331 -31
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +325 -30
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/cel-engine.test.ts +65 -0
- package/src/cel-engine.ts +73 -2
- package/src/index.ts +5 -1
- package/src/template-engine.ts +194 -42
- package/src/template-formatters.test.ts +55 -0
- package/src/validate.test.ts +73 -0
- package/src/validate.ts +207 -0
package/src/template-engine.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
51
|
+
return undefined;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
while ((m =
|
|
63
|
-
|
|
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:
|
|
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
|
|
93
|
-
|
|
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
|
+
});
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
];
|