@telorun/templating 0.9.0 → 0.10.1
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/README.md +2 -141
- package/dist/cel/catalog.d.ts.map +1 -1
- package/dist/cel/catalog.js +65 -16
- package/dist/cel/compile.d.ts.map +1 -1
- package/dist/cel/compile.js +24 -0
- package/dist/cel/environment.d.ts.map +1 -1
- package/dist/cel/environment.js +5 -1
- package/package.json +3 -2
- package/src/cel/catalog.ts +66 -19
- package/src/cel/compile.ts +22 -0
- package/src/cel/environment.ts +5 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Built to be language-agnostic and infinitely extensible.
|
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
# Reconcile your manifest into a running backend
|
|
26
|
-
$ telo ./examples/hello-api
|
|
26
|
+
$ telo ./examples/hello-api
|
|
27
27
|
|
|
28
28
|
{"level":30,"time":1771610393008,"pid":1310178,"hostname":"dev","msg":"Server listening at http://127.0.0.1:8844"}
|
|
29
29
|
```
|
|
@@ -44,146 +44,7 @@ $ telo ./examples/hello-api.yaml
|
|
|
44
44
|
|
|
45
45
|
## Example manifest
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
```yaml
|
|
50
|
-
kind: Telo.Application
|
|
51
|
-
metadata:
|
|
52
|
-
name: feedback
|
|
53
|
-
version: 1.0.0
|
|
54
|
-
description: |
|
|
55
|
-
A complete feedback collection REST API — no code, pure YAML.
|
|
56
|
-
Persists entries to SQLite and serves them over HTTP.
|
|
57
|
-
imports:
|
|
58
|
-
Http: std/http-server@0.11.0
|
|
59
|
-
Sql: std/sql@0.9.0
|
|
60
|
-
targets:
|
|
61
|
-
- !ref Migrations
|
|
62
|
-
- !ref Server
|
|
63
|
-
---
|
|
64
|
-
# SQLite database — swap driver/host/database for PostgreSQL with zero YAML changes
|
|
65
|
-
kind: Sql.Connection
|
|
66
|
-
metadata:
|
|
67
|
-
name: Db
|
|
68
|
-
driver: sqlite
|
|
69
|
-
file: ./tmp/feedback.db
|
|
70
|
-
---
|
|
71
|
-
# Migrations: applied automatically before the server starts
|
|
72
|
-
kind: Sql.Migrations
|
|
73
|
-
metadata:
|
|
74
|
-
name: Migrations
|
|
75
|
-
connection: !ref Db
|
|
76
|
-
---
|
|
77
|
-
kind: Sql.Migration
|
|
78
|
-
metadata:
|
|
79
|
-
name: Migration_20260413_182154_CreateFeedback
|
|
80
|
-
version: 20260413_182154_CreateFeedback
|
|
81
|
-
sql: |
|
|
82
|
-
CREATE TABLE IF NOT EXISTS feedback (
|
|
83
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
84
|
-
text TEXT NOT NULL,
|
|
85
|
-
source TEXT,
|
|
86
|
-
score INTEGER NOT NULL DEFAULT 0,
|
|
87
|
-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
88
|
-
)
|
|
89
|
-
---
|
|
90
|
-
kind: Http.Server
|
|
91
|
-
metadata:
|
|
92
|
-
name: Server
|
|
93
|
-
baseUrl: http://localhost:8844
|
|
94
|
-
port: 8844
|
|
95
|
-
logger: true
|
|
96
|
-
openapi:
|
|
97
|
-
info:
|
|
98
|
-
title: Feedback API
|
|
99
|
-
version: 1.0.0
|
|
100
|
-
mounts:
|
|
101
|
-
- path: /v1
|
|
102
|
-
mount: !ref FeedbackRoutes
|
|
103
|
-
---
|
|
104
|
-
kind: Http.Api
|
|
105
|
-
metadata:
|
|
106
|
-
name: FeedbackRoutes
|
|
107
|
-
routes:
|
|
108
|
-
# POST /v1/feedback — insert a new entry, score derived from body length heuristic
|
|
109
|
-
- request:
|
|
110
|
-
path: /feedback
|
|
111
|
-
method: POST
|
|
112
|
-
schema:
|
|
113
|
-
body:
|
|
114
|
-
type: object
|
|
115
|
-
properties:
|
|
116
|
-
text:
|
|
117
|
-
type: string
|
|
118
|
-
minLength: 1
|
|
119
|
-
source:
|
|
120
|
-
type: string
|
|
121
|
-
required: [ text ]
|
|
122
|
-
handler:
|
|
123
|
-
kind: Sql.Exec
|
|
124
|
-
connection: !ref Db
|
|
125
|
-
inputs:
|
|
126
|
-
sql: "INSERT INTO feedback (text, source, score) VALUES (?, ?, ?)"
|
|
127
|
-
bindings:
|
|
128
|
-
- "${{ request.body.text }}"
|
|
129
|
-
- "${{ request.body.source }}"
|
|
130
|
-
- "${{ size(request.body.text) }}"
|
|
131
|
-
response:
|
|
132
|
-
- status: 201
|
|
133
|
-
headers:
|
|
134
|
-
Content-Type: application/json
|
|
135
|
-
body:
|
|
136
|
-
ok: true
|
|
137
|
-
message: Feedback received
|
|
138
|
-
|
|
139
|
-
# GET /v1/feedback — list all entries, newest first
|
|
140
|
-
- request:
|
|
141
|
-
path: /feedback
|
|
142
|
-
method: GET
|
|
143
|
-
handler:
|
|
144
|
-
kind: Sql.Select
|
|
145
|
-
connection: !ref Db
|
|
146
|
-
from: feedback
|
|
147
|
-
columns: [ id, text, source, score, created_at ]
|
|
148
|
-
orderBy:
|
|
149
|
-
- { column: created_at, direction: desc }
|
|
150
|
-
response:
|
|
151
|
-
- status: 200
|
|
152
|
-
headers:
|
|
153
|
-
Content-Type: application/json
|
|
154
|
-
body: "${{ result.rows }}"
|
|
155
|
-
|
|
156
|
-
# GET /v1/feedback/{id} — fetch a single entry
|
|
157
|
-
- request:
|
|
158
|
-
path: /feedback/{id}
|
|
159
|
-
method: GET
|
|
160
|
-
schema:
|
|
161
|
-
params:
|
|
162
|
-
type: object
|
|
163
|
-
properties:
|
|
164
|
-
id:
|
|
165
|
-
type: integer
|
|
166
|
-
required: [ id ]
|
|
167
|
-
handler:
|
|
168
|
-
kind: Sql.Select
|
|
169
|
-
connection: !ref Db
|
|
170
|
-
from: feedback
|
|
171
|
-
columns: [ id, text, source, score, created_at ]
|
|
172
|
-
where:
|
|
173
|
-
- { column: id, op: "=", value: "${{ request.params.id }}" }
|
|
174
|
-
response:
|
|
175
|
-
- status: 200
|
|
176
|
-
when: "size(result.rows) > 0"
|
|
177
|
-
headers:
|
|
178
|
-
Content-Type: application/json
|
|
179
|
-
body: "${{ result.rows[0] }}"
|
|
180
|
-
- status: 404
|
|
181
|
-
headers:
|
|
182
|
-
Content-Type: application/json
|
|
183
|
-
body:
|
|
184
|
-
ok: false
|
|
185
|
-
message: Not found
|
|
186
|
-
```
|
|
47
|
+
See [examples/](./examples/) for a list of working applications.
|
|
187
48
|
|
|
188
49
|
## Status
|
|
189
50
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../src/cel/catalog.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../src/cel/catalog.ts"],"names":[],"mappings":"AAGA;;;0DAG0D;AAC1D,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9B,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC3B,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9B,IAAI,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAClE,YAAY,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,YAAY,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,IAAI,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,CAAC;CAClC;AA2CD,MAAM,MAAM,mBAAmB,GAC3B,YAAY,GACZ,MAAM,GACN,MAAM,GACN,QAAQ,GACR,MAAM,GACN,YAAY,GACZ,MAAM,GACN,UAAU,GACV,SAAS,GACT,MAAM,CAAC;AAEX;;;qBAGqB;AACrB,MAAM,WAAW,cAAc;IAC7B,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;0EACsE;IACtE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;yDAIqD;IACrD,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;wBACoB;IACpB,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;2EACuE;IACvE,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,EAAE,WAAW,KAAK,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC;CACjE;AAED,wEAAwE;AACxE,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;AAyE5D,eAAO,MAAM,aAAa,EAAE,SAAS,cAAc,EA8elD,CAAC;AAEF,oEAAoE;AACpE,wBAAgB,kBAAkB,IAAI,eAAe,EAAE,CAEtD"}
|
package/dist/cel/catalog.js
CHANGED
|
@@ -1,4 +1,45 @@
|
|
|
1
|
+
import { RE2JS } from "re2js";
|
|
1
2
|
import { v1, v3, v4, v5, v6, v7, validate as uuidValidate, version as uuidVersion } from "uuid";
|
|
3
|
+
/** RE2 regex engine for the CEL `regex*` functions — `re2js`, a pure-JS port of
|
|
4
|
+
* Google's RE2. The dialect is RE2 (linear-time, no backtracking, ReDoS-safe) —
|
|
5
|
+
* never JS `RegExp` — so a manifest's regex behaves the same across runtimes.
|
|
6
|
+
* Flags map a trailing option string to RE2 flags; inline `(?s)` etc. work in
|
|
7
|
+
* the pattern too.
|
|
8
|
+
*
|
|
9
|
+
* Why pure-JS and not a native RE2:
|
|
10
|
+
* - The Rust `regex` crate via napi (N-API) is faster, but shipping it means a
|
|
11
|
+
* cross-platform prebuild matrix + per-triple npm packages — a whole release
|
|
12
|
+
* subsystem for one function family.
|
|
13
|
+
* - The `re2` npm package (Google's C++ RE2) is simpler to depend on, but it's a
|
|
14
|
+
* nan/V8 addon and **does not load under Bun** (incomplete V8 C++ ABI →
|
|
15
|
+
* `undefined symbol`). Telo's CLI and test suite run on Bun, so that's a
|
|
16
|
+
* non-starter.
|
|
17
|
+
* `re2js` is pure JS: zero native addons, runs identically on Node, Bun, and the
|
|
18
|
+
* browser (keeps this package — and the analyzer — browser-safe), and needs no
|
|
19
|
+
* prebuilds. The cost is throughput vs. native, which is irrelevant for the
|
|
20
|
+
* small strings CEL manifests run regex over. */
|
|
21
|
+
const RE2_FLAG = {
|
|
22
|
+
i: RE2JS.CASE_INSENSITIVE,
|
|
23
|
+
m: RE2JS.MULTILINE,
|
|
24
|
+
s: RE2JS.DOTALL,
|
|
25
|
+
};
|
|
26
|
+
const compileRe2 = (fn, pattern, flags) => {
|
|
27
|
+
let bits = 0;
|
|
28
|
+
for (const c of flags ?? "") {
|
|
29
|
+
if (c === "g")
|
|
30
|
+
continue; // global is implicit in replaceAll / find-loop
|
|
31
|
+
const bit = RE2_FLAG[c];
|
|
32
|
+
if (bit === undefined)
|
|
33
|
+
throw new Error(`${fn}: unknown regex flag '${c}' (supported: i, m, s)`);
|
|
34
|
+
bits |= bit;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return RE2JS.compile(pattern, bits);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
throw new Error(`${fn}: invalid RE2 pattern ${JSON.stringify(pattern)}: ${e instanceof Error ? e.message : String(e)}`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
2
43
|
const num = (x) => Number(x);
|
|
3
44
|
const minMax = (list, isMin) => {
|
|
4
45
|
if (!Array.isArray(list) || list.length === 0)
|
|
@@ -14,9 +55,6 @@ const minMax = (list, isMin) => {
|
|
|
14
55
|
}
|
|
15
56
|
return best;
|
|
16
57
|
};
|
|
17
|
-
/** Ensure a regex replaces every match: append the global flag unless the
|
|
18
|
-
* caller already passed it. */
|
|
19
|
-
const withGlobal = (flags) => flags && flags.includes("g") ? flags : `${flags ?? ""}g`;
|
|
20
58
|
const sortList = (list) => [...list].sort((a, b) => {
|
|
21
59
|
if (typeof a === "number" || typeof a === "bigint") {
|
|
22
60
|
const d = num(a) - num(b);
|
|
@@ -201,39 +239,50 @@ export const CEL_FUNCTIONS = [
|
|
|
201
239
|
name: "regexReplace",
|
|
202
240
|
signature: "regexReplace(string, string, string, string?): string",
|
|
203
241
|
category: "string",
|
|
204
|
-
summary: "Replace every regex match with a replacement ($1 backrefs); flags like 'i', 'm', 's'.",
|
|
242
|
+
summary: "Replace every regex match (RE2 syntax) with a replacement ($1 backrefs); flags like 'i', 'm', 's'.",
|
|
205
243
|
deterministic: true,
|
|
206
244
|
hostBacked: false,
|
|
207
|
-
build: () => (s, pattern, replacement, flags) =>
|
|
245
|
+
build: () => (s, pattern, replacement, flags) => compileRe2("regexReplace", pattern, flags).matcher(s).replaceAll(replacement),
|
|
208
246
|
},
|
|
209
247
|
{
|
|
210
248
|
name: "regexExtract",
|
|
211
|
-
signature: "regexExtract(string, string): string",
|
|
249
|
+
signature: "regexExtract(string, string, string?): string",
|
|
212
250
|
category: "string",
|
|
213
|
-
summary: "First whole match of a regex, or '' when there is none.",
|
|
251
|
+
summary: "First whole match of a regex (RE2 syntax), or '' when there is none.",
|
|
214
252
|
deterministic: true,
|
|
215
253
|
hostBacked: false,
|
|
216
|
-
build: () => (s, pattern) =>
|
|
254
|
+
build: () => (s, pattern, flags) => {
|
|
255
|
+
const m = compileRe2("regexExtract", pattern, flags).matcher(s);
|
|
256
|
+
return m.find() ? (m.group() ?? "") : "";
|
|
257
|
+
},
|
|
217
258
|
},
|
|
218
259
|
{
|
|
219
260
|
name: "regexExtractAll",
|
|
220
|
-
signature: "regexExtractAll(string, string): list<string>",
|
|
261
|
+
signature: "regexExtractAll(string, string, string?): list<string>",
|
|
221
262
|
category: "string",
|
|
222
|
-
summary: "Every whole match of a regex, in order.",
|
|
263
|
+
summary: "Every whole match of a regex (RE2 syntax), in order.",
|
|
223
264
|
deterministic: true,
|
|
224
265
|
hostBacked: false,
|
|
225
|
-
build: () => (s, pattern
|
|
266
|
+
build: () => (s, pattern, flags) => {
|
|
267
|
+
const m = compileRe2("regexExtractAll", pattern, flags).matcher(s);
|
|
268
|
+
const out = [];
|
|
269
|
+
while (m.find())
|
|
270
|
+
out.push(m.group() ?? "");
|
|
271
|
+
return out;
|
|
272
|
+
},
|
|
226
273
|
},
|
|
227
274
|
{
|
|
228
275
|
name: "regexGroups",
|
|
229
|
-
signature: "regexGroups(string, string): list<string>",
|
|
276
|
+
signature: "regexGroups(string, string, string?): list<string>",
|
|
230
277
|
category: "string",
|
|
231
|
-
summary: "Capture groups of the first regex match (empty list when there is no match
|
|
278
|
+
summary: "Capture groups of the first regex match (RE2 syntax); empty list when there is no match.",
|
|
232
279
|
deterministic: true,
|
|
233
280
|
hostBacked: false,
|
|
234
|
-
build: () => (s, pattern) => {
|
|
235
|
-
const m =
|
|
236
|
-
|
|
281
|
+
build: () => (s, pattern, flags) => {
|
|
282
|
+
const m = compileRe2("regexGroups", pattern, flags).matcher(s);
|
|
283
|
+
if (!m.find())
|
|
284
|
+
return [];
|
|
285
|
+
return Array.from({ length: m.groupCount() }, (_unused, i) => m.group(i + 1) ?? "");
|
|
237
286
|
},
|
|
238
287
|
},
|
|
239
288
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../../src/cel/compile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AACnE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../../src/cel/compile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AACnE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAGxD,eAAO,MAAM,cAAc,QAA8B,CAAC;AAC1D,eAAO,MAAM,oBAAoB,QAAqC,CAAC;AAgBvE;;2CAE2C;AAC3C,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,aAAa,CAQ/E;AAED;;;;+DAI+D;AAC/D,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CA8BlE;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B;IAAE,SAAS,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,OAAO,EAAE,CAAA;CAAE,CAsB5C"}
|
package/dist/cel/compile.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { isCompiledValue } from "@telorun/sdk";
|
|
2
|
+
import { extractAccessChains } from "./analyze.js";
|
|
2
3
|
export const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
|
|
3
4
|
export const EXACT_TEMPLATE_REGEX = /^\s*\$\{\{\s*([^}]+?)\s*\}\}\s*$/;
|
|
5
|
+
/** Root variable identifiers an expression reads, from its parsed AST — the
|
|
6
|
+
* first element of every member-access chain (`self.table` → `self`). Returns
|
|
7
|
+
* undefined when the parse result carries no AST so consumers can tell "no
|
|
8
|
+
* refs" from "unknown". */
|
|
9
|
+
function rootRefs(parsed) {
|
|
10
|
+
const ast = parsed.ast;
|
|
11
|
+
if (!ast)
|
|
12
|
+
return undefined;
|
|
13
|
+
const roots = new Set();
|
|
14
|
+
for (const chain of extractAccessChains(ast)) {
|
|
15
|
+
if (chain.length > 0)
|
|
16
|
+
roots.add(chain[0]);
|
|
17
|
+
}
|
|
18
|
+
return [...roots];
|
|
19
|
+
}
|
|
4
20
|
/** Compile a single CEL expression (no `${{ }}` wrapping) into a CompiledValue.
|
|
5
21
|
* Throws on syntax errors. Used by the `!cel` engine where the entire tagged
|
|
6
22
|
* scalar is treated as one expression. */
|
|
@@ -9,6 +25,7 @@ export function compileExpression(expr, env) {
|
|
|
9
25
|
return {
|
|
10
26
|
__compiled: true,
|
|
11
27
|
source: expr,
|
|
28
|
+
refs: rootRefs(fn),
|
|
12
29
|
call: (ctx) => fn(ctx),
|
|
13
30
|
};
|
|
14
31
|
}
|
|
@@ -34,10 +51,17 @@ export function compileString(s, env) {
|
|
|
34
51
|
}
|
|
35
52
|
if (last < s.length)
|
|
36
53
|
parts.push(s.slice(last));
|
|
54
|
+
const refs = new Set();
|
|
55
|
+
for (const p of parts) {
|
|
56
|
+
if (typeof p !== "string" && p.refs)
|
|
57
|
+
for (const r of p.refs)
|
|
58
|
+
refs.add(r);
|
|
59
|
+
}
|
|
37
60
|
return {
|
|
38
61
|
__compiled: true,
|
|
39
62
|
source: s,
|
|
40
63
|
parts,
|
|
64
|
+
refs: [...refs],
|
|
41
65
|
call: (ctx) => parts.map((p) => (typeof p === "string" ? p : String(p.call(ctx) ?? ""))).join(""),
|
|
42
66
|
};
|
|
43
67
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"environment.d.ts","sourceRoot":"","sources":["../../src/cel/environment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEnD,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAE/D,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAoBhD;;;;;;;;;;;;;;;yDAeyD;AACzD;;;;;wBAKwB;AACxB,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAyB5D;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,GAAE,OAAO,CAAC,WAAW,CAAM,GAAG,WAAW,
|
|
1
|
+
{"version":3,"file":"environment.d.ts","sourceRoot":"","sources":["../../src/cel/environment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEnD,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAE/D,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAoBhD;;;;;;;;;;;;;;;yDAeyD;AACzD;;;;;wBAKwB;AACxB,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAyB5D;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,GAAE,OAAO,CAAC,WAAW,CAAM,GAAG,WAAW,CAkBpF"}
|
package/dist/cel/environment.js
CHANGED
|
@@ -68,7 +68,11 @@ export function deriveSignatures(signature) {
|
|
|
68
68
|
}
|
|
69
69
|
export function buildCelEnvironment(handlers = {}) {
|
|
70
70
|
const h = { ...STUB_HANDLERS, ...handlers };
|
|
71
|
-
|
|
71
|
+
// cel-go defaults HomogeneousAggregateLiterals OFF: heterogeneous list/map
|
|
72
|
+
// literals unify to `dyn` rather than erroring. cel-js flips that default to
|
|
73
|
+
// strict; we align with cel-go so manifests (dyn-heavy: request, rows, …)
|
|
74
|
+
// don't hit false positives the runtime evaluates fine.
|
|
75
|
+
let env = new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true, homogeneousAggregateLiterals: false });
|
|
72
76
|
for (const fn of CEL_FUNCTIONS) {
|
|
73
77
|
const impl = fn.build(h);
|
|
74
78
|
// `register` lists one cel-js signature per arity (overloaded functions).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/templating",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Telo Templating - Engine registry and shared CEL core for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@marcbachmann/cel-js": "^7.6.1",
|
|
39
|
+
"re2js": "^2.8.3",
|
|
39
40
|
"uuid": "^10.0.0",
|
|
40
41
|
"yaml": "^2.8.3"
|
|
41
42
|
},
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
"@types/uuid": "^10.0.0",
|
|
45
46
|
"typescript": "^5.0.0",
|
|
46
47
|
"vitest": "^2.1.8",
|
|
47
|
-
"@telorun/sdk": "0.
|
|
48
|
+
"@telorun/sdk": "0.41.0"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
50
51
|
"@telorun/sdk": "*"
|
package/src/cel/catalog.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RE2JS } from "re2js";
|
|
1
2
|
import { v1, v3, v4, v5, v6, v7, validate as uuidValidate, version as uuidVersion } from "uuid";
|
|
2
3
|
|
|
3
4
|
/** Host-injected functions that need platform APIs the templating package must
|
|
@@ -15,6 +16,47 @@ export interface CelHandlers {
|
|
|
15
16
|
json: (value: unknown) => string;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/** RE2 regex engine for the CEL `regex*` functions — `re2js`, a pure-JS port of
|
|
20
|
+
* Google's RE2. The dialect is RE2 (linear-time, no backtracking, ReDoS-safe) —
|
|
21
|
+
* never JS `RegExp` — so a manifest's regex behaves the same across runtimes.
|
|
22
|
+
* Flags map a trailing option string to RE2 flags; inline `(?s)` etc. work in
|
|
23
|
+
* the pattern too.
|
|
24
|
+
*
|
|
25
|
+
* Why pure-JS and not a native RE2:
|
|
26
|
+
* - The Rust `regex` crate via napi (N-API) is faster, but shipping it means a
|
|
27
|
+
* cross-platform prebuild matrix + per-triple npm packages — a whole release
|
|
28
|
+
* subsystem for one function family.
|
|
29
|
+
* - The `re2` npm package (Google's C++ RE2) is simpler to depend on, but it's a
|
|
30
|
+
* nan/V8 addon and **does not load under Bun** (incomplete V8 C++ ABI →
|
|
31
|
+
* `undefined symbol`). Telo's CLI and test suite run on Bun, so that's a
|
|
32
|
+
* non-starter.
|
|
33
|
+
* `re2js` is pure JS: zero native addons, runs identically on Node, Bun, and the
|
|
34
|
+
* browser (keeps this package — and the analyzer — browser-safe), and needs no
|
|
35
|
+
* prebuilds. The cost is throughput vs. native, which is irrelevant for the
|
|
36
|
+
* small strings CEL manifests run regex over. */
|
|
37
|
+
const RE2_FLAG: Record<string, number> = {
|
|
38
|
+
i: RE2JS.CASE_INSENSITIVE,
|
|
39
|
+
m: RE2JS.MULTILINE,
|
|
40
|
+
s: RE2JS.DOTALL,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const compileRe2 = (fn: string, pattern: string, flags?: string): RE2JS => {
|
|
44
|
+
let bits = 0;
|
|
45
|
+
for (const c of flags ?? "") {
|
|
46
|
+
if (c === "g") continue; // global is implicit in replaceAll / find-loop
|
|
47
|
+
const bit = RE2_FLAG[c];
|
|
48
|
+
if (bit === undefined) throw new Error(`${fn}: unknown regex flag '${c}' (supported: i, m, s)`);
|
|
49
|
+
bits |= bit;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return RE2JS.compile(pattern, bits);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`${fn}: invalid RE2 pattern ${JSON.stringify(pattern)}: ${e instanceof Error ? e.message : String(e)}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
18
60
|
export type CelFunctionCategory =
|
|
19
61
|
| "conversion"
|
|
20
62
|
| "time"
|
|
@@ -73,11 +115,6 @@ const minMax = (list: unknown[], isMin: boolean): unknown => {
|
|
|
73
115
|
return best;
|
|
74
116
|
};
|
|
75
117
|
|
|
76
|
-
/** Ensure a regex replaces every match: append the global flag unless the
|
|
77
|
-
* caller already passed it. */
|
|
78
|
-
const withGlobal = (flags?: string): string =>
|
|
79
|
-
flags && flags.includes("g") ? flags : `${flags ?? ""}g`;
|
|
80
|
-
|
|
81
118
|
const sortList = (list: unknown[]): unknown[] =>
|
|
82
119
|
[...list].sort((a, b) => {
|
|
83
120
|
if (typeof a === "number" || typeof a === "bigint") {
|
|
@@ -270,41 +307,51 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
|
|
|
270
307
|
name: "regexReplace",
|
|
271
308
|
signature: "regexReplace(string, string, string, string?): string",
|
|
272
309
|
category: "string",
|
|
273
|
-
summary:
|
|
310
|
+
summary:
|
|
311
|
+
"Replace every regex match (RE2 syntax) with a replacement ($1 backrefs); flags like 'i', 'm', 's'.",
|
|
274
312
|
deterministic: true,
|
|
275
313
|
hostBacked: false,
|
|
276
314
|
build: () => (s: string, pattern: string, replacement: string, flags?: string) =>
|
|
277
|
-
|
|
315
|
+
compileRe2("regexReplace", pattern, flags).matcher(s).replaceAll(replacement),
|
|
278
316
|
},
|
|
279
317
|
{
|
|
280
318
|
name: "regexExtract",
|
|
281
|
-
signature: "regexExtract(string, string): string",
|
|
319
|
+
signature: "regexExtract(string, string, string?): string",
|
|
282
320
|
category: "string",
|
|
283
|
-
summary: "First whole match of a regex, or '' when there is none.",
|
|
321
|
+
summary: "First whole match of a regex (RE2 syntax), or '' when there is none.",
|
|
284
322
|
deterministic: true,
|
|
285
323
|
hostBacked: false,
|
|
286
|
-
build: () => (s: string, pattern: string
|
|
324
|
+
build: () => (s: string, pattern: string, flags?: string) => {
|
|
325
|
+
const m = compileRe2("regexExtract", pattern, flags).matcher(s);
|
|
326
|
+
return m.find() ? (m.group() ?? "") : "";
|
|
327
|
+
},
|
|
287
328
|
},
|
|
288
329
|
{
|
|
289
330
|
name: "regexExtractAll",
|
|
290
|
-
signature: "regexExtractAll(string, string): list<string>",
|
|
331
|
+
signature: "regexExtractAll(string, string, string?): list<string>",
|
|
291
332
|
category: "string",
|
|
292
|
-
summary: "Every whole match of a regex, in order.",
|
|
333
|
+
summary: "Every whole match of a regex (RE2 syntax), in order.",
|
|
293
334
|
deterministic: true,
|
|
294
335
|
hostBacked: false,
|
|
295
|
-
build: () => (s: string, pattern: string) =>
|
|
296
|
-
|
|
336
|
+
build: () => (s: string, pattern: string, flags?: string) => {
|
|
337
|
+
const m = compileRe2("regexExtractAll", pattern, flags).matcher(s);
|
|
338
|
+
const out: string[] = [];
|
|
339
|
+
while (m.find()) out.push(m.group() ?? "");
|
|
340
|
+
return out;
|
|
341
|
+
},
|
|
297
342
|
},
|
|
298
343
|
{
|
|
299
344
|
name: "regexGroups",
|
|
300
|
-
signature: "regexGroups(string, string): list<string>",
|
|
345
|
+
signature: "regexGroups(string, string, string?): list<string>",
|
|
301
346
|
category: "string",
|
|
302
|
-
summary:
|
|
347
|
+
summary:
|
|
348
|
+
"Capture groups of the first regex match (RE2 syntax); empty list when there is no match.",
|
|
303
349
|
deterministic: true,
|
|
304
350
|
hostBacked: false,
|
|
305
|
-
build: () => (s: string, pattern: string) => {
|
|
306
|
-
const m =
|
|
307
|
-
|
|
351
|
+
build: () => (s: string, pattern: string, flags?: string) => {
|
|
352
|
+
const m = compileRe2("regexGroups", pattern, flags).matcher(s);
|
|
353
|
+
if (!m.find()) return [];
|
|
354
|
+
return Array.from({ length: m.groupCount() }, (_unused, i) => m.group(i + 1) ?? "");
|
|
308
355
|
},
|
|
309
356
|
},
|
|
310
357
|
{
|
package/src/cel/compile.ts
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import { isCompiledValue, type CompiledValue } from "@telorun/sdk";
|
|
2
2
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
3
|
+
import { extractAccessChains } from "./analyze.js";
|
|
3
4
|
|
|
4
5
|
export const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
|
|
5
6
|
export const EXACT_TEMPLATE_REGEX = /^\s*\$\{\{\s*([^}]+?)\s*\}\}\s*$/;
|
|
6
7
|
|
|
8
|
+
/** Root variable identifiers an expression reads, from its parsed AST — the
|
|
9
|
+
* first element of every member-access chain (`self.table` → `self`). Returns
|
|
10
|
+
* undefined when the parse result carries no AST so consumers can tell "no
|
|
11
|
+
* refs" from "unknown". */
|
|
12
|
+
function rootRefs(parsed: unknown): readonly string[] | undefined {
|
|
13
|
+
const ast = (parsed as { ast?: unknown }).ast;
|
|
14
|
+
if (!ast) return undefined;
|
|
15
|
+
const roots = new Set<string>();
|
|
16
|
+
for (const chain of extractAccessChains(ast as never)) {
|
|
17
|
+
if (chain.length > 0) roots.add(chain[0]!);
|
|
18
|
+
}
|
|
19
|
+
return [...roots];
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
/** Compile a single CEL expression (no `${{ }}` wrapping) into a CompiledValue.
|
|
8
23
|
* Throws on syntax errors. Used by the `!cel` engine where the entire tagged
|
|
9
24
|
* scalar is treated as one expression. */
|
|
@@ -12,6 +27,7 @@ export function compileExpression(expr: string, env: Environment): CompiledValue
|
|
|
12
27
|
return {
|
|
13
28
|
__compiled: true,
|
|
14
29
|
source: expr,
|
|
30
|
+
refs: rootRefs(fn),
|
|
15
31
|
call: (ctx: Record<string, unknown>) => fn(ctx),
|
|
16
32
|
};
|
|
17
33
|
}
|
|
@@ -38,10 +54,16 @@ export function compileString(s: string, env: Environment): unknown {
|
|
|
38
54
|
}
|
|
39
55
|
if (last < s.length) parts.push(s.slice(last));
|
|
40
56
|
|
|
57
|
+
const refs = new Set<string>();
|
|
58
|
+
for (const p of parts) {
|
|
59
|
+
if (typeof p !== "string" && p.refs) for (const r of p.refs) refs.add(r);
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
return {
|
|
42
63
|
__compiled: true,
|
|
43
64
|
source: s,
|
|
44
65
|
parts,
|
|
66
|
+
refs: [...refs],
|
|
45
67
|
call: (ctx: Record<string, unknown>) =>
|
|
46
68
|
parts.map((p) => (typeof p === "string" ? p : String(p.call(ctx) ?? ""))).join(""),
|
|
47
69
|
} satisfies CompiledValue;
|
package/src/cel/environment.ts
CHANGED
|
@@ -73,7 +73,11 @@ export function deriveSignatures(signature: string): string[] {
|
|
|
73
73
|
|
|
74
74
|
export function buildCelEnvironment(handlers: Partial<CelHandlers> = {}): Environment {
|
|
75
75
|
const h: CelHandlers = { ...STUB_HANDLERS, ...handlers };
|
|
76
|
-
|
|
76
|
+
// cel-go defaults HomogeneousAggregateLiterals OFF: heterogeneous list/map
|
|
77
|
+
// literals unify to `dyn` rather than erroring. cel-js flips that default to
|
|
78
|
+
// strict; we align with cel-go so manifests (dyn-heavy: request, rows, …)
|
|
79
|
+
// don't hit false positives the runtime evaluates fine.
|
|
80
|
+
let env = new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true, homogeneousAggregateLiterals: false });
|
|
77
81
|
for (const fn of CEL_FUNCTIONS) {
|
|
78
82
|
const impl = fn.build(h);
|
|
79
83
|
// `register` lists one cel-js signature per arity (overloaded functions).
|