@jxsuite/runtime 4.1.0 → 5.2.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/src/expression.ts CHANGED
@@ -13,25 +13,65 @@ import {
13
13
  INTL_HELPER_PATHS,
14
14
  INTL_LOCALE_PARAM,
15
15
  } from "@jxsuite/schema/intl";
16
- import type { JxExpressionNode, JxExpressionOperand } from "@jxsuite/schema/types";
17
- import type { JxScope } from "./types.ts";
16
+ import { isFunction, isNonNullObject, isString } from "@jxsuite/schema/guards";
17
+ import type {
18
+ CemParameter,
19
+ JxExpressionNode,
20
+ JxExpressionOperand,
21
+ JxRef,
22
+ } from "@jxsuite/schema/types";
23
+ import type { JxScope, JxScopeValue } from "./types.ts";
18
24
  import { readPath, refAccessor, refSegments } from "./pointer.ts";
19
25
 
20
- /** The runtime's expression node — the schema's expression model. */
21
- export type ExpressionNode = JxExpressionNode;
22
- export type ExpressionOperand = JxExpressionOperand;
26
+ /** The published names of the schema's expression types, kept for existing importers. */
27
+ export type {
28
+ JxExpressionNode as ExpressionNode,
29
+ JxExpressionOperand as ExpressionOperand,
30
+ } from "@jxsuite/schema/types";
23
31
 
24
32
  /** The `$map` iteration context object stored in scope during mapped rendering. */
25
33
  interface ScopeMapCtx {
26
- item?: unknown;
34
+ item?: JxScopeValue;
27
35
  index?: number;
28
- [key: string]: unknown;
36
+ [key: string]: JxScopeValue;
29
37
  }
30
38
 
31
39
  /** View the scope's `$map` iteration context, if present. */
32
40
  function scopeMap(state: JxScope): ScopeMapCtx | undefined {
33
41
  const map = state.$map;
34
- return map && typeof map === "object" ? (map as ScopeMapCtx) : undefined;
42
+ /* SAFETY: the guard proved a non-null object, and the runtime binds `$map` only to the
43
+ `{ item, index }` record a mapped row renders under; every member is read as a scope
44
+ member. */
45
+ return isNonNullObject(map) ? (map as ScopeMapCtx) : undefined;
46
+ }
47
+
48
+ /**
49
+ * Spec §19.2 shape detection: an operand object carrying `operator` is a nested node, never a
50
+ * literal.
51
+ */
52
+ function isNodeOperand(operand: JxExpressionOperand): operand is JxExpressionNode {
53
+ return isNonNullObject(operand) && !Array.isArray(operand) && "operator" in operand;
54
+ }
55
+
56
+ /** Spec §19.2 shape detection: an operand object carrying `$ref` is a pointer, never a literal. */
57
+ function isRefOperand(operand: JxExpressionOperand): operand is JxRef {
58
+ return isNonNullObject(operand) && !Array.isArray(operand) && "$ref" in operand;
59
+ }
60
+
61
+ /**
62
+ * A scope callable, a blessed global or a standard-library method as a formula invokes it: the
63
+ * resolved arguments go in untyped, as they do in the compiled `fn(...args)`, and whatever the
64
+ * callee returns comes back.
65
+ */
66
+ type FormulaCallable = (...args: JxScopeValue[]) => JxScopeValue;
67
+
68
+ /** Named-formula arguments bound for one `call` body, read through `$args/<name>` refs. */
69
+ type FormulaArgs = Record<string, JxScopeValue>;
70
+
71
+ /** A named-formula def met in scope unlowered: its body, and its parameters as written. */
72
+ interface RawFormulaDef {
73
+ $expression: JxExpressionNode;
74
+ parameters?: readonly (string | CemParameter | null)[];
35
75
  }
36
76
 
37
77
  interface CompileOpts {
@@ -189,56 +229,84 @@ export const BLESSED_GLOBALS = new Set([
189
229
  ...INTL_HELPER_PATHS,
190
230
  ]);
191
231
 
232
+ /** What a synthetic helper returns: a formatted string, a comparison or a list of segments. */
233
+ type BlessedHelperResult = string | number | string[] | undefined;
234
+
235
+ /**
236
+ * One synthetic helper. Each declares the arguments its Intl constructor expects; the table holds
237
+ * them as `(...args: never[])`, the parameter list every function is assignable to, so helpers of
238
+ * different arities share one map.
239
+ */
240
+ type BlessedHelper = (...args: never[]) => BlessedHelperResult;
241
+
192
242
  /**
193
243
  * Synthetic blessed helpers (spec §19.4c): pure functions that have no direct `window` global
194
244
  * because the underlying standard-library API is a constructor. The interpreter dispatches to
195
245
  * these; the compiler emits the equivalent inline construct-then-format expression.
196
246
  */
197
- const BLESSED_HELPERS: Record<string, (...a: unknown[]) => unknown> = {
198
- "Intl/compare": (a, b, locale, options) =>
199
- new Intl.Collator(
200
- (locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE,
201
- options as Intl.CollatorOptions | undefined,
202
- ).compare(a as string, b as string),
203
- "Intl/displayName": (code, type, locale, options) =>
204
- new Intl.DisplayNames((locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE, {
205
- ...(options as Intl.DisplayNamesOptions | undefined),
206
- type: (type as Intl.DisplayNamesType | undefined) ?? "language",
207
- }).of(code as string),
208
- "Intl/formatDate": (value, locale, options) =>
209
- new Intl.DateTimeFormat((locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE, {
210
- timeZone: DEFAULT_TIME_ZONE,
211
- ...(options as Intl.DateTimeFormatOptions | undefined),
212
- }).format(new Date(value as string | number | Date)),
213
- "Intl/formatList": (values, locale, options) =>
214
- new Intl.ListFormat(
215
- (locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE,
216
- options as Intl.ListFormatOptions | undefined,
217
- ).format(values as string[]),
218
- "Intl/formatNumber": (value, locale, options) =>
219
- new Intl.NumberFormat(
220
- (locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE,
221
- options as Intl.NumberFormatOptions | undefined,
222
- ).format(value as number),
223
- "Intl/formatRelativeTime": (value, unit, locale, options) =>
224
- new Intl.RelativeTimeFormat(
225
- (locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE,
226
- options as Intl.RelativeTimeFormatOptions | undefined,
227
- ).format(value as number, unit as Intl.RelativeTimeFormatUnit),
228
- "Intl/plural": (value, locale, options) =>
229
- new Intl.PluralRules(
230
- (locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE,
231
- options as Intl.PluralRulesOptions | undefined,
232
- ).select(value as number),
247
+ const BLESSED_HELPERS = new Map<string, BlessedHelper>([
248
+ [
249
+ "Intl/compare",
250
+ (a: string, b: string, locale?: string, options?: Intl.CollatorOptions) =>
251
+ new Intl.Collator(locale ?? DEFAULT_FORMAT_LOCALE, options).compare(a, b),
252
+ ],
253
+ [
254
+ "Intl/displayName",
255
+ (
256
+ code: string,
257
+ type?: Intl.DisplayNamesType,
258
+ locale?: string,
259
+ options?: Intl.DisplayNamesOptions,
260
+ ) =>
261
+ new Intl.DisplayNames(locale ?? DEFAULT_FORMAT_LOCALE, {
262
+ ...options,
263
+ type: type ?? "language",
264
+ }).of(code),
265
+ ],
266
+ [
267
+ "Intl/formatDate",
268
+ (value: string | number | Date, locale?: string, options?: Intl.DateTimeFormatOptions) =>
269
+ new Intl.DateTimeFormat(locale ?? DEFAULT_FORMAT_LOCALE, {
270
+ timeZone: DEFAULT_TIME_ZONE,
271
+ ...options,
272
+ }).format(new Date(value)),
273
+ ],
274
+ [
275
+ "Intl/formatList",
276
+ (values: string[], locale?: string, options?: Intl.ListFormatOptions) =>
277
+ new Intl.ListFormat(locale ?? DEFAULT_FORMAT_LOCALE, options).format(values),
278
+ ],
279
+ [
280
+ "Intl/formatNumber",
281
+ (value: number, locale?: string, options?: Intl.NumberFormatOptions) =>
282
+ new Intl.NumberFormat(locale ?? DEFAULT_FORMAT_LOCALE, options).format(value),
283
+ ],
284
+ [
285
+ "Intl/formatRelativeTime",
286
+ (
287
+ value: number,
288
+ unit: Intl.RelativeTimeFormatUnit,
289
+ locale?: string,
290
+ options?: Intl.RelativeTimeFormatOptions,
291
+ ) => new Intl.RelativeTimeFormat(locale ?? DEFAULT_FORMAT_LOCALE, options).format(value, unit),
292
+ ],
293
+ [
294
+ "Intl/plural",
295
+ (value: number, locale?: string, options?: Intl.PluralRulesOptions) =>
296
+ new Intl.PluralRules(locale ?? DEFAULT_FORMAT_LOCALE, options).select(value),
297
+ ],
233
298
  /* Returns the segments as plain strings, because a formula's value has to be JSON — the
234
299
  Segmenter's own iterator of {segment, index, isWordLike} records is not. */
235
- "Intl/segment": (value, granularity, locale) =>
236
- [
237
- ...new Intl.Segmenter((locale as string | undefined) ?? DEFAULT_FORMAT_LOCALE, {
238
- granularity: (granularity as "grapheme" | "sentence" | "word" | undefined) ?? "grapheme",
239
- }).segment(value as string),
240
- ].map((part) => part.segment),
241
- };
300
+ [
301
+ "Intl/segment",
302
+ (value: string, granularity?: "grapheme" | "sentence" | "word", locale?: string) =>
303
+ [
304
+ ...new Intl.Segmenter(locale ?? DEFAULT_FORMAT_LOCALE, {
305
+ granularity: granularity ?? "grapheme",
306
+ }).segment(value),
307
+ ].map((part) => part.segment),
308
+ ],
309
+ ]);
242
310
 
243
311
  /**
244
312
  * Fill in a helper's omitted `locale` argument from the page's own locale.
@@ -255,18 +323,17 @@ const BLESSED_HELPERS: Record<string, (...a: unknown[]) => unknown> = {
255
323
  * used standalone — keeps the fixed default.
256
324
  *
257
325
  * @param {string} path - Helper callee path, e.g. `Intl/formatNumber`
258
- * @param {unknown[]} args - Resolved arguments, in declaration order
326
+ * @param {JxScopeValue[]} args - Resolved arguments, in declaration order
259
327
  * @param {JxScope} state
260
- * @returns {unknown[]}
328
+ * @returns {JxScopeValue[]}
261
329
  */
262
- function withPageLocale(path: string, args: unknown[], state: JxScope): unknown[] {
330
+ function withPageLocale(path: string, args: JxScopeValue[], state: JxScope): JxScopeValue[] {
263
331
  const index = INTL_LOCALE_PARAM[path];
264
332
  if (index === undefined || index < 0 || args[index] != null) {
265
333
  return args;
266
334
  }
267
- const page = (state as { $page?: { locale?: unknown } }).$page;
268
- const locale = page?.locale;
269
- if (typeof locale !== "string" || locale === "") {
335
+ const locale = readPath(state.$page, "locale");
336
+ if (!isString(locale) || locale === "") {
270
337
  return args;
271
338
  }
272
339
  const filled = [...args];
@@ -290,11 +357,11 @@ export function isMutating(op: string) {
290
357
  // ─── Runtime Evaluation ──────────────────────────────────────────────────────
291
358
 
292
359
  interface IterCtx {
293
- acc?: unknown;
294
- item?: unknown;
360
+ acc?: JxScopeValue;
361
+ item?: JxScopeValue;
295
362
  index?: number;
296
363
  /** Named-formula arguments bound for the current `call` body ($args/<name> refs). */
297
- args?: Record<string, unknown>;
364
+ args?: FormulaArgs;
298
365
  /** Call-nesting depth; capped at MAX_CALL_DEPTH to bound recursive formulas. */
299
366
  callDepth?: number;
300
367
  }
@@ -309,7 +376,7 @@ export const MAX_CALL_DEPTH = 64;
309
376
  * can badge every node. Absent (production), evaluation is untouched.
310
377
  */
311
378
  export interface ExpressionTrace {
312
- report: (path: (string | number)[], value: unknown) => void;
379
+ report: (path: (string | number)[], value: JxScopeValue) => void;
313
380
  /** Current node path — internal; leave unset at the call boundary. */
314
381
  path?: (string | number)[];
315
382
  /** Recursion depth — internal; reporting stops beyond MAX_REPORT_DEPTH. */
@@ -340,33 +407,31 @@ function subTrace(
340
407
 
341
408
  /** Resolve an operand to its runtime value. */
342
409
  function resolveOperand(
343
- operand: unknown,
410
+ operand: JxExpressionOperand | undefined,
344
411
  state: JxScope,
345
412
  event: Event | null,
346
413
  iterCtx?: IterCtx,
347
414
  trace?: ExpressionTrace,
348
- ): unknown {
415
+ ): JxScopeValue {
349
416
  if (operand === null || operand === undefined) {
350
417
  return operand;
351
418
  }
352
419
 
353
420
  // Nested expression node
354
- if (typeof operand === "object" && !Array.isArray(operand) && "operator" in operand) {
355
- return evaluateExpression(operand as ExpressionNode, state, event, iterCtx, trace);
421
+ if (isNodeOperand(operand)) {
422
+ return evaluateExpression(operand, state, event, iterCtx, trace);
356
423
  }
357
424
 
358
425
  // $ref pointer
359
- if (typeof operand === "object" && !Array.isArray(operand) && "$ref" in operand) {
360
- const resolved = resolveExprRef((operand as { $ref: string }).$ref, state, event, iterCtx);
426
+ if (isRefOperand(operand)) {
427
+ const resolved = resolveExprRef(operand.$ref, state, event, iterCtx);
361
428
  trace?.report(trace.path ?? [], resolved);
362
429
  return resolved;
363
430
  }
364
431
 
365
432
  // Array of operands (e.g., splice args)
366
433
  if (Array.isArray(operand)) {
367
- return operand.map((o: unknown, i: number) =>
368
- resolveOperand(o, state, event, iterCtx, subTrace(trace, i)),
369
- );
434
+ return resolveOperands(operand, state, event, iterCtx, trace);
370
435
  }
371
436
 
372
437
  // Literal
@@ -374,14 +439,29 @@ function resolveOperand(
374
439
  return operand;
375
440
  }
376
441
 
377
- /** Resolve a $ref string within expression context. */
442
+ /** Resolve each operand of a list, tracing member `i` at segment `i` beneath `trace`. */
443
+ function resolveOperands(
444
+ operands: readonly JxExpressionOperand[],
445
+ state: JxScope,
446
+ event: Event | null,
447
+ iterCtx: IterCtx | undefined,
448
+ trace: ExpressionTrace | undefined,
449
+ ): JxScopeValue[] {
450
+ return operands.map((o, i) => resolveOperand(o, state, event, iterCtx, subTrace(trace, i)));
451
+ }
452
+
378
453
  /**
379
454
  * The receiver a `call` binds: the value the pointer's parent path names. `#/state/x` and
380
455
  * `parent#/x` are owned by the scope itself; `$map/item/fn` by the item; a pointer with no parent
381
456
  * segment (`$map/item`, `$args/x`) has no owner.
382
457
  */
383
- function calleeOwner(ref: string, state: JxScope, event: Event | null, iterCtx?: IterCtx): unknown {
384
- const parentRef = (prefix: string): unknown => {
458
+ function calleeOwner(
459
+ ref: string,
460
+ state: JxScope,
461
+ event: Event | null,
462
+ iterCtx?: IterCtx,
463
+ ): JxScopeValue {
464
+ const parentRef = (prefix: string): JxScopeValue => {
385
465
  const rest = ref.slice(prefix.length);
386
466
  if (!rest.includes("/")) {
387
467
  return state;
@@ -402,6 +482,7 @@ function calleeOwner(ref: string, state: JxScope, event: Event | null, iterCtx?:
402
482
  return undefined;
403
483
  }
404
484
 
485
+ /** Resolve a $ref string within expression context. */
405
486
  function resolveExprRef(ref: string, state: JxScope, event: Event | null, iterCtx?: IterCtx) {
406
487
  if (ref === "$reduce/acc") {
407
488
  return iterCtx?.acc;
@@ -412,11 +493,11 @@ function resolveExprRef(ref: string, state: JxScope, event: Event | null, iterCt
412
493
  if (ref.startsWith("$args/")) {
413
494
  const parts = ref.slice("$args/".length).split("/");
414
495
  const base = iterCtx?.args?.[parts[0]!];
415
- return parts.length > 1 ? getPath(base, parts.slice(1).join("/")) : base;
496
+ return parts.length > 1 ? readPath(base, parts.slice(1).join("/")) : base;
416
497
  }
417
498
  if (ref.startsWith("event#/")) {
418
499
  const path = ref.slice("event#/".length);
419
- return getPath(event, path);
500
+ return readPath(event, path);
420
501
  }
421
502
  if (ref.startsWith("$map/")) {
422
503
  const parts = ref.split("/");
@@ -430,7 +511,7 @@ function resolveExprRef(ref: string, state: JxScope, event: Event | null, iterCt
430
511
  } else {
431
512
  base = map?.[key!] ?? state[`$map/${key}`];
432
513
  }
433
- return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
514
+ return parts.length > 2 ? readPath(base, parts.slice(2).join("/")) : base;
434
515
  }
435
516
  if (ref.startsWith("#/state/")) {
436
517
  // One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
@@ -445,21 +526,27 @@ function resolveExprRef(ref: string, state: JxScope, event: Event | null, iterCt
445
526
  return readPath(state, ref.slice("parent#/".length));
446
527
  }
447
528
  if (ref.startsWith("window#/")) {
448
- return getPath(globalThis.window, ref.slice("window#/".length));
529
+ return readPath(globalThis.window, ref.slice("window#/".length));
449
530
  }
450
531
  if (ref.startsWith("document#/")) {
451
- return getPath(globalThis.document, ref.slice("document#/".length));
532
+ return readPath(globalThis.document, ref.slice("document#/".length));
452
533
  }
453
534
  return readPath(state, ref) ?? null;
454
535
  }
455
536
 
537
+ /** Where an assignment writes: the member `key` of the object `obj`. */
538
+ interface WritableLocation {
539
+ obj: JxScope;
540
+ key: string;
541
+ }
542
+
456
543
  /** Resolve a $ref to a writable location — returns { obj, key } for assignment. */
457
544
  function resolveWritableRef(
458
545
  ref: string,
459
546
  state: JxScope,
460
547
  _event: Event | null,
461
548
  iterCtx?: IterCtx,
462
- ): { obj: JxScope; key: string } {
549
+ ): WritableLocation {
463
550
  if (ref.startsWith("$map/")) {
464
551
  const parts = ref.split("/");
465
552
  const [, key] = parts;
@@ -475,7 +562,11 @@ function resolveWritableRef(
475
562
  if (parts.length > 2) {
476
563
  const pathParts = parts.slice(2);
477
564
  const lastKey = pathParts.pop();
478
- const obj = pathParts.length > 0 ? getPath(base, pathParts.join("/")) : base;
565
+ const obj = pathParts.length > 0 ? readPath(base, pathParts.join("/")) : base;
566
+ /* SAFETY: `parts.length > 2`, so `pathParts` held at least one segment and `pop()` returned a
567
+ string. A `$map/…` write names a member of an object in the item (spec §19.2 resolves it
568
+ like any `$ref`); a non-object here fails at the assignment as the compiled `_item.a = v`
569
+ does. */
479
570
  return { key: lastKey as string, obj: obj as JxScope };
480
571
  }
481
572
  return {
@@ -497,10 +588,13 @@ function resolveWritableRef(
497
588
  const parts = refSegments(sub);
498
589
  const lastKey = parts.pop();
499
590
  let obj = state;
500
- // Pointer paths address objects by construction (validated by the schema).
501
591
  for (const p of parts) {
592
+ /* SAFETY: a writable pointer's leading segments name the objects that hold its target (spec
593
+ §19.3). The runtime does not validate that, so a missing or primitive segment fails at the
594
+ next member access or at the write, as the compiled `s.a.b = v` does. */
502
595
  obj = obj[p] as JxScope;
503
596
  }
597
+ // SAFETY: `sub` contains a `/`, so `refSegments` returned at least two segments and `pop()` a string.
504
598
  return { key: lastKey as string, obj };
505
599
  }
506
600
  return { key: ref, obj: state };
@@ -508,24 +602,69 @@ function resolveWritableRef(
508
602
 
509
603
  /** Evaluate an expression node at runtime. */
510
604
  export function evaluateExpression(
511
- node: ExpressionNode,
605
+ node: JxExpressionNode,
512
606
  state: JxScope,
513
607
  event: Event | null,
514
608
  iterCtx?: IterCtx,
515
609
  trace?: ExpressionTrace,
516
- ): unknown {
610
+ ): JxScopeValue {
517
611
  const result = evaluateNode(node, state, event, iterCtx, trace);
518
612
  trace?.report(trace.path ?? [], result);
519
613
  return result;
520
614
  }
521
615
 
616
+ /**
617
+ * Apply one of the binary operators that convert their own operands: the arithmetic ones (ECMA-262
618
+ * §13.15.3 ApplyStringOrNumericBinaryOperator) and the relational ones (§7.2.13 IsLessThan). The
619
+ * interpreter hands them the resolved values unconverted, exactly as the compiled `(left op right)`
620
+ * does, so `+` still concatenates when either side is a string; `number` is what lets TypeScript
621
+ * accept the operators, not a conversion.
622
+ *
623
+ * @param {string} operator - One of `+ - * / %` or `< <= > >=`
624
+ * @param {number} left
625
+ * @param {number} right
626
+ * @returns {number | boolean}
627
+ */
628
+ function applyCoercingOperator(operator: string, left: number, right: number): number | boolean {
629
+ switch (operator) {
630
+ case "+": {
631
+ return left + right;
632
+ }
633
+ case "-": {
634
+ return left - right;
635
+ }
636
+ case "*": {
637
+ return left * right;
638
+ }
639
+ case "/": {
640
+ return left / right;
641
+ }
642
+ case "%": {
643
+ return left % right;
644
+ }
645
+ case "<": {
646
+ return left < right;
647
+ }
648
+ case "<=": {
649
+ return left <= right;
650
+ }
651
+ case ">": {
652
+ return left > right;
653
+ }
654
+ default: {
655
+ // `>=`: the binary operator set leaves nothing else that reaches here.
656
+ return left >= right;
657
+ }
658
+ }
659
+ }
660
+
522
661
  function evaluateNode(
523
- node: ExpressionNode,
662
+ node: JxExpressionNode,
524
663
  state: JxScope,
525
664
  event: Event | null,
526
665
  iterCtx?: IterCtx,
527
666
  trace?: ExpressionTrace,
528
- ): unknown {
667
+ ): JxScopeValue {
529
668
  const { operator, target, value, initial } = node;
530
669
 
531
670
  if (!BLESSED_OPERATORS.has(operator)) {
@@ -534,25 +673,20 @@ function evaluateNode(
534
673
 
535
674
  // ─── Unary ───
536
675
  if (UNARY_OPS.has(operator) && !("value" in node)) {
537
- const operand: unknown = resolveOperand(
538
- target,
539
- state,
540
- event,
541
- iterCtx,
542
- subTrace(trace, "target"),
543
- );
676
+ const operand = resolveOperand(target, state, event, iterCtx, subTrace(trace, "target"));
544
677
  if (operator === "!") {
545
678
  return !operand;
546
679
  }
547
680
  if (operator === "-") {
681
+ // SAFETY: unary minus applies ToNumeric itself (ECMA-262 §13.5.4); `number` only lets TypeScript accept the operator.
548
682
  return -(operand as number);
549
683
  }
550
684
  }
551
685
 
552
686
  // ─── Call (spec §19.4c) ───
553
687
  if (operator === "call") {
554
- const calleeRef = (target as { $ref?: string } | null)?.$ref;
555
- if (typeof calleeRef !== "string") {
688
+ const calleeRef = isRefOperand(target) ? target.$ref : undefined;
689
+ if (!isString(calleeRef)) {
556
690
  throw new TypeError("$expression: call target must be a $ref pointer");
557
691
  }
558
692
  const callDepth = (iterCtx?.callDepth ?? 0) + 1;
@@ -560,9 +694,7 @@ function evaluateNode(
560
694
  throw new Error(`$expression: call depth exceeded (${MAX_CALL_DEPTH})`);
561
695
  }
562
696
  const argValues = Array.isArray(value)
563
- ? value.map((o: unknown, i: number) =>
564
- resolveOperand(o, state, event, iterCtx, subTrace(trace, ["value", i])),
565
- )
697
+ ? value.map((o, i) => resolveOperand(o, state, event, iterCtx, subTrace(trace, ["value", i])))
566
698
  : [];
567
699
 
568
700
  // Blessed pure global via the window#/ scheme (Math.max, JSON.parse, …).
@@ -571,16 +703,23 @@ function evaluateNode(
571
703
  throw new Error(`$expression: "${calleeRef}" is not a blessed pure global`);
572
704
  }
573
705
  const globalPath = calleeRef.slice("window#/".length);
574
- const helper = BLESSED_HELPERS[globalPath];
706
+ const helper = BLESSED_HELPERS.get(globalPath);
575
707
  if (helper) {
576
- return helper(...withPageLocale(globalPath, argValues, state));
708
+ /* SAFETY: formula arguments carry no runtime types; each helper hands them to an Intl
709
+ constructor that coerces or throws itself, exactly as the inline call
710
+ `compileHelperCall` emits does. The parameter types document that contract. */
711
+ return (helper as (...args: JxScopeValue[]) => BlessedHelperResult)(
712
+ ...withPageLocale(globalPath, argValues, state),
713
+ );
577
714
  }
578
- const fn = getPath(globalThis.window, globalPath) as (...a: unknown[]) => unknown;
715
+ /* SAFETY: `isBlessedGlobal` admitted `globalPath`, and every BLESSED_GLOBALS entry without a
716
+ synthetic helper names a standard-library function on `window` (Math.max, JSON.parse, …). */
717
+ const fn = readPath(globalThis.window, globalPath) as FormulaCallable;
579
718
  const lastSlash = globalPath.lastIndexOf("/");
580
719
  const thisArg =
581
720
  lastSlash === -1
582
721
  ? globalThis.window
583
- : getPath(globalThis.window, globalPath.slice(0, lastSlash));
722
+ : readPath(globalThis.window, globalPath.slice(0, lastSlash));
584
723
  return fn.apply(thisArg, argValues);
585
724
  }
586
725
 
@@ -588,24 +727,28 @@ function evaluateNode(
588
727
  // A scope callable (buildScope lowers parameterized formula entries to functions). The receiver
589
728
  // Is the object that owns the pointer's last segment — what the compiled lowering binds when it
590
729
  // Emits `s.commands.run(...)` — so a host method handed in through the scope keeps its `this`.
591
- if (typeof callee === "function") {
592
- return (callee as (...a: unknown[]) => unknown).apply(
730
+ if (isFunction(callee)) {
731
+ // SAFETY: the guard proved `callee` callable; a scope callable takes the resolved arguments untyped, as the compiled call does.
732
+ return (callee as FormulaCallable).apply(
593
733
  calleeOwner(calleeRef, state, event, iterCtx),
594
734
  argValues,
595
735
  );
596
736
  }
597
737
  // A raw named-formula def (standalone evaluation, e.g. editor preview).
598
- if (callee && typeof callee === "object" && "$expression" in callee) {
599
- const def = callee as { $expression: ExpressionNode; parameters?: unknown[] };
600
- const args: Record<string, unknown> = {};
738
+ if (isNonNullObject(callee) && "$expression" in callee) {
739
+ /* SAFETY: a scope member carrying `$expression` is a named-formula def (spec §19.4c), left
740
+ unlowered only when the document is evaluated standalone. Its parameters are read as the
741
+ document wrote them, so a malformed `null` entry stays possible and is skipped below. */
742
+ const def = callee as RawFormulaDef;
743
+ const args: FormulaArgs = {};
601
744
  for (const [i, p] of (def.parameters ?? []).entries()) {
602
- const name = typeof p === "string" ? p : ((p as { name?: string } | null)?.name ?? "");
745
+ const name = isString(p) ? p : (p?.name ?? "");
603
746
  if (!name) {
604
747
  continue;
605
748
  }
606
749
  args[name] =
607
- argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
608
- ? (p as { default?: unknown }).default
750
+ argValues[i] === undefined && isNonNullObject(p) && "default" in p
751
+ ? p.default
609
752
  : argValues[i];
610
753
  }
611
754
  // The callee body's node paths are its own tree, not the call site's — no trace inside.
@@ -640,7 +783,7 @@ function evaluateNode(
640
783
  const cases = node.cases ?? {};
641
784
  const matched = Object.hasOwn(cases, key);
642
785
  if (trace) {
643
- let result: unknown;
786
+ let result: JxScopeValue;
644
787
  for (const [k, caseOperand] of Object.entries(cases)) {
645
788
  const v = resolveOperand(caseOperand, state, event, iterCtx, subTrace(trace, ["cases", k]));
646
789
  if (k === key) {
@@ -663,49 +806,15 @@ function evaluateNode(
663
806
 
664
807
  // ─── Binary (pure) ───
665
808
  if (BINARY_OPS.has(operator)) {
666
- const left = resolveOperand(
667
- target,
668
- state,
669
- event,
670
- iterCtx,
671
- subTrace(trace, "target"),
672
- ) as number & string;
673
- const right = resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")) as number &
674
- string;
809
+ const left = resolveOperand(target, state, event, iterCtx, subTrace(trace, "target"));
810
+ const right = resolveOperand(value, state, event, iterCtx, subTrace(trace, "value"));
675
811
  switch (operator) {
676
- case "+": {
677
- return left + right;
678
- }
679
- case "-": {
680
- return left - right;
681
- }
682
- case "*": {
683
- return left * right;
684
- }
685
- case "/": {
686
- return left / right;
687
- }
688
- case "%": {
689
- return left % right;
690
- }
691
812
  case "===": {
692
813
  return left === right;
693
814
  }
694
815
  case "!==": {
695
816
  return left !== right;
696
817
  }
697
- case "<": {
698
- return left < right;
699
- }
700
- case "<=": {
701
- return left <= right;
702
- }
703
- case ">": {
704
- return left > right;
705
- }
706
- case ">=": {
707
- return left >= right;
708
- }
709
818
  case "&&": {
710
819
  return left && right;
711
820
  }
@@ -713,64 +822,40 @@ function evaluateNode(
713
822
  return left || right;
714
823
  }
715
824
  case "??": {
716
- return (left as unknown) ?? (right as unknown);
825
+ return left ?? right;
717
826
  }
718
827
  default: {
719
- break;
828
+ // SAFETY: the remaining binary operators coerce their own operands (see applyCoercingOperator).
829
+ return applyCoercingOperator(operator, left as number, right as number);
720
830
  }
721
831
  }
722
832
  }
723
833
 
724
834
  // ─── Assignment ───
725
835
  if (ASSIGNMENT_OPS.has(operator)) {
726
- const rhs = resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")) as number;
727
- const { obj, key } = resolveWritableRef(
728
- (target as { $ref: string }).$ref,
729
- state,
730
- event,
731
- iterCtx,
732
- );
733
- switch (operator) {
734
- case "=": {
735
- obj[key] = rhs;
736
- break;
737
- }
738
- case "+=": {
739
- const current = obj[key] as number;
740
- obj[key] = current + rhs;
741
- break;
742
- }
743
- case "-=": {
744
- const current = obj[key] as number;
745
- obj[key] = current - rhs;
746
- break;
747
- }
748
- case "*=": {
749
- const current = obj[key] as number;
750
- obj[key] = current * rhs;
751
- break;
752
- }
753
- case "/=": {
754
- const current = obj[key] as number;
755
- obj[key] = current / rhs;
756
- break;
757
- }
758
- default: {
759
- break;
760
- }
761
- }
836
+ const rhs = resolveOperand(value, state, event, iterCtx, subTrace(trace, "value"));
837
+ // SAFETY: an assignment's target is a writable `$ref` (spec §19.3); the schema admits nothing else there.
838
+ const { obj, key } = resolveWritableRef((target as JxRef).$ref, state, event, iterCtx);
839
+ /* SAFETY: a compound `a op= b` is `a = a op b`, and `op` converts its own operands (see
840
+ applyCoercingOperator); plain `=` stores `rhs` untouched and never reads `obj[key]`. */
841
+ obj[key] =
842
+ operator === "="
843
+ ? rhs
844
+ : applyCoercingOperator(operator.slice(0, -1), obj[key] as number, rhs as number);
762
845
  return;
763
846
  }
764
847
 
765
848
  // ─── Array methods ───
766
849
  if (ARRAY_METHOD_OPS.has(operator)) {
767
- const arr: unknown[] = resolveOperand(
850
+ /* SAFETY: an array-mutation operator's receiver is an array (spec §19.3); a non-array fails at
851
+ the method call exactly as the compiled `receiver.push(v)` does. */
852
+ const arr = resolveOperand(
768
853
  target,
769
854
  state,
770
855
  event,
771
856
  iterCtx,
772
857
  subTrace(trace, "target"),
773
- ) as unknown[];
858
+ ) as JxScopeValue[];
774
859
  switch (operator) {
775
860
  case "push": {
776
861
  return arr.push(resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")));
@@ -785,14 +870,15 @@ function evaluateNode(
785
870
  return arr.shift();
786
871
  }
787
872
  case "splice": {
788
- const args: unknown[] = resolveOperand(
789
- value,
790
- state,
791
- event,
792
- iterCtx,
793
- subTrace(trace, "value"),
794
- ) as unknown[];
795
- return (arr.splice as (...a: unknown[]) => unknown[])(...args);
873
+ /* SAFETY: splice's `value` is its argument list `[start, deleteCount, ...items]` (spec
874
+ §19.3); splice converts `start` and `deleteCount` itself (ToIntegerOrInfinity), as the
875
+ compiled `receiver.splice(...)` does. */
876
+ const args = resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")) as [
877
+ start: number,
878
+ deleteCount: number,
879
+ ...items: JxScopeValue[],
880
+ ];
881
+ return arr.splice(...args);
796
882
  }
797
883
  default: {
798
884
  break;
@@ -807,32 +893,38 @@ function evaluateNode(
807
893
  value === undefined
808
894
  ? []
809
895
  : Array.isArray(value)
810
- ? (resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")) as unknown[])
896
+ ? resolveOperands(value, state, event, iterCtx, subTrace(trace, "value"))
811
897
  : [resolveOperand(value, state, event, iterCtx, subTrace(trace, "value"))];
812
- const method = (receiver as Record<string, unknown> | null | undefined)?.[operator];
813
- if (typeof method !== "function") {
898
+ const method = readPath(receiver, operator);
899
+ if (!isFunction(method)) {
814
900
  // Null-safe, like path reads: a missing receiver or method yields undefined, not a throw.
815
901
  return undefined;
816
902
  }
817
- return (method as (...a: unknown[]) => unknown).apply(receiver, args);
903
+ // SAFETY: the guard proved `method` callable; a standard-library method coerces its own arguments.
904
+ return (method as FormulaCallable).apply(receiver, args);
818
905
  }
819
906
 
820
907
  // ─── Aggregates (pure) ───
821
908
  if (AGGREGATE_OPS.has(operator)) {
822
- const arr: unknown[] = resolveOperand(
909
+ /* SAFETY: an aggregate's source is an array (spec §19.4a); a non-array fails at the iteration
910
+ exactly as the compiled `source.map(...)` does. */
911
+ const arr = resolveOperand(
823
912
  target,
824
913
  state,
825
914
  event,
826
915
  iterCtx,
827
916
  subTrace(trace, "target"),
828
- ) as unknown[];
917
+ ) as JxScopeValue[];
918
+ /* SAFETY: an aggregate's `value` is its per-item expression node (spec §19.4a), the only shape
919
+ the schema admits there; anything else fails evaluateExpression's operator check. */
920
+ const perItem = value as JxExpressionNode;
829
921
  // Trace only the first iteration's per-item expression — one sample badge per node,
830
922
  // Not one report per array element.
831
923
  if (operator === "reduce") {
832
- let acc: unknown = resolveOperand(initial, state, event, iterCtx, subTrace(trace, "initial"));
924
+ let acc = resolveOperand(initial, state, event, iterCtx, subTrace(trace, "initial"));
833
925
  for (const [index, item] of arr.entries()) {
834
926
  acc = evaluateExpression(
835
- value as ExpressionNode,
927
+ perItem,
836
928
  state,
837
929
  event,
838
930
  { acc, index, item },
@@ -842,9 +934,9 @@ function evaluateNode(
842
934
  return acc;
843
935
  }
844
936
  if (operator === "map") {
845
- return arr.map((item: unknown, index: number) =>
937
+ return arr.map((item, index) =>
846
938
  evaluateExpression(
847
- value as ExpressionNode,
939
+ perItem,
848
940
  state,
849
941
  event,
850
942
  { ...iterCtx, index, item },
@@ -853,9 +945,9 @@ function evaluateNode(
853
945
  );
854
946
  }
855
947
  if (operator === "filter") {
856
- return arr.filter((item: unknown, index: number) =>
948
+ return arr.filter((item, index) =>
857
949
  evaluateExpression(
858
- value as ExpressionNode,
950
+ perItem,
859
951
  state,
860
952
  event,
861
953
  { ...iterCtx, index, item },
@@ -871,7 +963,7 @@ function evaluateNode(
871
963
  // ─── Compiler: Expression → JS Source ────────────────────────────────────────
872
964
 
873
965
  /** Compile an operand to a JS source string. */
874
- function compileOperand(operand: unknown, opts: CompileOpts): string {
966
+ function compileOperand(operand: JxExpressionOperand | undefined, opts: CompileOpts): string {
875
967
  if (operand === null) {
876
968
  return "null";
877
969
  }
@@ -880,18 +972,18 @@ function compileOperand(operand: unknown, opts: CompileOpts): string {
880
972
  }
881
973
 
882
974
  // Nested expression node
883
- if (typeof operand === "object" && !Array.isArray(operand) && "operator" in operand) {
884
- return compileExpression(operand as ExpressionNode, opts);
975
+ if (isNodeOperand(operand)) {
976
+ return compileExpression(operand, opts);
885
977
  }
886
978
 
887
979
  // $ref pointer
888
- if (typeof operand === "object" && !Array.isArray(operand) && "$ref" in operand) {
889
- return compileRef((operand as { $ref: string }).$ref, opts);
980
+ if (isRefOperand(operand)) {
981
+ return compileRef(operand.$ref, opts);
890
982
  }
891
983
 
892
984
  // Array of operands
893
985
  if (Array.isArray(operand)) {
894
- return operand.map((o: unknown) => compileOperand(o, opts)).join(", ");
986
+ return operand.map((o) => compileOperand(o, opts)).join(", ");
895
987
  }
896
988
 
897
989
  // Literal
@@ -968,9 +1060,9 @@ function compileRef(ref: string, opts: CompileOpts) {
968
1060
  }
969
1061
 
970
1062
  /** Compile a writable $ref target to its JS equivalent (for LHS of assignment). */
971
- function compileTarget(target: unknown, opts: CompileOpts): string {
972
- if (typeof target === "object" && target !== null && "$ref" in target) {
973
- return compileRef((target as { $ref: string }).$ref, opts);
1063
+ function compileTarget(target: JxExpressionOperand, opts: CompileOpts): string {
1064
+ if (isRefOperand(target)) {
1065
+ return compileRef(target.$ref, opts);
974
1066
  }
975
1067
  return compileOperand(target, opts);
976
1068
  }
@@ -979,7 +1071,7 @@ function compileTarget(target: unknown, opts: CompileOpts): string {
979
1071
  * Compile an expression node to a JS source string. Returns a statement for mutating ops, a value
980
1072
  * expression for pure ops.
981
1073
  *
982
- * @param {ExpressionNode} node
1074
+ * @param {JxExpressionNode} node
983
1075
  * @param {CompileOpts} [opts]
984
1076
  * @returns {string}
985
1077
  */
@@ -1042,7 +1134,7 @@ function compileHelperCall(path: string, args: string[], opts: CompileOpts): str
1042
1134
  }
1043
1135
  }
1044
1136
 
1045
- export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}): string {
1137
+ export function compileExpression(node: JxExpressionNode, opts: CompileOpts = {}): string {
1046
1138
  const { operator, target, value, initial } = node;
1047
1139
 
1048
1140
  // ─── Unary ───
@@ -1058,8 +1150,8 @@ export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}):
1058
1150
 
1059
1151
  // ─── Call (spec §19.4c) ───
1060
1152
  if (operator === "call") {
1061
- const calleeRef = (target as { $ref?: string } | null)?.$ref ?? "";
1062
- const args = Array.isArray(value) ? value.map((o: unknown) => compileOperand(o, opts)) : [];
1153
+ const calleeRef = (isRefOperand(target) ? target.$ref : undefined) ?? "";
1154
+ const args = Array.isArray(value) ? value.map((o) => compileOperand(o, opts)) : [];
1063
1155
  if (calleeRef.startsWith("window#/")) {
1064
1156
  if (!isBlessedGlobal(calleeRef)) {
1065
1157
  throw new Error(`$expression: "${calleeRef}" is not a blessed pure global`);
@@ -1133,7 +1225,7 @@ export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}):
1133
1225
  }
1134
1226
  case "splice": {
1135
1227
  const args: string = Array.isArray(value)
1136
- ? value.map((o: unknown) => compileOperand(o, opts)).join(", ")
1228
+ ? value.map((o) => compileOperand(o, opts)).join(", ")
1137
1229
  : compileOperand(value, opts);
1138
1230
  return `${arr}.splice(${args})`;
1139
1231
  }
@@ -1150,7 +1242,7 @@ export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}):
1150
1242
  value === undefined
1151
1243
  ? ""
1152
1244
  : Array.isArray(value)
1153
- ? value.map((o: unknown) => compileOperand(o, opts)).join(", ")
1245
+ ? value.map((o) => compileOperand(o, opts)).join(", ")
1154
1246
  : compileOperand(value, opts);
1155
1247
  // ?.method?.() matches the interpreter: missing receiver or method yields undefined.
1156
1248
  return `(${receiver})?.${operator}?.(${args})`;
@@ -1159,7 +1251,8 @@ export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}):
1159
1251
  // ─── Aggregates (pure) ───
1160
1252
  if (AGGREGATE_OPS.has(operator)) {
1161
1253
  const arr: string = compileOperand(target, opts);
1162
- const itemExpr: string = compileExpression(value as ExpressionNode, opts);
1254
+ // SAFETY: an aggregate's `value` is its per-item expression node (spec §19.4a), the only shape the schema admits there.
1255
+ const itemExpr: string = compileExpression(value as JxExpressionNode, opts);
1163
1256
  if (operator === "reduce") {
1164
1257
  const seed: string = compileOperand(initial, opts);
1165
1258
  return `${arr}.reduce((_acc, _item, _index) => ${itemExpr}, ${seed})`;
@@ -1175,13 +1268,6 @@ export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}):
1175
1268
  return "undefined";
1176
1269
  }
1177
1270
 
1178
- // ─── Utilities ───────────────────────────────────────────────────────────────
1179
-
1180
- /** Resolve a `$ref` path on an object, through the one tokenizer (`pointer.ts`). */
1181
- function getPath(obj: unknown, path: string): unknown {
1182
- return readPath(obj, path);
1183
- }
1184
-
1185
1271
  // ─── Statement-Engine Surface (spec §20) ─────────────────────────────────────
1186
1272
 
1187
1273
  /**
@@ -1189,16 +1275,19 @@ function getPath(obj: unknown, path: string): unknown {
1189
1275
  * statement execution (spec §20) for `if` tests and `$switch` discriminants.
1190
1276
  */
1191
1277
  export function evaluateOperand(
1192
- operand: unknown,
1278
+ operand: JxExpressionOperand | undefined,
1193
1279
  state: JxScope,
1194
1280
  event: Event | null,
1195
1281
  iterCtx?: IterCtx,
1196
- ): unknown {
1282
+ ): JxScopeValue {
1197
1283
  return resolveOperand(operand, state, event, iterCtx);
1198
1284
  }
1199
1285
 
1200
1286
  /** Compile a bare operand to JS source — the statement compiler's counterpart to evaluateOperand. */
1201
- export function compileOperandSource(operand: unknown, opts: CompileOpts = {}): string {
1287
+ export function compileOperandSource(
1288
+ operand: JxExpressionOperand | undefined,
1289
+ opts: CompileOpts = {},
1290
+ ): string {
1202
1291
  return compileOperand(operand, opts);
1203
1292
  }
1204
1293