@telorun/templating 0.18.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 +1 @@
1
- {"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../../src/cel/analyze.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAGpD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,EAAE,EAAE,CAI7D;AA0DD;;+DAE+D;AAC/D,eAAO,MAAM,aAAa,QAAQ,CAAC;AAqBnC,UAAU,aAAa;IACrB,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAC;CAChB;AAoFD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,OAAO,EACb,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GACjC,aAAa,EAAE,CAIjB;AA2FD;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,MAAM,EAAE,EACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,MAAM,GAAG,IAAI,CAsBf"}
1
+ {"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../../src/cel/analyze.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAGpD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,EAAE,EAAE,CAI7D;AAsFD;;+DAE+D;AAC/D,eAAO,MAAM,aAAa,QAAQ,CAAC;AAqBnC,UAAU,aAAa;IACrB,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAC;CAChB;AAoFD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,OAAO,EACb,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GACjC,aAAa,EAAE,CAIjB;AAoGD;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,MAAM,EAAE,EACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,MAAM,GAAG,IAAI,CAsBf"}
@@ -11,12 +11,44 @@ export function extractAccessChains(node) {
11
11
  return chains;
12
12
  }
13
13
  const COMPREHENSION_METHODS = new Set(["filter", "map", "exists", "all", "exists_one"]);
14
+ /** `cel.bind(name, init, body)` — CEL's only binding form, and the one the
15
+ * parser expands rather than dispatching, so it appears as a receiver call on a
16
+ * bare `cel` identifier that is in no scope and never will be. Both walks below
17
+ * need the same three facts out of it, so the shape is read once here.
18
+ *
19
+ * The receiver is deliberately not returned: it contributes no chain, and
20
+ * descending into it is what produced a `CEL_UNKNOWN_FIELD` for `cel` itself. */
21
+ function bindCall(node) {
22
+ if (node.op !== "rcall" || !Array.isArray(node.args))
23
+ return null;
24
+ const [method, receiver, callArgs] = node.args;
25
+ if (method !== "bind")
26
+ return null;
27
+ if (!isASTNode(receiver) || receiver.op !== "id" || receiver.args !== "cel")
28
+ return null;
29
+ if (!Array.isArray(callArgs) || callArgs.length !== 3)
30
+ return null;
31
+ const [nameNode, init, body] = callArgs;
32
+ if (!isASTNode(nameNode) || nameNode.op !== "id")
33
+ return null;
34
+ if (!isASTNode(init) || !isASTNode(body))
35
+ return null;
36
+ return { name: nameNode.args, init, body };
37
+ }
14
38
  function visitNode(node, chains, boundVars) {
15
39
  const chain = extractChain(node, boundVars);
16
40
  if (chain !== null) {
17
41
  chains.push(chain);
18
42
  return;
19
43
  }
44
+ // The bound name is in scope for the body ONLY; `init` is evaluated in the
45
+ // enclosing scope, so a name used there still has to resolve there.
46
+ const bind = bindCall(node);
47
+ if (bind) {
48
+ visitNode(bind.init, chains, boundVars);
49
+ visitNode(bind.body, chains, new Set(boundVars).add(bind.name));
50
+ return;
51
+ }
20
52
  if (node.op === "rcall" &&
21
53
  Array.isArray(node.args) &&
22
54
  typeof node.args[0] === "string" &&
@@ -210,6 +242,14 @@ function walkNullable(node, nonNull, boundVars, issues, schema) {
210
242
  walkNullable(b, union(nonNull, carried), boundVars, issues, schema);
211
243
  return;
212
244
  }
245
+ // `cel.bind` binds a name for its body; mirror extractAccessChains so a bound
246
+ // name is never read as a nullable context field.
247
+ const bind = bindCall(node);
248
+ if (bind) {
249
+ walkNullable(bind.init, nonNull, boundVars, issues, schema);
250
+ walkNullable(bind.body, nonNull, new Set(boundVars).add(bind.name), issues, schema);
251
+ return;
252
+ }
213
253
  // Comprehension macros bind a loop variable; mirror extractAccessChains so a
214
254
  // bound var is never mistaken for a nullable context field.
215
255
  if (node.op === "rcall" &&
@@ -12,7 +12,7 @@ export interface CelHandlers {
12
12
  base64Decode: (s: string) => string;
13
13
  json: (value: unknown) => string;
14
14
  }
15
- export type CelFunctionCategory = "conversion" | "time" | "uuid" | "string" | "math" | "collection" | "json" | "encoding" | "hashing" | "null";
15
+ export type CelFunctionCategory = "conversion" | "time" | "uuid" | "string" | "math" | "collection" | "json" | "encoding" | "hashing" | "formatting" | "null";
16
16
  /** One entry in the CEL standard library — the single source of truth that both
17
17
  * registers the function (`build`) and documents it (everything else). `telo
18
18
  * cel functions` and `celFunctionCatalog()` read the metadata; `buildCelEnvironment`
@@ -38,6 +38,24 @@ export interface CelFunctionDoc {
38
38
  * analyzer's stub throws if such a function is actually evaluated. */
39
39
  readonly hostBacked: boolean;
40
40
  readonly build: (h: CelHandlers) => (...args: any[]) => unknown;
41
+ /**
42
+ * Check the arguments that were written as LITERALS, at analysis time.
43
+ *
44
+ * A type is all a signature can constrain, so a guard over a value —
45
+ * an unparseable format specifier, a decimal count out of range, a day length
46
+ * of zero, an unknown IANA zone — fires only when the expression is evaluated.
47
+ * That puts a defect the manifest states in plain sight behind a run, which is
48
+ * the opposite of what static analysis is for.
49
+ *
50
+ * `literals[i]` is the value of argument `i` when it was written as a literal,
51
+ * and `undefined` when it is an expression whose value is not statically
52
+ * known — so a checker MUST skip an `undefined` rather than judge it.
53
+ * Returns a message, or `undefined` when there is nothing to report.
54
+ *
55
+ * Implementations call the SAME guard the runtime calls, so the static and
56
+ * dynamic answers cannot drift into disagreement.
57
+ */
58
+ readonly checkArgs?: (literals: readonly unknown[]) => string | undefined;
41
59
  }
42
60
  /** Public, build-free view of a catalog entry (for `--json` / docs). */
43
61
  export type CelFunctionInfo = Omit<CelFunctionDoc, "build">;
@@ -1 +1 @@
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;AAwH5D,eAAO,MAAM,aAAa,EAAE,SAAS,cAAc,EA8lBlD,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":"AAIA;;;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,YAAY,GACZ,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;IAChE;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,KAAK,MAAM,GAAG,SAAS,CAAC;CAC3E;AAYD,wEAAwE;AACxE,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;AA4X5D,eAAO,MAAM,aAAa,EAAE,SAAS,cAAc,EA8wBlD,CAAC;AAEF,oEAAoE;AACpE,wBAAgB,kBAAkB,IAAI,eAAe,EAAE,CAEtD"}
@@ -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
  /** RE2 regex engine for the CEL `regex*` functions — `re2js`, a pure-JS port of
@@ -40,6 +41,16 @@ const compileRe2 = (fn, pattern, flags) => {
40
41
  throw new Error(`${fn}: invalid RE2 pattern ${JSON.stringify(pattern)}: ${e instanceof Error ? e.message : String(e)}`);
41
42
  }
42
43
  };
44
+ /** Run a runtime guard for its refusal, so a `checkArgs` never restates one. */
45
+ const literalGuard = (run) => {
46
+ try {
47
+ run();
48
+ return undefined;
49
+ }
50
+ catch (e) {
51
+ return e instanceof Error ? e.message : String(e);
52
+ }
53
+ };
43
54
  const num = (x) => Number(x);
44
55
  const minMax = (list, isMin) => {
45
56
  if (!Array.isArray(list) || list.length === 0)
@@ -62,6 +73,147 @@ const sortList = (list) => [...list].sort((a, b) => {
62
73
  }
63
74
  return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
64
75
  });
76
+ /** The number-formatting locale, pinned rather than defaulted.
77
+ *
78
+ * d3-format's default locale renders a negative with U+2212 MINUS SIGN, so
79
+ * `format(-1.5, '.2f')` is `"−1.50"` and not `"-1.50"` — a string that no
80
+ * downstream parser, comparison or diff treats as the number it looks like.
81
+ * Every field here is fixed to its ASCII form for the same reason the layer is
82
+ * locale-free at all: the same manifest must render the same bytes on every
83
+ * runtime, and a second engine implementing the specifier grammar has to be
84
+ * able to reproduce these exactly. */
85
+ const FORMAT_LOCALE = formatLocale({
86
+ decimal: ".",
87
+ thousands: ",",
88
+ grouping: [3],
89
+ currency: ["$", ""],
90
+ minus: "-",
91
+ percent: "%",
92
+ nan: "NaN",
93
+ });
94
+ /** Largest integer a double represents exactly. */
95
+ const MAX_EXACT_INT = 9007199254740991n;
96
+ /** A CEL `int` is a BigInt in this runtime and `d3-format` throws on one
97
+ * outright, so a formattable argument is converted here. Past 2^53 a double
98
+ * stops representing every integer, and silently emitting a number that is not
99
+ * the one the author computed is the defect class this family exists to close —
100
+ * so that case raises instead. */
101
+ const formattable = (fn, x) => {
102
+ if (typeof x === "bigint") {
103
+ if (x > MAX_EXACT_INT || x < -MAX_EXACT_INT) {
104
+ throw new Error(`${fn}: integer ${x} exceeds 2^53-1 and cannot be formatted exactly as a double`);
105
+ }
106
+ return Number(x);
107
+ }
108
+ const n = Number(x);
109
+ // The runtime backstop behind the typed registrations. A value that is not a
110
+ // number formats as the string "NaN", which is the failure this family exists
111
+ // to remove: it looks like an answer and prints into a document. Named here
112
+ // rather than coerced, the way an instant argument is.
113
+ if (!Number.isFinite(n)) {
114
+ throw new Error(`${fn}: expected a finite number, got ${JSON.stringify(x)}`);
115
+ }
116
+ return n;
117
+ };
118
+ /** Specifier type characters d3 implements. An unknown one PARSES — `.2q`
119
+ * yields `"1"` rather than throwing — so a typo would silently format against
120
+ * the default type. The set is checked here so a bad specifier is refused
121
+ * rather than quietly answered. */
122
+ const FORMAT_TYPES = new Set([..."efgrs%pbodxXcn"]);
123
+ /** A specifier is a CEL value, so it can be request-derived — an `Http.Server`
124
+ * evaluating `format(x, request.query.spec)` would otherwise grow this map for
125
+ * the life of the process, and it is module-global, so every in-process kernel
126
+ * shares it. Cleared wholesale at the cap rather than evicted one at a time: a
127
+ * manifest's real specifier set is a handful of constants that repopulate
128
+ * immediately, and an LRU is machinery for a hit rate nothing here needs. */
129
+ const FORMATTER_CACHE_MAX = 256;
130
+ const formatterCache = new Map();
131
+ const formatter = (fn, spec) => {
132
+ const text = String(spec);
133
+ const cached = formatterCache.get(text);
134
+ if (cached)
135
+ return cached;
136
+ const type = text.slice(-1);
137
+ if (text !== "" && /[a-zA-Z%]/.test(type) && !FORMAT_TYPES.has(type)) {
138
+ throw new Error(`${fn}: unknown format type '${type}' (one of ${[...FORMAT_TYPES].join("")})`);
139
+ }
140
+ // The `.precision` group — width is the digits BEFORE the dot, so this is the
141
+ // only `.`-digits sequence the grammar admits.
142
+ const precision = /\.(\d+)/.exec(text);
143
+ if (precision)
144
+ digitCount(fn, precision[1]);
145
+ let built;
146
+ try {
147
+ built = FORMAT_LOCALE.format(text);
148
+ }
149
+ catch {
150
+ throw new Error(`${fn}: invalid format specifier ${JSON.stringify(text)}`);
151
+ }
152
+ if (formatterCache.size >= FORMATTER_CACHE_MAX)
153
+ formatterCache.clear();
154
+ formatterCache.set(text, built);
155
+ return built;
156
+ };
157
+ /** Decimal places, bounded. The ceiling is well below what `toFixed` accepts
158
+ * because past it the digits are an artefact of the binary representation
159
+ * rather than of the value.
160
+ *
161
+ * ONE rule, enforced wherever a precision is written: `formatter` applies it to
162
+ * a specifier's `.precision` group too. Bounding only this spelling let an
163
+ * author route around the guard by writing `format(x, '.11f')` instead of
164
+ * `fixed(x, 11)` — the family giving two answers to one question. It is not the
165
+ * grammar subsetting the "full d3 surface" decision refuses: every specifier
166
+ * type and flag stays available, and only the digit count is capped. */
167
+ const MAX_DECIMALS = 10;
168
+ const digitCount = (fn, digits) => {
169
+ const n = Number(digits);
170
+ if (!Number.isInteger(n) || n < 0 || n > MAX_DECIMALS) {
171
+ throw new Error(`${fn}: decimal places must be an integer 0-${MAX_DECIMALS}, got ${String(digits)}`);
172
+ }
173
+ return n;
174
+ };
175
+ /** Render a minute count against a declared day length. The day is a policy
176
+ * argument, never an assumption — see the catalog entry's summary. */
177
+ const durationText = (minutes, minutesPerDay) => {
178
+ const perDay = Math.round(formattable("formatDuration", minutesPerDay));
179
+ if (!Number.isFinite(perDay) || perDay <= 0) {
180
+ throw new Error(`formatDuration: minutesPerDay must be a positive number, got ${perDay}`);
181
+ }
182
+ const total = Math.round(formattable("formatDuration", minutes));
183
+ if (!Number.isFinite(total)) {
184
+ throw new Error(`formatDuration: minutes must be a finite number`);
185
+ }
186
+ const magnitude = Math.abs(total);
187
+ const days = Math.floor(magnitude / perDay);
188
+ const withinDay = magnitude % perDay;
189
+ const hours = Math.floor(withinDay / 60);
190
+ const mins = withinDay % 60;
191
+ const parts = [];
192
+ if (days)
193
+ parts.push(`${days}d`);
194
+ if (hours)
195
+ parts.push(`${hours}h`);
196
+ if (mins)
197
+ parts.push(`${mins}m`);
198
+ if (parts.length === 0)
199
+ parts.push("0m");
200
+ return `${total < 0 ? "-" : ""}${parts.join(" ")}`;
201
+ };
202
+ /** Refuse an unknown zone in this family's own voice. Left to `Intl`, the
203
+ * failure is a raw `RangeError` naming neither the function nor what was
204
+ * wrong with the argument — the only refusal here that did not read
205
+ * `<fn>: <what is wrong>`, and whose wording belongs to the JS engine rather
206
+ * than to Telo. Also the guard `checkArgs` runs at analysis time, so a literal
207
+ * zone is checked once and answered identically in both places. */
208
+ const assertZone = (fn, tz) => {
209
+ try {
210
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
211
+ }
212
+ catch {
213
+ throw new Error(`${fn}: unknown IANA time zone ${JSON.stringify(tz)}`);
214
+ }
215
+ return tz;
216
+ };
65
217
  /** `Intl.DateTimeFormat` is an ECMA-402 global in Node (full ICU) and browsers,
66
218
  * so timezone handling needs no Node-only API and stays browser-safe. */
67
219
  const zoneParts = (date, tz, opts) => {
@@ -75,8 +227,7 @@ const zoneParts = (date, tz, opts) => {
75
227
  * offset (e.g. `2026-06-06T18:30:00.000-05:00`). Uses only standard Intl
76
228
  * fields and derives the offset arithmetically, so it needs no newer Intl
77
229
  * type-lib features and stays portable. */
78
- const isoInZone = (tz) => {
79
- const now = new Date();
230
+ const isoInZone = (now, tz) => {
80
231
  if (tz === "UTC" || tz === "Z")
81
232
  return now.toISOString();
82
233
  const p = zoneParts(now, tz, {
@@ -98,14 +249,114 @@ const isoInZone = (tz) => {
98
249
  : `${offsetMin > 0 ? "+" : "-"}${String(Math.floor(Math.abs(offsetMin) / 60)).padStart(2, "0")}:${String(Math.abs(offsetMin) % 60).padStart(2, "0")}`;
99
250
  return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}.${ms}${offset}`;
100
251
  };
101
- /** Current calendar date (`YYYY-MM-DD`) in `tz`. */
102
- const dateInZone = (tz) => {
103
- const now = new Date();
252
+ /** Calendar date (`YYYY-MM-DD`) of an instant in `tz`. */
253
+ const dateInZone = (now, tz) => {
104
254
  if (tz === "UTC" || tz === "Z")
105
255
  return now.toISOString().slice(0, 10);
106
256
  const p = zoneParts(now, tz, { year: "numeric", month: "2-digit", day: "2-digit" });
107
257
  return `${p.year}-${p.month}-${p.day}`;
108
258
  };
259
+ const zonedFields = (date, tz) => {
260
+ const p = zoneParts(date, tz, {
261
+ hourCycle: "h23",
262
+ year: "numeric",
263
+ month: "2-digit",
264
+ day: "2-digit",
265
+ hour: "2-digit",
266
+ minute: "2-digit",
267
+ second: "2-digit",
268
+ });
269
+ return {
270
+ year: +p.year,
271
+ month: +p.month,
272
+ day: +p.day,
273
+ hour: +p.hour,
274
+ minute: +p.minute,
275
+ second: +p.second,
276
+ };
277
+ };
278
+ /** The zone's offset at `date`, in milliseconds — its wall clock read as UTC,
279
+ * minus the real instant. The same arithmetic `isoInZone` does. */
280
+ const zoneOffsetMs = (date, tz) => {
281
+ const f = zonedFields(date, tz);
282
+ return Date.UTC(f.year, f.month - 1, f.day, f.hour, f.minute, f.second) - date.getTime();
283
+ };
284
+ const sameWallClock = (a, b) => a.year === b.year &&
285
+ a.month === b.month &&
286
+ a.day === b.day &&
287
+ a.hour === b.hour &&
288
+ a.minute === b.minute &&
289
+ a.second === b.second;
290
+ /** The instant whose wall clock in `tz` is the given fields.
291
+ *
292
+ * The offset depends on the instant being solved for, so this takes the offset
293
+ * at the UTC reading, corrects, and then CHECKS by reading the result back.
294
+ * That check is the whole point: a plain fixpoint settles on an instant whose
295
+ * wall clock is not the one asked for whenever the requested time does not
296
+ * exist, and it settles BACKWARDS — which silently moves the calendar day, the
297
+ * one thing `addMonths` and `startOfMonth` exist to control. Chile jumps
298
+ * 00:00 → 01:00 on 2026-09-06 and Cuba on 2026-03-08, so "the 6th at midnight"
299
+ * there is not a time; a fixpoint answered "the 5th at 23:00".
300
+ *
301
+ * Resolution follows Java's `ZonedDateTime` and Temporal's `compatible`:
302
+ * a wall clock that exists twice (a fall-back) takes the EARLIER instant, and
303
+ * one that does not exist (a spring-forward gap) shifts FORWARD out of the gap,
304
+ * which keeps the requested day. */
305
+ const instantOfZoned = (f, tz) => {
306
+ const asUtc = Date.UTC(f.year, f.month - 1, f.day, f.hour, f.minute, f.second);
307
+ const offsetA = zoneOffsetMs(new Date(asUtc), tz);
308
+ const candidateA = asUtc - offsetA;
309
+ const offsetB = zoneOffsetMs(new Date(candidateA), tz);
310
+ if (offsetA === offsetB)
311
+ return new Date(candidateA);
312
+ const candidateB = asUtc - offsetB;
313
+ const aHolds = sameWallClock(zonedFields(new Date(candidateA), tz), f);
314
+ const bHolds = sameWallClock(zonedFields(new Date(candidateB), tz), f);
315
+ if (aHolds && bHolds)
316
+ return new Date(Math.min(candidateA, candidateB));
317
+ if (aHolds)
318
+ return new Date(candidateA);
319
+ if (bHolds)
320
+ return new Date(candidateB);
321
+ return new Date(Math.max(candidateA, candidateB));
322
+ };
323
+ /** `Date.UTC` maps years 0-99 to 1900-1999, so the year is set explicitly. */
324
+ const daysInMonth = (year, month) => {
325
+ const d = new Date(Date.UTC(2000, month, 0));
326
+ d.setUTCFullYear(year, month, 0);
327
+ return d.getUTCDate();
328
+ };
329
+ /** An instant argument arrives as a `Date`; anything else is a caller error the
330
+ * type-checker did not catch (a `dyn` slot), so it is named rather than
331
+ * coerced into an Invalid Date that formats as `NaN`. */
332
+ const instantArg = (fn, v) => {
333
+ if (v instanceof Date && Number.isFinite(v.getTime()))
334
+ return v;
335
+ throw new Error(`${fn}: expected a timestamp`);
336
+ };
337
+ /** Drop entries whose value is null or the empty string. Nothing else: an empty
338
+ * list or map is a value someone deliberately built. CEL hands a map over as a
339
+ * plain object or a `Map` depending on how it was produced, so both are read. */
340
+ const compactValue = (v) => {
341
+ const keep = (x) => x !== null && x !== undefined && x !== "";
342
+ if (Array.isArray(v))
343
+ return v.filter(keep);
344
+ if (v instanceof Map) {
345
+ return new Map([...v.entries()].filter(([, value]) => keep(value)));
346
+ }
347
+ // A PLAIN object only. Rebuilding an arbitrary object from its entries is how
348
+ // a byte buffer becomes `{"0":137,…}` and an instant becomes `{}` — silently,
349
+ // and looking like a value. The same rule the compile walker follows, and the
350
+ // same "name it rather than coerce it" the instant argument follows.
351
+ if (v !== null && typeof v === "object") {
352
+ const proto = Object.getPrototypeOf(v);
353
+ if (proto !== Object.prototype && proto !== null) {
354
+ throw new Error(`compact: expected a map or a list, got ${v.constructor?.name ?? "an object"}`);
355
+ }
356
+ return Object.fromEntries(Object.entries(v).filter(([, value]) => keep(value)));
357
+ }
358
+ throw new Error(`compact: expected a map or a list, got ${JSON.stringify(v)}`);
359
+ };
109
360
  const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
110
361
  /** Base64 → bytes, written out rather than delegated: `Buffer` is not browser-
111
362
  * safe and `atob` round-trips through a string, which is the corruption this
@@ -417,12 +668,30 @@ export const CEL_FUNCTIONS = [
417
668
  },
418
669
  {
419
670
  name: "round",
420
- signature: "round(dyn): double",
671
+ signature: "round(dyn, int?): double",
672
+ register: [
673
+ "round(double): double",
674
+ "round(int): double",
675
+ "round(double, int): double",
676
+ "round(int, int): double",
677
+ ],
421
678
  category: "math",
422
- summary: "Round to the nearest integer.",
679
+ summary: "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.",
423
680
  deterministic: true,
424
681
  hostBacked: false,
425
- build: () => (x) => Math.round(num(x)),
682
+ // Both arities go through `formattable`, so the 2^53 refusal does not depend
683
+ // on which one the author wrote. Guarding only the two-argument form left
684
+ // `round(x)` silently answering with a neighbouring integer — the defect
685
+ // this family exists to close, reachable by writing one fewer argument.
686
+ build: () => (x, digits) => digits === undefined
687
+ ? Math.round(formattable("round", x))
688
+ : Number(formattable("round", x).toFixed(digitCount("round", digits))),
689
+ checkArgs: (lit) => literalGuard(() => {
690
+ if (lit[1] !== undefined)
691
+ digitCount("round", lit[1]);
692
+ if (typeof lit[0] === "bigint")
693
+ formattable("round", lit[0]);
694
+ }),
426
695
  },
427
696
  {
428
697
  name: "min",
@@ -619,7 +888,7 @@ export const CEL_FUNCTIONS = [
619
888
  summary: "Current time as ISO-8601; UTC by default, or in the given IANA timezone.",
620
889
  deterministic: false,
621
890
  hostBacked: false,
622
- build: () => (tz) => isoInZone(tz ?? "UTC"),
891
+ build: () => (tz) => isoInZone(new Date(), tz ?? "UTC"),
623
892
  },
624
893
  {
625
894
  name: "today",
@@ -628,7 +897,7 @@ export const CEL_FUNCTIONS = [
628
897
  summary: "Current calendar date (YYYY-MM-DD); UTC by default, or in the given IANA timezone.",
629
898
  deterministic: false,
630
899
  hostBacked: false,
631
- build: () => (tz) => dateInZone(tz ?? "UTC"),
900
+ build: () => (tz) => dateInZone(new Date(), tz ?? "UTC"),
632
901
  },
633
902
  {
634
903
  name: "nowMillis",
@@ -675,6 +944,159 @@ export const CEL_FUNCTIONS = [
675
944
  hostBacked: false,
676
945
  build: () => (t) => BigInt(Math.floor(t.getTime() / 1000)),
677
946
  },
947
+ // Formatting. The number surface is the d3-format specifier grammar in full,
948
+ // `[[fill]align][sign][symbol][0][width][,][.precision][~][type]`, so a chart
949
+ // axis label and the table cell beside it cannot round the same value two
950
+ // ways. Rounding is therefore d3's: `f` rounds the double at the decimal
951
+ // place, so `.2f` of 1.005 is "1.00" — 1.005 is not representable and the
952
+ // nearest double sits below the half.
953
+ {
954
+ name: "format",
955
+ signature: "format(dyn, string): string",
956
+ // Registered per numeric type rather than as `dyn`. A `dyn` first parameter
957
+ // accepted a string and answered "NaN" — a value that looks like an answer
958
+ // and prints into a document. A genuinely dynamic expression still passes,
959
+ // because cel-js matches `dyn` against any declared parameter type; what
960
+ // this rejects is a STATICALLY known wrong type, at `telo check`.
961
+ register: ["format(double, string): string", "format(int, string): string"],
962
+ category: "formatting",
963
+ summary: "Format a number with a d3-format specifier (`.2f`, `,.2f`, `.1%`, `.2s`).",
964
+ deterministic: true,
965
+ hostBacked: false,
966
+ build: () => (x, spec) => formatter("format", spec)(formattable("format", x)),
967
+ checkArgs: (lit) => literalGuard(() => {
968
+ if (lit[1] !== undefined)
969
+ formatter("format", lit[1]);
970
+ if (typeof lit[0] === "bigint")
971
+ formattable("format", lit[0]);
972
+ }),
973
+ },
974
+ {
975
+ name: "fixed",
976
+ signature: "fixed(dyn, int): string",
977
+ register: ["fixed(double, int): string", "fixed(int, int): string"],
978
+ category: "formatting",
979
+ summary: "Fixed-decimal string with the given number of places (0–10).",
980
+ deterministic: true,
981
+ hostBacked: false,
982
+ build: () => (x, digits) => formatter("fixed", `.${digitCount("fixed", digits)}f`)(formattable("fixed", x)),
983
+ checkArgs: (lit) => literalGuard(() => {
984
+ if (lit[1] !== undefined)
985
+ digitCount("fixed", lit[1]);
986
+ if (typeof lit[0] === "bigint")
987
+ formattable("fixed", lit[0]);
988
+ }),
989
+ },
990
+ {
991
+ name: "formatDuration",
992
+ signature: "formatDuration(dyn, int): string",
993
+ register: [
994
+ "formatDuration(double, int): string",
995
+ "formatDuration(int, int): string",
996
+ ],
997
+ category: "formatting",
998
+ summary: "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.",
999
+ deterministic: true,
1000
+ hostBacked: false,
1001
+ build: () => (minutes, minutesPerDay) => durationText(minutes, minutesPerDay),
1002
+ checkArgs: (lit) => literalGuard(() => {
1003
+ if (lit[1] !== undefined)
1004
+ durationText(lit[0] ?? 0, lit[1]);
1005
+ }),
1006
+ },
1007
+ {
1008
+ name: "dateIn",
1009
+ signature: "dateIn(timestamp, string?): string",
1010
+ register: [
1011
+ "dateIn(google.protobuf.Timestamp): string",
1012
+ "dateIn(google.protobuf.Timestamp, string): string",
1013
+ ],
1014
+ category: "time",
1015
+ summary: "Calendar date (`YYYY-MM-DD`) of an instant, in an IANA zone (UTC by default).",
1016
+ deterministic: true,
1017
+ hostBacked: false,
1018
+ build: () => (t, tz) => dateInZone(instantArg("dateIn", t), assertZone("dateIn", tz ?? "UTC")),
1019
+ checkArgs: (lit) => literalGuard(() => {
1020
+ if (typeof lit[1] === "string")
1021
+ assertZone("dateIn", lit[1]);
1022
+ }),
1023
+ },
1024
+ {
1025
+ name: "isoIn",
1026
+ signature: "isoIn(timestamp, string?): string",
1027
+ register: [
1028
+ "isoIn(google.protobuf.Timestamp): string",
1029
+ "isoIn(google.protobuf.Timestamp, string): string",
1030
+ ],
1031
+ category: "time",
1032
+ summary: "ISO-8601 rendering of an instant, in an IANA zone (UTC by default).",
1033
+ deterministic: true,
1034
+ hostBacked: false,
1035
+ build: () => (t, tz) => isoInZone(instantArg("isoIn", t), assertZone("isoIn", tz ?? "UTC")),
1036
+ checkArgs: (lit) => literalGuard(() => {
1037
+ if (typeof lit[1] === "string")
1038
+ assertZone("isoIn", lit[1]);
1039
+ }),
1040
+ },
1041
+ {
1042
+ name: "startOfMonth",
1043
+ signature: "startOfMonth(timestamp, string?): timestamp",
1044
+ register: [
1045
+ "startOfMonth(google.protobuf.Timestamp): google.protobuf.Timestamp",
1046
+ "startOfMonth(google.protobuf.Timestamp, string): google.protobuf.Timestamp",
1047
+ ],
1048
+ category: "time",
1049
+ summary: "Midnight on the 1st of the instant's month, in an IANA zone (UTC by default).",
1050
+ deterministic: true,
1051
+ hostBacked: false,
1052
+ checkArgs: (lit) => literalGuard(() => {
1053
+ if (typeof lit[1] === "string")
1054
+ assertZone("startOfMonth", lit[1]);
1055
+ }),
1056
+ build: () => (t, tz) => {
1057
+ const zone = assertZone("startOfMonth", tz ?? "UTC");
1058
+ const f = zonedFields(instantArg("startOfMonth", t), zone);
1059
+ return instantOfZoned({ year: f.year, month: f.month, day: 1, hour: 0, minute: 0, second: 0 }, zone);
1060
+ },
1061
+ },
1062
+ {
1063
+ name: "addMonths",
1064
+ signature: "addMonths(timestamp, int, string?): timestamp",
1065
+ register: [
1066
+ "addMonths(google.protobuf.Timestamp, int): google.protobuf.Timestamp",
1067
+ "addMonths(google.protobuf.Timestamp, int, string): google.protobuf.Timestamp",
1068
+ ],
1069
+ category: "time",
1070
+ summary: "Shift an instant by whole months in an IANA zone, clamping the day of month (Jan 31 + 1 month is Feb 28).",
1071
+ deterministic: true,
1072
+ hostBacked: false,
1073
+ checkArgs: (lit) => literalGuard(() => {
1074
+ if (typeof lit[2] === "string")
1075
+ assertZone("addMonths", lit[2]);
1076
+ }),
1077
+ build: () => (t, months, tz) => {
1078
+ const zone = assertZone("addMonths", tz ?? "UTC");
1079
+ const f = zonedFields(instantArg("addMonths", t), zone);
1080
+ const shifted = f.year * 12 + (f.month - 1) + Number(months);
1081
+ // `%` takes the dividend's sign in JS, so a negative total would yield
1082
+ // month 0. Unreachable for realistic dates and wrong for free otherwise.
1083
+ const year = Math.floor(shifted / 12);
1084
+ const month = (((shifted % 12) + 12) % 12) + 1;
1085
+ return instantOfZoned({ ...f, year, month, day: Math.min(f.day, daysInMonth(year, month)) }, zone);
1086
+ },
1087
+ },
1088
+ {
1089
+ name: "compact",
1090
+ signature: "compact(dyn): dyn",
1091
+ // A `dyn` parameter accepted an instant (yielding `{}`) and a byte buffer
1092
+ // (yielding `{"0":137,…}`), both silently and both passing `telo check`.
1093
+ register: ["compact(list): list", "compact(map): map"],
1094
+ category: "collection",
1095
+ summary: "Drop entries whose value is null or the empty string, from a map or a list.",
1096
+ deterministic: true,
1097
+ hostBacked: false,
1098
+ build: () => (v) => compactValue(v),
1099
+ },
678
1100
  // UUID
679
1101
  {
680
1102
  name: "uuidv1",
@@ -32,6 +32,11 @@ export declare function functionIndex(env: Environment): FunctionIndex;
32
32
  export interface CallAudit {
33
33
  readonly diagnostics: readonly EngineDiagnostic[];
34
34
  readonly calls: readonly CallSite[];
35
+ /** Refusals decided from arguments written as literals. Reported whatever the
36
+ * type-checker said, unlike {@link CallAudit.diagnostics}: a call whose types
37
+ * are all correct and whose specifier is `.2q` type-checks perfectly, and is
38
+ * exactly the defect this catches. */
39
+ readonly argumentIssues: readonly EngineDiagnostic[];
35
40
  /** Names that resolve, but that no registered signature accepts as written.
36
41
  * The caller appends their signatures to a type-check failure it could not
37
42
  * otherwise explain. */
@@ -1 +1 @@
1
- {"version":3,"file":"diagnose.d.ts","sourceRoot":"","sources":["../../src/cel/diagnose.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEjE,OAAO,KAAK,EAAE,QAAQ,EAAiB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAE9E;;;;;;;;;;;;;;;4DAe4D;AAE5D,sEAAsE;AACtE,UAAU,OAAO;IACf,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,UAAU,CAAC;IACrC,mEAAmE;IACnE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAC,CAAC;CAC1D;AAWD;uDACuD;AACvD,wBAAgB,aAAa,CAAC,GAAG,EAAE,WAAW,GAAG,aAAa,CAiB7D;AAuJD,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,WAAW,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAClD,QAAQ,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAC;IACpC;;6BAEyB;IACzB,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAED;;;yBAGyB;AACzB,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,WAAW,GAAG,SAAS,CA0DpF;AAED;;6BAE6B;AAC7B,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,GAAG,EAAE,WAAW,GAAG,MAAM,CAIpF"}
1
+ {"version":3,"file":"diagnose.d.ts","sourceRoot":"","sources":["../../src/cel/diagnose.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEjE,OAAO,KAAK,EAAE,QAAQ,EAAiB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAE9E;;;;;;;;;;;;;;;4DAe4D;AAE5D,sEAAsE;AACtE,UAAU,OAAO;IACf,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,UAAU,CAAC;IACrC,mEAAmE;IACnE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAC,CAAC;CAC1D;AAWD;uDACuD;AACvD,wBAAgB,aAAa,CAAC,GAAG,EAAE,WAAW,GAAG,aAAa,CAiB7D;AA4KD,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,WAAW,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAClD,QAAQ,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAC;IACpC;;;2CAGuC;IACvC,QAAQ,CAAC,cAAc,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACrD;;6BAEyB;IACzB,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAED;;;yBAGyB;AACzB,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,WAAW,GAAG,SAAS,CA0EpF;AAED;;6BAE6B;AAC7B,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,GAAG,EAAE,WAAW,GAAG,MAAM,CAIpF"}