@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.
- package/dist/cel/analyze.d.ts.map +1 -1
- package/dist/cel/analyze.js +40 -0
- package/dist/cel/catalog.d.ts +19 -1
- package/dist/cel/catalog.d.ts.map +1 -1
- package/dist/cel/catalog.js +432 -10
- package/dist/cel/diagnose.d.ts +5 -0
- package/dist/cel/diagnose.d.ts.map +1 -1
- package/dist/cel/diagnose.js +37 -1
- package/dist/engines/cel.d.ts.map +1 -1
- package/dist/engines/cel.js +5 -0
- package/dist/engines/sql.d.ts.map +1 -1
- package/dist/engines/sql.js +7 -0
- package/package.json +4 -2
- package/src/cel/analyze.ts +37 -0
- package/src/cel/catalog.ts +476 -10
- package/src/cel/diagnose.ts +44 -2
- package/src/engines/cel.ts +6 -0
- package/src/engines/sql.ts +7 -0
package/src/cel/catalog.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
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",
|
package/src/cel/diagnose.ts
CHANGED
|
@@ -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
|
|
package/src/engines/cel.ts
CHANGED
|
@@ -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 {
|
package/src/engines/sql.ts
CHANGED
|
@@ -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 };
|