@telorun/templating 0.18.0 → 0.20.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,163 @@ 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
+
463
+ /** A CEL map as entries. Read the way `compact` reads one — a map arrives as a
464
+ * plain object or a `Map` depending on how it was produced — and refusing
465
+ * anything that is not one, since rebuilding an arbitrary object from its
466
+ * entries is how a byte buffer becomes `{"0":137,…}` silently. A LIST is named
467
+ * rather than coerced: CEL's `+` already concatenates lists, so a list here is
468
+ * a mistake with a spelling that works, not a case to support. */
469
+ const mapEntries = (fn: string, v: unknown): [string, unknown][] => {
470
+ if (v instanceof Map) return [...v.entries()] as [string, unknown][];
471
+ if (Array.isArray(v)) throw new Error(`${fn}: expected a map, got a list — use '+' to join lists`);
472
+ if (v !== null && typeof v === "object") {
473
+ const proto = Object.getPrototypeOf(v);
474
+ if (proto !== Object.prototype && proto !== null) {
475
+ throw new Error(`${fn}: expected a map, got ${v.constructor?.name ?? "an object"}`);
476
+ }
477
+ return Object.entries(v as Record<string, unknown>);
478
+ }
479
+ throw new Error(`${fn}: expected a map, got ${JSON.stringify(v)}`);
480
+ };
481
+
482
+ /** Right-hand precedence, so `merge(defaults, overrides)` reads as it looks.
483
+ *
484
+ * The map case is the one with no spelling at all — `+` joins lists and
485
+ * strings and refuses maps — so a child kind inheriting a map-valued field
486
+ * could only REPLACE it. That turns a default the parent set for a reason into
487
+ * something every consumer must restate, and a consumer who restates it
488
+ * incompletely gets a system that works until the omitted entry matters.
489
+ *
490
+ * Follows the LEFT argument's shape: this extends that map, so what comes back
491
+ * is what was extended. */
492
+ const mergeMaps = (a: unknown, b: unknown): unknown => {
493
+ const entries = [...mapEntries("merge", a), ...mapEntries("merge", b)];
494
+ return a instanceof Map ? new Map(entries) : Object.fromEntries(entries);
495
+ };
496
+
173
497
  const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
174
498
 
175
499
  /** Base64 → bytes, written out rather than delegated: `Buffer` is not browser-
@@ -489,12 +813,31 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
489
813
  },
490
814
  {
491
815
  name: "round",
492
- signature: "round(dyn): double",
816
+ signature: "round(dyn, int?): double",
817
+ register: [
818
+ "round(double): double",
819
+ "round(int): double",
820
+ "round(double, int): double",
821
+ "round(int, int): double",
822
+ ],
493
823
  category: "math",
494
- summary: "Round to the nearest integer.",
824
+ summary:
825
+ "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
826
  deterministic: true,
496
827
  hostBacked: false,
497
- build: () => (x: unknown) => Math.round(num(x)),
828
+ // Both arities go through `formattable`, so the 2^53 refusal does not depend
829
+ // on which one the author wrote. Guarding only the two-argument form left
830
+ // `round(x)` silently answering with a neighbouring integer — the defect
831
+ // this family exists to close, reachable by writing one fewer argument.
832
+ build: () => (x: unknown, digits?: unknown) =>
833
+ digits === undefined
834
+ ? Math.round(formattable("round", x))
835
+ : Number(formattable("round", x).toFixed(digitCount("round", digits))),
836
+ checkArgs: (lit) =>
837
+ literalGuard(() => {
838
+ if (lit[1] !== undefined) digitCount("round", lit[1]);
839
+ if (typeof lit[0] === "bigint") formattable("round", lit[0]);
840
+ }),
498
841
  },
499
842
  {
500
843
  name: "min",
@@ -694,7 +1037,7 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
694
1037
  summary: "Current time as ISO-8601; UTC by default, or in the given IANA timezone.",
695
1038
  deterministic: false,
696
1039
  hostBacked: false,
697
- build: () => (tz?: string) => isoInZone(tz ?? "UTC"),
1040
+ build: () => (tz?: string) => isoInZone(new Date(), tz ?? "UTC"),
698
1041
  },
699
1042
  {
700
1043
  name: "today",
@@ -703,7 +1046,7 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
703
1046
  summary: "Current calendar date (YYYY-MM-DD); UTC by default, or in the given IANA timezone.",
704
1047
  deterministic: false,
705
1048
  hostBacked: false,
706
- build: () => (tz?: string) => dateInZone(tz ?? "UTC"),
1049
+ build: () => (tz?: string) => dateInZone(new Date(), tz ?? "UTC"),
707
1050
  },
708
1051
  {
709
1052
  name: "nowMillis",
@@ -750,6 +1093,174 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
750
1093
  hostBacked: false,
751
1094
  build: () => (t: Date) => BigInt(Math.floor(t.getTime() / 1000)),
752
1095
  },
1096
+ // Formatting. The number surface is the d3-format specifier grammar in full,
1097
+ // `[[fill]align][sign][symbol][0][width][,][.precision][~][type]`, so a chart
1098
+ // axis label and the table cell beside it cannot round the same value two
1099
+ // ways. Rounding is therefore d3's: `f` rounds the double at the decimal
1100
+ // place, so `.2f` of 1.005 is "1.00" — 1.005 is not representable and the
1101
+ // nearest double sits below the half.
1102
+ {
1103
+ name: "format",
1104
+ signature: "format(dyn, string): string",
1105
+ // Registered per numeric type rather than as `dyn`. A `dyn` first parameter
1106
+ // accepted a string and answered "NaN" — a value that looks like an answer
1107
+ // and prints into a document. A genuinely dynamic expression still passes,
1108
+ // because cel-js matches `dyn` against any declared parameter type; what
1109
+ // this rejects is a STATICALLY known wrong type, at `telo check`.
1110
+ register: ["format(double, string): string", "format(int, string): string"],
1111
+ category: "formatting",
1112
+ summary: "Format a number with a d3-format specifier (`.2f`, `,.2f`, `.1%`, `.2s`).",
1113
+ deterministic: true,
1114
+ hostBacked: false,
1115
+ build: () => (x: unknown, spec: unknown) => formatter("format", spec)(formattable("format", x)),
1116
+ checkArgs: (lit) =>
1117
+ literalGuard(() => {
1118
+ if (lit[1] !== undefined) formatter("format", lit[1]);
1119
+ if (typeof lit[0] === "bigint") formattable("format", lit[0]);
1120
+ }),
1121
+ },
1122
+ {
1123
+ name: "fixed",
1124
+ signature: "fixed(dyn, int): string",
1125
+ register: ["fixed(double, int): string", "fixed(int, int): string"],
1126
+ category: "formatting",
1127
+ summary: "Fixed-decimal string with the given number of places (0–10).",
1128
+ deterministic: true,
1129
+ hostBacked: false,
1130
+ build: () => (x: unknown, digits: unknown) =>
1131
+ formatter("fixed", `.${digitCount("fixed", digits)}f`)(formattable("fixed", x)),
1132
+ checkArgs: (lit) =>
1133
+ literalGuard(() => {
1134
+ if (lit[1] !== undefined) digitCount("fixed", lit[1]);
1135
+ if (typeof lit[0] === "bigint") formattable("fixed", lit[0]);
1136
+ }),
1137
+ },
1138
+ {
1139
+ name: "formatDuration",
1140
+ signature: "formatDuration(dyn, int): string",
1141
+ register: [
1142
+ "formatDuration(double, int): string",
1143
+ "formatDuration(int, int): string",
1144
+ ],
1145
+ category: "formatting",
1146
+ summary:
1147
+ "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.",
1148
+ deterministic: true,
1149
+ hostBacked: false,
1150
+ build: () => (minutes: unknown, minutesPerDay: unknown) => durationText(minutes, minutesPerDay),
1151
+ checkArgs: (lit) =>
1152
+ literalGuard(() => {
1153
+ if (lit[1] !== undefined) durationText(lit[0] ?? 0, lit[1]);
1154
+ }),
1155
+ },
1156
+ {
1157
+ name: "dateIn",
1158
+ signature: "dateIn(timestamp, string?): string",
1159
+ register: [
1160
+ "dateIn(google.protobuf.Timestamp): string",
1161
+ "dateIn(google.protobuf.Timestamp, string): string",
1162
+ ],
1163
+ category: "time",
1164
+ summary: "Calendar date (`YYYY-MM-DD`) of an instant, in an IANA zone (UTC by default).",
1165
+ deterministic: true,
1166
+ hostBacked: false,
1167
+ build: () => (t: unknown, tz?: string) => dateInZone(instantArg("dateIn", t), assertZone("dateIn", tz ?? "UTC")),
1168
+ checkArgs: (lit) =>
1169
+ literalGuard(() => {
1170
+ if (typeof lit[1] === "string") assertZone("dateIn", lit[1]);
1171
+ }),
1172
+ },
1173
+ {
1174
+ name: "isoIn",
1175
+ signature: "isoIn(timestamp, string?): string",
1176
+ register: [
1177
+ "isoIn(google.protobuf.Timestamp): string",
1178
+ "isoIn(google.protobuf.Timestamp, string): string",
1179
+ ],
1180
+ category: "time",
1181
+ summary: "ISO-8601 rendering of an instant, in an IANA zone (UTC by default).",
1182
+ deterministic: true,
1183
+ hostBacked: false,
1184
+ build: () => (t: unknown, tz?: string) => isoInZone(instantArg("isoIn", t), assertZone("isoIn", tz ?? "UTC")),
1185
+ checkArgs: (lit) =>
1186
+ literalGuard(() => {
1187
+ if (typeof lit[1] === "string") assertZone("isoIn", lit[1]);
1188
+ }),
1189
+ },
1190
+ {
1191
+ name: "startOfMonth",
1192
+ signature: "startOfMonth(timestamp, string?): timestamp",
1193
+ register: [
1194
+ "startOfMonth(google.protobuf.Timestamp): google.protobuf.Timestamp",
1195
+ "startOfMonth(google.protobuf.Timestamp, string): google.protobuf.Timestamp",
1196
+ ],
1197
+ category: "time",
1198
+ summary: "Midnight on the 1st of the instant's month, in an IANA zone (UTC by default).",
1199
+ deterministic: true,
1200
+ hostBacked: false,
1201
+ checkArgs: (lit) =>
1202
+ literalGuard(() => {
1203
+ if (typeof lit[1] === "string") assertZone("startOfMonth", lit[1]);
1204
+ }),
1205
+ build: () => (t: unknown, tz?: string) => {
1206
+ const zone = assertZone("startOfMonth", tz ?? "UTC");
1207
+ const f = zonedFields(instantArg("startOfMonth", t), zone);
1208
+ return instantOfZoned(
1209
+ { year: f.year, month: f.month, day: 1, hour: 0, minute: 0, second: 0 },
1210
+ zone,
1211
+ );
1212
+ },
1213
+ },
1214
+ {
1215
+ name: "addMonths",
1216
+ signature: "addMonths(timestamp, int, string?): timestamp",
1217
+ register: [
1218
+ "addMonths(google.protobuf.Timestamp, int): google.protobuf.Timestamp",
1219
+ "addMonths(google.protobuf.Timestamp, int, string): google.protobuf.Timestamp",
1220
+ ],
1221
+ category: "time",
1222
+ summary:
1223
+ "Shift an instant by whole months in an IANA zone, clamping the day of month (Jan 31 + 1 month is Feb 28).",
1224
+ deterministic: true,
1225
+ hostBacked: false,
1226
+ checkArgs: (lit) =>
1227
+ literalGuard(() => {
1228
+ if (typeof lit[2] === "string") assertZone("addMonths", lit[2]);
1229
+ }),
1230
+ build: () => (t: unknown, months: unknown, tz?: string) => {
1231
+ const zone = assertZone("addMonths", tz ?? "UTC");
1232
+ const f = zonedFields(instantArg("addMonths", t), zone);
1233
+ const shifted = f.year * 12 + (f.month - 1) + Number(months);
1234
+ // `%` takes the dividend's sign in JS, so a negative total would yield
1235
+ // month 0. Unreachable for realistic dates and wrong for free otherwise.
1236
+ const year = Math.floor(shifted / 12);
1237
+ const month = (((shifted % 12) + 12) % 12) + 1;
1238
+ return instantOfZoned({ ...f, year, month, day: Math.min(f.day, daysInMonth(year, month)) }, zone);
1239
+ },
1240
+ },
1241
+ {
1242
+ name: "compact",
1243
+ signature: "compact(dyn): dyn",
1244
+ // A `dyn` parameter accepted an instant (yielding `{}`) and a byte buffer
1245
+ // (yielding `{"0":137,…}`), both silently and both passing `telo check`.
1246
+ register: ["compact(list): list", "compact(map): map"],
1247
+ category: "collection",
1248
+ summary: "Drop entries whose value is null or the empty string, from a map or a list.",
1249
+ deterministic: true,
1250
+ hostBacked: false,
1251
+ build: () => (v: unknown) => compactValue(v),
1252
+ },
1253
+ {
1254
+ name: "merge",
1255
+ signature: "merge(map, map): map",
1256
+ register: ["merge(map, map): map"],
1257
+ category: "collection",
1258
+ summary:
1259
+ "Combine two maps, with the right-hand map winning on a shared key. Use it to add to a map rather than replace it — `merge(defaults, overrides)`.",
1260
+ deterministic: true,
1261
+ hostBacked: false,
1262
+ build: () => (a: unknown, b: unknown) => mergeMaps(a, b),
1263
+ },
753
1264
  // UUID
754
1265
  {
755
1266
  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