@telorun/templating 0.17.0 → 0.19.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,3 +1,4 @@
1
+ import { formatLocale } from "d3-format";
1
2
  import { RE2JS } from "re2js";
2
3
  import { v1, v3, v4, v5, v6, v7, validate as uuidValidate, version as uuidVersion } from "uuid";
3
4
 
@@ -67,6 +68,7 @@ export type CelFunctionCategory =
67
68
  | "json"
68
69
  | "encoding"
69
70
  | "hashing"
71
+ | "formatting"
70
72
  | "null";
71
73
 
72
74
  /** One entry in the CEL standard library — the single source of truth that both
@@ -94,8 +96,36 @@ export interface CelFunctionDoc {
94
96
  * analyzer's stub throws if such a function is actually evaluated. */
95
97
  readonly hostBacked: boolean;
96
98
  readonly build: (h: CelHandlers) => (...args: any[]) => unknown;
99
+ /**
100
+ * Check the arguments that were written as LITERALS, at analysis time.
101
+ *
102
+ * A type is all a signature can constrain, so a guard over a value —
103
+ * an unparseable format specifier, a decimal count out of range, a day length
104
+ * of zero, an unknown IANA zone — fires only when the expression is evaluated.
105
+ * That puts a defect the manifest states in plain sight behind a run, which is
106
+ * the opposite of what static analysis is for.
107
+ *
108
+ * `literals[i]` is the value of argument `i` when it was written as a literal,
109
+ * and `undefined` when it is an expression whose value is not statically
110
+ * known — so a checker MUST skip an `undefined` rather than judge it.
111
+ * Returns a message, or `undefined` when there is nothing to report.
112
+ *
113
+ * Implementations call the SAME guard the runtime calls, so the static and
114
+ * dynamic answers cannot drift into disagreement.
115
+ */
116
+ readonly checkArgs?: (literals: readonly unknown[]) => string | undefined;
97
117
  }
98
118
 
119
+ /** Run a runtime guard for its refusal, so a `checkArgs` never restates one. */
120
+ const literalGuard = (run: () => void): string | undefined => {
121
+ try {
122
+ run();
123
+ return undefined;
124
+ } catch (e) {
125
+ return e instanceof Error ? e.message : String(e);
126
+ }
127
+ };
128
+
99
129
  /** Public, build-free view of a catalog entry (for `--json` / docs). */
100
130
  export type CelFunctionInfo = Omit<CelFunctionDoc, "build">;
101
131
 
@@ -124,6 +154,152 @@ const sortList = (list: unknown[]): unknown[] =>
124
154
  return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
125
155
  });
126
156
 
157
+ /** The number-formatting locale, pinned rather than defaulted.
158
+ *
159
+ * d3-format's default locale renders a negative with U+2212 MINUS SIGN, so
160
+ * `format(-1.5, '.2f')` is `"−1.50"` and not `"-1.50"` — a string that no
161
+ * downstream parser, comparison or diff treats as the number it looks like.
162
+ * Every field here is fixed to its ASCII form for the same reason the layer is
163
+ * locale-free at all: the same manifest must render the same bytes on every
164
+ * runtime, and a second engine implementing the specifier grammar has to be
165
+ * able to reproduce these exactly. */
166
+ const FORMAT_LOCALE = formatLocale({
167
+ decimal: ".",
168
+ thousands: ",",
169
+ grouping: [3],
170
+ currency: ["$", ""],
171
+ minus: "-",
172
+ percent: "%",
173
+ nan: "NaN",
174
+ });
175
+
176
+ /** Largest integer a double represents exactly. */
177
+ const MAX_EXACT_INT = 9007199254740991n;
178
+
179
+ /** A CEL `int` is a BigInt in this runtime and `d3-format` throws on one
180
+ * outright, so a formattable argument is converted here. Past 2^53 a double
181
+ * stops representing every integer, and silently emitting a number that is not
182
+ * the one the author computed is the defect class this family exists to close —
183
+ * so that case raises instead. */
184
+ const formattable = (fn: string, x: unknown): number => {
185
+ if (typeof x === "bigint") {
186
+ if (x > MAX_EXACT_INT || x < -MAX_EXACT_INT) {
187
+ throw new Error(
188
+ `${fn}: integer ${x} exceeds 2^53-1 and cannot be formatted exactly as a double`,
189
+ );
190
+ }
191
+ return Number(x);
192
+ }
193
+ const n = Number(x);
194
+ // The runtime backstop behind the typed registrations. A value that is not a
195
+ // number formats as the string "NaN", which is the failure this family exists
196
+ // to remove: it looks like an answer and prints into a document. Named here
197
+ // rather than coerced, the way an instant argument is.
198
+ if (!Number.isFinite(n)) {
199
+ throw new Error(`${fn}: expected a finite number, got ${JSON.stringify(x)}`);
200
+ }
201
+ return n;
202
+ };
203
+
204
+ /** Specifier type characters d3 implements. An unknown one PARSES — `.2q`
205
+ * yields `"1"` rather than throwing — so a typo would silently format against
206
+ * the default type. The set is checked here so a bad specifier is refused
207
+ * rather than quietly answered. */
208
+ const FORMAT_TYPES = new Set([..."efgrs%pbodxXcn"]);
209
+
210
+ /** A specifier is a CEL value, so it can be request-derived — an `Http.Server`
211
+ * evaluating `format(x, request.query.spec)` would otherwise grow this map for
212
+ * the life of the process, and it is module-global, so every in-process kernel
213
+ * shares it. Cleared wholesale at the cap rather than evicted one at a time: a
214
+ * manifest's real specifier set is a handful of constants that repopulate
215
+ * immediately, and an LRU is machinery for a hit rate nothing here needs. */
216
+ const FORMATTER_CACHE_MAX = 256;
217
+ const formatterCache = new Map<string, (n: number) => string>();
218
+
219
+ const formatter = (fn: string, spec: unknown): ((n: number) => string) => {
220
+ const text = String(spec);
221
+ const cached = formatterCache.get(text);
222
+ if (cached) return cached;
223
+ const type = text.slice(-1);
224
+ if (text !== "" && /[a-zA-Z%]/.test(type) && !FORMAT_TYPES.has(type)) {
225
+ throw new Error(`${fn}: unknown format type '${type}' (one of ${[...FORMAT_TYPES].join("")})`);
226
+ }
227
+ // The `.precision` group — width is the digits BEFORE the dot, so this is the
228
+ // only `.`-digits sequence the grammar admits.
229
+ const precision = /\.(\d+)/.exec(text);
230
+ if (precision) digitCount(fn, precision[1]);
231
+ let built: (n: number) => string;
232
+ try {
233
+ built = FORMAT_LOCALE.format(text);
234
+ } catch {
235
+ throw new Error(`${fn}: invalid format specifier ${JSON.stringify(text)}`);
236
+ }
237
+ if (formatterCache.size >= FORMATTER_CACHE_MAX) formatterCache.clear();
238
+ formatterCache.set(text, built);
239
+ return built;
240
+ };
241
+
242
+ /** Decimal places, bounded. The ceiling is well below what `toFixed` accepts
243
+ * because past it the digits are an artefact of the binary representation
244
+ * rather than of the value.
245
+ *
246
+ * ONE rule, enforced wherever a precision is written: `formatter` applies it to
247
+ * a specifier's `.precision` group too. Bounding only this spelling let an
248
+ * author route around the guard by writing `format(x, '.11f')` instead of
249
+ * `fixed(x, 11)` — the family giving two answers to one question. It is not the
250
+ * grammar subsetting the "full d3 surface" decision refuses: every specifier
251
+ * type and flag stays available, and only the digit count is capped. */
252
+ const MAX_DECIMALS = 10;
253
+
254
+ const digitCount = (fn: string, digits: unknown): number => {
255
+ const n = Number(digits);
256
+ if (!Number.isInteger(n) || n < 0 || n > MAX_DECIMALS) {
257
+ throw new Error(
258
+ `${fn}: decimal places must be an integer 0-${MAX_DECIMALS}, got ${String(digits)}`,
259
+ );
260
+ }
261
+ return n;
262
+ };
263
+
264
+ /** Render a minute count against a declared day length. The day is a policy
265
+ * argument, never an assumption — see the catalog entry's summary. */
266
+ const durationText = (minutes: unknown, minutesPerDay: unknown): string => {
267
+ const perDay = Math.round(formattable("formatDuration", minutesPerDay));
268
+ if (!Number.isFinite(perDay) || perDay <= 0) {
269
+ throw new Error(`formatDuration: minutesPerDay must be a positive number, got ${perDay}`);
270
+ }
271
+ const total = Math.round(formattable("formatDuration", minutes));
272
+ if (!Number.isFinite(total)) {
273
+ throw new Error(`formatDuration: minutes must be a finite number`);
274
+ }
275
+ const magnitude = Math.abs(total);
276
+ const days = Math.floor(magnitude / perDay);
277
+ const withinDay = magnitude % perDay;
278
+ const hours = Math.floor(withinDay / 60);
279
+ const mins = withinDay % 60;
280
+ const parts: string[] = [];
281
+ if (days) parts.push(`${days}d`);
282
+ if (hours) parts.push(`${hours}h`);
283
+ if (mins) parts.push(`${mins}m`);
284
+ if (parts.length === 0) parts.push("0m");
285
+ return `${total < 0 ? "-" : ""}${parts.join(" ")}`;
286
+ };
287
+
288
+ /** Refuse an unknown zone in this family's own voice. Left to `Intl`, the
289
+ * failure is a raw `RangeError` naming neither the function nor what was
290
+ * wrong with the argument — the only refusal here that did not read
291
+ * `<fn>: <what is wrong>`, and whose wording belongs to the JS engine rather
292
+ * than to Telo. Also the guard `checkArgs` runs at analysis time, so a literal
293
+ * zone is checked once and answered identically in both places. */
294
+ const assertZone = (fn: string, tz: string): string => {
295
+ try {
296
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
297
+ } catch {
298
+ throw new Error(`${fn}: unknown IANA time zone ${JSON.stringify(tz)}`);
299
+ }
300
+ return tz;
301
+ };
302
+
127
303
  /** `Intl.DateTimeFormat` is an ECMA-402 global in Node (full ICU) and browsers,
128
304
  * so timezone handling needs no Node-only API and stays browser-safe. */
129
305
  const zoneParts = (date: Date, tz: string, opts: Intl.DateTimeFormatOptions): Record<string, string> => {
@@ -138,8 +314,7 @@ const zoneParts = (date: Date, tz: string, opts: Intl.DateTimeFormatOptions): Re
138
314
  * offset (e.g. `2026-06-06T18:30:00.000-05:00`). Uses only standard Intl
139
315
  * fields and derives the offset arithmetically, so it needs no newer Intl
140
316
  * type-lib features and stays portable. */
141
- const isoInZone = (tz: string): string => {
142
- const now = new Date();
317
+ const isoInZone = (now: Date, tz: string): string => {
143
318
  if (tz === "UTC" || tz === "Z") return now.toISOString();
144
319
  const p = zoneParts(now, tz, {
145
320
  hourCycle: "h23",
@@ -162,14 +337,129 @@ const isoInZone = (tz: string): string => {
162
337
  return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}.${ms}${offset}`;
163
338
  };
164
339
 
165
- /** Current calendar date (`YYYY-MM-DD`) in `tz`. */
166
- const dateInZone = (tz: string): string => {
167
- const now = new Date();
340
+ /** Calendar date (`YYYY-MM-DD`) of an instant in `tz`. */
341
+ const dateInZone = (now: Date, tz: string): string => {
168
342
  if (tz === "UTC" || tz === "Z") return now.toISOString().slice(0, 10);
169
343
  const p = zoneParts(now, tz, { year: "numeric", month: "2-digit", day: "2-digit" });
170
344
  return `${p.year}-${p.month}-${p.day}`;
171
345
  };
172
346
 
347
+ /** An instant is only a date once a zone is chosen, so every calendar function
348
+ * reads its fields through one of these. `Intl` rejects an unknown zone, which
349
+ * is what turns a typo into an error rather than a silently-UTC answer. */
350
+ interface ZonedFields {
351
+ year: number;
352
+ month: number;
353
+ day: number;
354
+ hour: number;
355
+ minute: number;
356
+ second: number;
357
+ }
358
+
359
+ const zonedFields = (date: Date, tz: string): ZonedFields => {
360
+ const p = zoneParts(date, tz, {
361
+ hourCycle: "h23",
362
+ year: "numeric",
363
+ month: "2-digit",
364
+ day: "2-digit",
365
+ hour: "2-digit",
366
+ minute: "2-digit",
367
+ second: "2-digit",
368
+ });
369
+ return {
370
+ year: +p.year!,
371
+ month: +p.month!,
372
+ day: +p.day!,
373
+ hour: +p.hour!,
374
+ minute: +p.minute!,
375
+ second: +p.second!,
376
+ };
377
+ };
378
+
379
+ /** The zone's offset at `date`, in milliseconds — its wall clock read as UTC,
380
+ * minus the real instant. The same arithmetic `isoInZone` does. */
381
+ const zoneOffsetMs = (date: Date, tz: string): number => {
382
+ const f = zonedFields(date, tz);
383
+ return Date.UTC(f.year, f.month - 1, f.day, f.hour, f.minute, f.second) - date.getTime();
384
+ };
385
+
386
+ const sameWallClock = (a: ZonedFields, b: ZonedFields): boolean =>
387
+ a.year === b.year &&
388
+ a.month === b.month &&
389
+ a.day === b.day &&
390
+ a.hour === b.hour &&
391
+ a.minute === b.minute &&
392
+ a.second === b.second;
393
+
394
+ /** The instant whose wall clock in `tz` is the given fields.
395
+ *
396
+ * The offset depends on the instant being solved for, so this takes the offset
397
+ * at the UTC reading, corrects, and then CHECKS by reading the result back.
398
+ * That check is the whole point: a plain fixpoint settles on an instant whose
399
+ * wall clock is not the one asked for whenever the requested time does not
400
+ * exist, and it settles BACKWARDS — which silently moves the calendar day, the
401
+ * one thing `addMonths` and `startOfMonth` exist to control. Chile jumps
402
+ * 00:00 → 01:00 on 2026-09-06 and Cuba on 2026-03-08, so "the 6th at midnight"
403
+ * there is not a time; a fixpoint answered "the 5th at 23:00".
404
+ *
405
+ * Resolution follows Java's `ZonedDateTime` and Temporal's `compatible`:
406
+ * a wall clock that exists twice (a fall-back) takes the EARLIER instant, and
407
+ * one that does not exist (a spring-forward gap) shifts FORWARD out of the gap,
408
+ * which keeps the requested day. */
409
+ const instantOfZoned = (f: ZonedFields, tz: string): Date => {
410
+ const asUtc = Date.UTC(f.year, f.month - 1, f.day, f.hour, f.minute, f.second);
411
+ const offsetA = zoneOffsetMs(new Date(asUtc), tz);
412
+ const candidateA = asUtc - offsetA;
413
+ const offsetB = zoneOffsetMs(new Date(candidateA), tz);
414
+ if (offsetA === offsetB) return new Date(candidateA);
415
+
416
+ const candidateB = asUtc - offsetB;
417
+ const aHolds = sameWallClock(zonedFields(new Date(candidateA), tz), f);
418
+ const bHolds = sameWallClock(zonedFields(new Date(candidateB), tz), f);
419
+ if (aHolds && bHolds) return new Date(Math.min(candidateA, candidateB));
420
+ if (aHolds) return new Date(candidateA);
421
+ if (bHolds) return new Date(candidateB);
422
+ return new Date(Math.max(candidateA, candidateB));
423
+ };
424
+
425
+ /** `Date.UTC` maps years 0-99 to 1900-1999, so the year is set explicitly. */
426
+ const daysInMonth = (year: number, month: number): number => {
427
+ const d = new Date(Date.UTC(2000, month, 0));
428
+ d.setUTCFullYear(year, month, 0);
429
+ return d.getUTCDate();
430
+ };
431
+
432
+ /** An instant argument arrives as a `Date`; anything else is a caller error the
433
+ * type-checker did not catch (a `dyn` slot), so it is named rather than
434
+ * coerced into an Invalid Date that formats as `NaN`. */
435
+ const instantArg = (fn: string, v: unknown): Date => {
436
+ if (v instanceof Date && Number.isFinite(v.getTime())) return v;
437
+ throw new Error(`${fn}: expected a timestamp`);
438
+ };
439
+
440
+ /** Drop entries whose value is null or the empty string. Nothing else: an empty
441
+ * list or map is a value someone deliberately built. CEL hands a map over as a
442
+ * plain object or a `Map` depending on how it was produced, so both are read. */
443
+ const compactValue = (v: unknown): unknown => {
444
+ const keep = (x: unknown): boolean => x !== null && x !== undefined && x !== "";
445
+ if (Array.isArray(v)) return v.filter(keep);
446
+ if (v instanceof Map) {
447
+ return new Map([...v.entries()].filter(([, value]) => keep(value)));
448
+ }
449
+ // A PLAIN object only. Rebuilding an arbitrary object from its entries is how
450
+ // a byte buffer becomes `{"0":137,…}` and an instant becomes `{}` — silently,
451
+ // and looking like a value. The same rule the compile walker follows, and the
452
+ // same "name it rather than coerce it" the instant argument follows.
453
+ if (v !== null && typeof v === "object") {
454
+ const proto = Object.getPrototypeOf(v);
455
+ if (proto !== Object.prototype && proto !== null) {
456
+ throw new Error(`compact: expected a map or a list, got ${v.constructor?.name ?? "an object"}`);
457
+ }
458
+ return Object.fromEntries(Object.entries(v as Record<string, unknown>).filter(([, value]) => keep(value)));
459
+ }
460
+ throw new Error(`compact: expected a map or a list, got ${JSON.stringify(v)}`);
461
+ };
462
+
173
463
  const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
174
464
 
175
465
  /** Base64 → bytes, written out rather than delegated: `Buffer` is not browser-
@@ -489,12 +779,31 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
489
779
  },
490
780
  {
491
781
  name: "round",
492
- signature: "round(dyn): double",
782
+ signature: "round(dyn, int?): double",
783
+ register: [
784
+ "round(double): double",
785
+ "round(int): double",
786
+ "round(double, int): double",
787
+ "round(int, int): double",
788
+ ],
493
789
  category: "math",
494
- summary: "Round to the nearest integer.",
790
+ summary:
791
+ "Round to the nearest integer, or to the given number of decimal places (0–10). The two-argument form shares the formatter's rounding rule, so a rounded value and the cell rendered beside it agree at the boundary. An integer past 2^53 is refused rather than rounded to a neighbour.",
495
792
  deterministic: true,
496
793
  hostBacked: false,
497
- build: () => (x: unknown) => Math.round(num(x)),
794
+ // Both arities go through `formattable`, so the 2^53 refusal does not depend
795
+ // on which one the author wrote. Guarding only the two-argument form left
796
+ // `round(x)` silently answering with a neighbouring integer — the defect
797
+ // this family exists to close, reachable by writing one fewer argument.
798
+ build: () => (x: unknown, digits?: unknown) =>
799
+ digits === undefined
800
+ ? Math.round(formattable("round", x))
801
+ : Number(formattable("round", x).toFixed(digitCount("round", digits))),
802
+ checkArgs: (lit) =>
803
+ literalGuard(() => {
804
+ if (lit[1] !== undefined) digitCount("round", lit[1]);
805
+ if (typeof lit[0] === "bigint") formattable("round", lit[0]);
806
+ }),
498
807
  },
499
808
  {
500
809
  name: "min",
@@ -694,7 +1003,7 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
694
1003
  summary: "Current time as ISO-8601; UTC by default, or in the given IANA timezone.",
695
1004
  deterministic: false,
696
1005
  hostBacked: false,
697
- build: () => (tz?: string) => isoInZone(tz ?? "UTC"),
1006
+ build: () => (tz?: string) => isoInZone(new Date(), tz ?? "UTC"),
698
1007
  },
699
1008
  {
700
1009
  name: "today",
@@ -703,7 +1012,7 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
703
1012
  summary: "Current calendar date (YYYY-MM-DD); UTC by default, or in the given IANA timezone.",
704
1013
  deterministic: false,
705
1014
  hostBacked: false,
706
- build: () => (tz?: string) => dateInZone(tz ?? "UTC"),
1015
+ build: () => (tz?: string) => dateInZone(new Date(), tz ?? "UTC"),
707
1016
  },
708
1017
  {
709
1018
  name: "nowMillis",
@@ -750,6 +1059,163 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
750
1059
  hostBacked: false,
751
1060
  build: () => (t: Date) => BigInt(Math.floor(t.getTime() / 1000)),
752
1061
  },
1062
+ // Formatting. The number surface is the d3-format specifier grammar in full,
1063
+ // `[[fill]align][sign][symbol][0][width][,][.precision][~][type]`, so a chart
1064
+ // axis label and the table cell beside it cannot round the same value two
1065
+ // ways. Rounding is therefore d3's: `f` rounds the double at the decimal
1066
+ // place, so `.2f` of 1.005 is "1.00" — 1.005 is not representable and the
1067
+ // nearest double sits below the half.
1068
+ {
1069
+ name: "format",
1070
+ signature: "format(dyn, string): string",
1071
+ // Registered per numeric type rather than as `dyn`. A `dyn` first parameter
1072
+ // accepted a string and answered "NaN" — a value that looks like an answer
1073
+ // and prints into a document. A genuinely dynamic expression still passes,
1074
+ // because cel-js matches `dyn` against any declared parameter type; what
1075
+ // this rejects is a STATICALLY known wrong type, at `telo check`.
1076
+ register: ["format(double, string): string", "format(int, string): string"],
1077
+ category: "formatting",
1078
+ summary: "Format a number with a d3-format specifier (`.2f`, `,.2f`, `.1%`, `.2s`).",
1079
+ deterministic: true,
1080
+ hostBacked: false,
1081
+ build: () => (x: unknown, spec: unknown) => formatter("format", spec)(formattable("format", x)),
1082
+ checkArgs: (lit) =>
1083
+ literalGuard(() => {
1084
+ if (lit[1] !== undefined) formatter("format", lit[1]);
1085
+ if (typeof lit[0] === "bigint") formattable("format", lit[0]);
1086
+ }),
1087
+ },
1088
+ {
1089
+ name: "fixed",
1090
+ signature: "fixed(dyn, int): string",
1091
+ register: ["fixed(double, int): string", "fixed(int, int): string"],
1092
+ category: "formatting",
1093
+ summary: "Fixed-decimal string with the given number of places (0–10).",
1094
+ deterministic: true,
1095
+ hostBacked: false,
1096
+ build: () => (x: unknown, digits: unknown) =>
1097
+ formatter("fixed", `.${digitCount("fixed", digits)}f`)(formattable("fixed", x)),
1098
+ checkArgs: (lit) =>
1099
+ literalGuard(() => {
1100
+ if (lit[1] !== undefined) digitCount("fixed", lit[1]);
1101
+ if (typeof lit[0] === "bigint") formattable("fixed", lit[0]);
1102
+ }),
1103
+ },
1104
+ {
1105
+ name: "formatDuration",
1106
+ signature: "formatDuration(dyn, int): string",
1107
+ register: [
1108
+ "formatDuration(double, int): string",
1109
+ "formatDuration(int, int): string",
1110
+ ],
1111
+ category: "formatting",
1112
+ summary:
1113
+ "Render a minute count against a declared day length: `formatDuration(510, 480)` is `1d 30m`. The day length is an argument because it is a policy, not arithmetic. The result is a rendering for a reader, not a duration literal — its `d` is the declared day, so it must not be fed back into a field that parses a duration.",
1114
+ deterministic: true,
1115
+ hostBacked: false,
1116
+ build: () => (minutes: unknown, minutesPerDay: unknown) => durationText(minutes, minutesPerDay),
1117
+ checkArgs: (lit) =>
1118
+ literalGuard(() => {
1119
+ if (lit[1] !== undefined) durationText(lit[0] ?? 0, lit[1]);
1120
+ }),
1121
+ },
1122
+ {
1123
+ name: "dateIn",
1124
+ signature: "dateIn(timestamp, string?): string",
1125
+ register: [
1126
+ "dateIn(google.protobuf.Timestamp): string",
1127
+ "dateIn(google.protobuf.Timestamp, string): string",
1128
+ ],
1129
+ category: "time",
1130
+ summary: "Calendar date (`YYYY-MM-DD`) of an instant, in an IANA zone (UTC by default).",
1131
+ deterministic: true,
1132
+ hostBacked: false,
1133
+ build: () => (t: unknown, tz?: string) => dateInZone(instantArg("dateIn", t), assertZone("dateIn", tz ?? "UTC")),
1134
+ checkArgs: (lit) =>
1135
+ literalGuard(() => {
1136
+ if (typeof lit[1] === "string") assertZone("dateIn", lit[1]);
1137
+ }),
1138
+ },
1139
+ {
1140
+ name: "isoIn",
1141
+ signature: "isoIn(timestamp, string?): string",
1142
+ register: [
1143
+ "isoIn(google.protobuf.Timestamp): string",
1144
+ "isoIn(google.protobuf.Timestamp, string): string",
1145
+ ],
1146
+ category: "time",
1147
+ summary: "ISO-8601 rendering of an instant, in an IANA zone (UTC by default).",
1148
+ deterministic: true,
1149
+ hostBacked: false,
1150
+ build: () => (t: unknown, tz?: string) => isoInZone(instantArg("isoIn", t), assertZone("isoIn", tz ?? "UTC")),
1151
+ checkArgs: (lit) =>
1152
+ literalGuard(() => {
1153
+ if (typeof lit[1] === "string") assertZone("isoIn", lit[1]);
1154
+ }),
1155
+ },
1156
+ {
1157
+ name: "startOfMonth",
1158
+ signature: "startOfMonth(timestamp, string?): timestamp",
1159
+ register: [
1160
+ "startOfMonth(google.protobuf.Timestamp): google.protobuf.Timestamp",
1161
+ "startOfMonth(google.protobuf.Timestamp, string): google.protobuf.Timestamp",
1162
+ ],
1163
+ category: "time",
1164
+ summary: "Midnight on the 1st of the instant's month, in an IANA zone (UTC by default).",
1165
+ deterministic: true,
1166
+ hostBacked: false,
1167
+ checkArgs: (lit) =>
1168
+ literalGuard(() => {
1169
+ if (typeof lit[1] === "string") assertZone("startOfMonth", lit[1]);
1170
+ }),
1171
+ build: () => (t: unknown, tz?: string) => {
1172
+ const zone = assertZone("startOfMonth", tz ?? "UTC");
1173
+ const f = zonedFields(instantArg("startOfMonth", t), zone);
1174
+ return instantOfZoned(
1175
+ { year: f.year, month: f.month, day: 1, hour: 0, minute: 0, second: 0 },
1176
+ zone,
1177
+ );
1178
+ },
1179
+ },
1180
+ {
1181
+ name: "addMonths",
1182
+ signature: "addMonths(timestamp, int, string?): timestamp",
1183
+ register: [
1184
+ "addMonths(google.protobuf.Timestamp, int): google.protobuf.Timestamp",
1185
+ "addMonths(google.protobuf.Timestamp, int, string): google.protobuf.Timestamp",
1186
+ ],
1187
+ category: "time",
1188
+ summary:
1189
+ "Shift an instant by whole months in an IANA zone, clamping the day of month (Jan 31 + 1 month is Feb 28).",
1190
+ deterministic: true,
1191
+ hostBacked: false,
1192
+ checkArgs: (lit) =>
1193
+ literalGuard(() => {
1194
+ if (typeof lit[2] === "string") assertZone("addMonths", lit[2]);
1195
+ }),
1196
+ build: () => (t: unknown, months: unknown, tz?: string) => {
1197
+ const zone = assertZone("addMonths", tz ?? "UTC");
1198
+ const f = zonedFields(instantArg("addMonths", t), zone);
1199
+ const shifted = f.year * 12 + (f.month - 1) + Number(months);
1200
+ // `%` takes the dividend's sign in JS, so a negative total would yield
1201
+ // month 0. Unreachable for realistic dates and wrong for free otherwise.
1202
+ const year = Math.floor(shifted / 12);
1203
+ const month = (((shifted % 12) + 12) % 12) + 1;
1204
+ return instantOfZoned({ ...f, year, month, day: Math.min(f.day, daysInMonth(year, month)) }, zone);
1205
+ },
1206
+ },
1207
+ {
1208
+ name: "compact",
1209
+ signature: "compact(dyn): dyn",
1210
+ // A `dyn` parameter accepted an instant (yielding `{}`) and a byte buffer
1211
+ // (yielding `{"0":137,…}`), both silently and both passing `telo check`.
1212
+ register: ["compact(list): list", "compact(map): map"],
1213
+ category: "collection",
1214
+ summary: "Drop entries whose value is null or the empty string, from a map or a list.",
1215
+ deterministic: true,
1216
+ hostBacked: false,
1217
+ build: () => (v: unknown) => compactValue(v),
1218
+ },
753
1219
  // UUID
754
1220
  {
755
1221
  name: "uuidv1",
@@ -1,5 +1,5 @@
1
1
  import type { ASTNode, Environment } from "@marcbachmann/cel-js";
2
- import { CEL_FUNCTIONS } from "./catalog.js";
2
+ import { CEL_FUNCTIONS, type CelFunctionDoc } from "./catalog.js";
3
3
  import type { CallSite, DiagnosticFix, EngineDiagnostic } from "../engine.js";
4
4
 
5
5
  /** Classifies every function call in a CEL expression against the environment's
@@ -77,7 +77,7 @@ interface RawCall extends CallSite {
77
77
  * missing from here degrades to "no extra explanation" rather than to a false
78
78
  * error on valid CEL. That is what keeps a cel-js upgrade from turning a new
79
79
  * macro into a manifest this analyzer refuses. */
80
- const MACROS = new Set(["optMap", "optFlatMap"]);
80
+ const MACROS = new Set(["optMap", "optFlatMap", "bind"]);
81
81
 
82
82
  function isNode(v: unknown): v is ASTNode {
83
83
  return typeof v === "object" && v !== null && "op" in (v as Record<string, unknown>);
@@ -210,9 +210,35 @@ function signaturesOf(name: string, index: FunctionIndex): string[] {
210
210
  * field rather than carrying `undefined` into the diagnostic. */
211
211
  const withFix = (fix: DiagnosticFix | undefined): { fix?: DiagnosticFix } => (fix ? { fix } : {});
212
212
 
213
+ /** The literal value of an argument, or `undefined` when it is an expression
214
+ * whose value is not statically known. A negated numeric literal is one node
215
+ * out (`-1` parses as unary minus over a value), and reading it is what lets a
216
+ * bound like "0–10" catch the below-range case as well as the above. */
217
+ function literalOf(node: ASTNode): unknown {
218
+ if (node.op === "value") return node.args;
219
+ if (node.op === "-_") {
220
+ const inner = node.args;
221
+ if (isNode(inner) && inner.op === "value") {
222
+ const v = inner.args as unknown;
223
+ if (typeof v === "number") return -v;
224
+ if (typeof v === "bigint") return -v;
225
+ }
226
+ }
227
+ return undefined;
228
+ }
229
+
230
+ const ARG_CHECKS: ReadonlyMap<string, NonNullable<CelFunctionDoc["checkArgs"]>> = new Map(
231
+ CEL_FUNCTIONS.flatMap((f) => (f.checkArgs ? [[f.name, f.checkArgs] as const] : [])),
232
+ );
233
+
213
234
  export interface CallAudit {
214
235
  readonly diagnostics: readonly EngineDiagnostic[];
215
236
  readonly calls: readonly CallSite[];
237
+ /** Refusals decided from arguments written as literals. Reported whatever the
238
+ * type-checker said, unlike {@link CallAudit.diagnostics}: a call whose types
239
+ * are all correct and whose specifier is `.2q` type-checks perfectly, and is
240
+ * exactly the defect this catches. */
241
+ readonly argumentIssues: readonly EngineDiagnostic[];
216
242
  /** Names that resolve, but that no registered signature accepts as written.
217
243
  * The caller appends their signatures to a type-check failure it could not
218
244
  * otherwise explain. */
@@ -276,10 +302,26 @@ export function auditCalls(source: string, ast: ASTNode, env: Environment): Call
276
302
  unresolved.push(call.name);
277
303
  }
278
304
 
305
+ const argumentIssues: EngineDiagnostic[] = [];
306
+ for (const call of calls) {
307
+ const check = ARG_CHECKS.get(call.name);
308
+ if (!check) continue;
309
+ const message = check(call.args.map(literalOf));
310
+ // No span on an EngineDiagnostic, so the written call goes in the message —
311
+ // an expression with two calls to the same function is otherwise ambiguous.
312
+ if (message) {
313
+ argumentIssues.push({
314
+ code: "CEL_INVALID_ARGUMENT",
315
+ message: `${message} (in \`${source.slice(call.start, call.end)}\`)`,
316
+ });
317
+ }
318
+ }
319
+
279
320
  return {
280
321
  diagnostics,
281
322
  calls: calls.map(({ receiver: _receiver, args: _args, ...site }) => site),
282
323
  unresolved,
324
+ argumentIssues,
283
325
  };
284
326
  }
285
327
 
@@ -33,6 +33,12 @@ export function analyzeCelExpression(source: string, env: AnalyzeEnv): AnalyzeRe
33
33
 
34
34
  const audit = auditCalls(source, parsed.ast, env.celEnv);
35
35
 
36
+ // Reported whatever the type-checker concludes. A literal argument a guard
37
+ // will refuse is a defect the manifest states outright, and leaving it to the
38
+ // run is the thing static analysis exists to prevent — so it is not gated on
39
+ // `checkError` the way the call audit is.
40
+ out.push(...audit.argumentIssues);
41
+
36
42
  let type: string | undefined;
37
43
  let checkError: string | undefined;
38
44
  try {
@@ -25,6 +25,13 @@ export const sqlEngine: TemplatingEngine = {
25
25
  return {
26
26
  __compiled: true,
27
27
  source,
28
+ // The AST-derived root identifiers of every interpolation, carried through
29
+ // rather than dropped. `compileString` has already computed them one line
30
+ // above, and a consumer that asks a compiled value what it READS —
31
+ // a template body deciding which nodes survive its `init()` — otherwise
32
+ // falls back to scanning the source text, which cannot tell an identifier
33
+ // from a word inside a SQL string literal.
34
+ refs: typeof inner === "string" ? [] : (inner as CompiledValue).refs,
28
35
  call: (ctx: Record<string, unknown>): ParameterizedSql => {
29
36
  const { fragments, values } = toParameterized(inner, ctx);
30
37
  return { __teloParameterized: true, fragments, values };