@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 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.yaml
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
- Here is an example Telo application that defines a simple HTTP API:
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":"AAEA;;;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;AAED,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;AA8E5D,eAAO,MAAM,aAAa,EAAE,SAAS,cAAc,EAoelD,CAAC;AAEF,oEAAoE;AACpE,wBAAgB,kBAAkB,IAAI,eAAe,EAAE,CAEtD"}
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"}
@@ -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) => s.replace(new RegExp(pattern, withGlobal(flags)), replacement),
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) => s.match(new RegExp(pattern))?.[0] ?? "",
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) => [...s.matchAll(new RegExp(pattern, "g"))].map((m) => m[0]),
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 = s.match(new RegExp(pattern));
236
- return m ? m.slice(1).map((g) => g ?? "") : [];
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;AAExD,eAAO,MAAM,cAAc,QAA8B,CAAC;AAC1D,eAAO,MAAM,oBAAoB,QAAqC,CAAC;AAEvE;;2CAE2C;AAC3C,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,aAAa,CAO/E;AAED;;;;+DAI+D;AAC/D,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAwBlE;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"}
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"}
@@ -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,CAcpF"}
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"}
@@ -68,7 +68,11 @@ export function deriveSignatures(signature) {
68
68
  }
69
69
  export function buildCelEnvironment(handlers = {}) {
70
70
  const h = { ...STUB_HANDLERS, ...handlers };
71
- let env = new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true });
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.9.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.26.0"
48
+ "@telorun/sdk": "0.41.0"
48
49
  },
49
50
  "peerDependencies": {
50
51
  "@telorun/sdk": "*"
@@ -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: "Replace every regex match with a replacement ($1 backrefs); flags like 'i', 'm', 's'.",
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
- s.replace(new RegExp(pattern, withGlobal(flags)), replacement),
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) => s.match(new RegExp(pattern))?.[0] ?? "",
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
- [...s.matchAll(new RegExp(pattern, "g"))].map((m) => m[0]),
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: "Capture groups of the first regex match (empty list when there is no match).",
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 = s.match(new RegExp(pattern));
307
- return m ? m.slice(1).map((g) => g ?? "") : [];
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
  {
@@ -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;
@@ -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
- let env = new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true });
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).