@jxsuite/runtime 0.34.2 → 1.1.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
@@ -30,6 +30,11 @@ function scopeMap(state: JxScope): ScopeMapCtx | undefined {
30
30
  interface CompileOpts {
31
31
  statePrefix?: string;
32
32
  eventParam?: string;
33
+ /**
34
+ * Parameter-name order per named-formula state key, used by `call` sites to map positional
35
+ * argument lists onto the emitted formula functions' named-args object.
36
+ */
37
+ formulaParams?: Record<string, string[]>;
33
38
  }
34
39
 
35
40
  const MUTATING_OPS = new Set([
@@ -59,18 +64,154 @@ const BINARY_OPS = new Set([
59
64
  ">=",
60
65
  "&&",
61
66
  "||",
67
+ "??",
62
68
  ]);
63
69
  const ASSIGNMENT_OPS = new Set(["=", "+=", "-=", "*=", "/="]);
64
70
  const ARRAY_METHOD_OPS = new Set(["push", "pop", "shift", "unshift", "splice"]);
65
71
  const AGGREGATE_OPS = new Set(["reduce", "map", "filter"]);
72
+ const CONDITIONAL_OPS = new Set(["?:", "switch"]);
73
+
74
+ /**
75
+ * Pure standard-library method operators (spec §19.4d): genuine `String.prototype` /
76
+ * `Array.prototype` / `Number.prototype` methods that never mutate their receiver — the ES2023
77
+ * change-by-copy family stands in where mutation would otherwise occur (`toSorted`, not `sort`).
78
+ * Receiver in `target`; `value` carries the argument (bare scalar) or argument list (array), the
79
+ * splice precedent. No token is invented.
80
+ */
81
+ export const PURE_METHOD_OPS = new Set([
82
+ // Array.prototype (and the String.prototype homonyms: includes/indexOf/slice/at/concat)
83
+ "includes",
84
+ "indexOf",
85
+ "lastIndexOf",
86
+ "join",
87
+ "slice",
88
+ "concat",
89
+ "at",
90
+ "flat",
91
+ "toSorted",
92
+ "toReversed",
93
+ "toSpliced",
94
+ "with",
95
+ // String.prototype
96
+ "toUpperCase",
97
+ "toLowerCase",
98
+ "trim",
99
+ "trimStart",
100
+ "trimEnd",
101
+ "split",
102
+ "startsWith",
103
+ "endsWith",
104
+ "padStart",
105
+ "padEnd",
106
+ "replaceAll",
107
+ "repeat",
108
+ "charAt",
109
+ "normalize",
110
+ "toLocaleUpperCase",
111
+ "toLocaleLowerCase",
112
+ // Number.prototype
113
+ "toFixed",
114
+ "toPrecision",
115
+ "toLocaleString",
116
+ ]);
66
117
 
67
118
  export const BLESSED_OPERATORS = new Set([
68
119
  ...MUTATING_OPS,
69
120
  ...UNARY_OPS,
70
121
  ...BINARY_OPS,
71
122
  ...AGGREGATE_OPS,
123
+ ...CONDITIONAL_OPS,
124
+ ...PURE_METHOD_OPS,
125
+ "call",
126
+ ]);
127
+
128
+ /**
129
+ * Pure globals a `call` node may target through the `window#/` scheme (spec §19.4c). Every entry is
130
+ * a genuine ECMAScript or WHATWG standard-library function with no side effects; anything off this
131
+ * list is a compile-time error, keeping formula purity decidable.
132
+ */
133
+ export const BLESSED_GLOBALS = new Set([
134
+ // Global functions
135
+ "isNaN",
136
+ "isFinite",
137
+ "parseFloat",
138
+ "parseInt",
139
+ "encodeURIComponent",
140
+ "decodeURIComponent",
141
+ "structuredClone",
142
+ // Math
143
+ "Math/abs",
144
+ "Math/ceil",
145
+ "Math/floor",
146
+ "Math/round",
147
+ "Math/trunc",
148
+ "Math/sign",
149
+ "Math/sqrt",
150
+ "Math/cbrt",
151
+ "Math/pow",
152
+ "Math/exp",
153
+ "Math/log",
154
+ "Math/log2",
155
+ "Math/log10",
156
+ "Math/min",
157
+ "Math/max",
158
+ "Math/hypot",
159
+ // Number
160
+ "Number/isInteger",
161
+ "Number/isFinite",
162
+ "Number/isNaN",
163
+ "Number/parseFloat",
164
+ "Number/parseInt",
165
+ // JSON
166
+ "JSON/parse",
167
+ "JSON/stringify",
168
+ // Object
169
+ "Object/keys",
170
+ "Object/values",
171
+ "Object/entries",
172
+ "Object/fromEntries",
173
+ // Array
174
+ "Array/from",
175
+ "Array/isArray",
176
+ "Array/of",
177
+ // String
178
+ "String/fromCharCode",
179
+ "String/fromCodePoint",
180
+ // Intl (synthetic helpers — see BLESSED_HELPERS; Intl formatters are constructors,
181
+ // So the plain-function call shape wraps construct-then-format)
182
+ "Intl/formatNumber",
183
+ "Intl/formatDate",
184
+ "Intl/formatRelativeTime",
72
185
  ]);
73
186
 
187
+ /**
188
+ * Synthetic blessed helpers (spec §19.4c): pure functions that have no direct `window` global
189
+ * because the underlying standard-library API is a constructor. The interpreter dispatches to
190
+ * these; the compiler emits the equivalent inline construct-then-format expression.
191
+ */
192
+ const BLESSED_HELPERS: Record<string, (...a: unknown[]) => unknown> = {
193
+ "Intl/formatDate": (value, locale, options) =>
194
+ new Intl.DateTimeFormat(
195
+ locale as string | undefined,
196
+ options as Intl.DateTimeFormatOptions | undefined,
197
+ ).format(new Date(value as string | number | Date)),
198
+ "Intl/formatNumber": (value, locale, options) =>
199
+ new Intl.NumberFormat(
200
+ locale as string | undefined,
201
+ options as Intl.NumberFormatOptions | undefined,
202
+ ).format(value as number),
203
+ "Intl/formatRelativeTime": (value, unit, locale, options) =>
204
+ new Intl.RelativeTimeFormat(
205
+ locale as string | undefined,
206
+ options as Intl.RelativeTimeFormatOptions | undefined,
207
+ ).format(value as number, unit as Intl.RelativeTimeFormatUnit),
208
+ };
209
+
210
+ /** Whether a `window#/…` callee ref is on the blessed pure-globals list. */
211
+ export function isBlessedGlobal(ref: string): boolean {
212
+ return ref.startsWith("window#/") && BLESSED_GLOBALS.has(ref.slice("window#/".length));
213
+ }
214
+
74
215
  /**
75
216
  * @param {string} op
76
217
  * @returns {boolean}
@@ -85,6 +226,49 @@ interface IterCtx {
85
226
  acc?: unknown;
86
227
  item?: unknown;
87
228
  index?: number;
229
+ /** Named-formula arguments bound for the current `call` body ($args/<name> refs). */
230
+ args?: Record<string, unknown>;
231
+ /** Call-nesting depth; capped at MAX_CALL_DEPTH to bound recursive formulas. */
232
+ callDepth?: number;
233
+ }
234
+
235
+ /** Recursion bound for `call` chains (formula → formula), mirroring MAX_REPORT_DEPTH. */
236
+ export const MAX_CALL_DEPTH = 64;
237
+
238
+ /**
239
+ * Editor-mode evaluation trace (spec §19.9). When passed, every node and operand reports its
240
+ * evaluated value keyed by its path within the expression tree, and the branch-selecting operators
241
+ * (`?:`, `switch` — plus the eager `&&`/`||`/`??`) yield values for ALL branches so a visual editor
242
+ * can badge every node. Absent (production), evaluation is untouched.
243
+ */
244
+ export interface ExpressionTrace {
245
+ report: (path: (string | number)[], value: unknown) => void;
246
+ /** Current node path — internal; leave unset at the call boundary. */
247
+ path?: (string | number)[];
248
+ /** Recursion depth — internal; reporting stops beyond MAX_REPORT_DEPTH. */
249
+ depth?: number;
250
+ }
251
+
252
+ /** Reporting recursion bound: branch-forcing removes natural exit conditions, so cap the walk. */
253
+ export const MAX_REPORT_DEPTH = 64;
254
+
255
+ /** Derive the trace for a child operand at path segment(s) `seg`; undefined once capped. */
256
+ function subTrace(
257
+ trace: ExpressionTrace | undefined,
258
+ seg: string | number | (string | number)[],
259
+ ): ExpressionTrace | undefined {
260
+ if (!trace) {
261
+ return undefined;
262
+ }
263
+ const depth = (trace.depth ?? 0) + 1;
264
+ if (depth > MAX_REPORT_DEPTH) {
265
+ return undefined;
266
+ }
267
+ return {
268
+ depth,
269
+ path: [...(trace.path ?? []), ...(Array.isArray(seg) ? seg : [seg])],
270
+ report: trace.report,
271
+ };
88
272
  }
89
273
 
90
274
  /** Resolve an operand to its runtime value. */
@@ -93,6 +277,7 @@ function resolveOperand(
93
277
  state: JxScope,
94
278
  event: Event | null,
95
279
  iterCtx?: IterCtx,
280
+ trace?: ExpressionTrace,
96
281
  ): unknown {
97
282
  if (operand === null || operand === undefined) {
98
283
  return operand;
@@ -100,20 +285,25 @@ function resolveOperand(
100
285
 
101
286
  // Nested expression node
102
287
  if (typeof operand === "object" && !Array.isArray(operand) && "operator" in operand) {
103
- return evaluateExpression(operand as ExpressionNode, state, event, iterCtx);
288
+ return evaluateExpression(operand as ExpressionNode, state, event, iterCtx, trace);
104
289
  }
105
290
 
106
291
  // $ref pointer
107
292
  if (typeof operand === "object" && !Array.isArray(operand) && "$ref" in operand) {
108
- return resolveExprRef((operand as { $ref: string }).$ref, state, event, iterCtx);
293
+ const resolved = resolveExprRef((operand as { $ref: string }).$ref, state, event, iterCtx);
294
+ trace?.report(trace.path ?? [], resolved);
295
+ return resolved;
109
296
  }
110
297
 
111
298
  // Array of operands (e.g., splice args)
112
299
  if (Array.isArray(operand)) {
113
- return operand.map((o: unknown) => resolveOperand(o, state, event, iterCtx));
300
+ return operand.map((o: unknown, i: number) =>
301
+ resolveOperand(o, state, event, iterCtx, subTrace(trace, i)),
302
+ );
114
303
  }
115
304
 
116
305
  // Literal
306
+ trace?.report(trace.path ?? [], operand);
117
307
  return operand;
118
308
  }
119
309
 
@@ -125,6 +315,11 @@ function resolveExprRef(ref: string, state: JxScope, event: Event | null, iterCt
125
315
  if (ref.startsWith("$reduce/")) {
126
316
  return iterCtx?.acc;
127
317
  }
318
+ if (ref.startsWith("$args/")) {
319
+ const parts = ref.slice("$args/".length).split("/");
320
+ const base = iterCtx?.args?.[parts[0]!];
321
+ return parts.length > 1 ? getPath(base, parts.slice(1).join("/")) : base;
322
+ }
128
323
  if (ref.startsWith("event#/")) {
129
324
  const path = ref.slice("event#/".length);
130
325
  return getPath(event, path);
@@ -217,6 +412,19 @@ export function evaluateExpression(
217
412
  state: JxScope,
218
413
  event: Event | null,
219
414
  iterCtx?: IterCtx,
415
+ trace?: ExpressionTrace,
416
+ ): unknown {
417
+ const result = evaluateNode(node, state, event, iterCtx, trace);
418
+ trace?.report(trace.path ?? [], result);
419
+ return result;
420
+ }
421
+
422
+ function evaluateNode(
423
+ node: ExpressionNode,
424
+ state: JxScope,
425
+ event: Event | null,
426
+ iterCtx?: IterCtx,
427
+ trace?: ExpressionTrace,
220
428
  ): unknown {
221
429
  const { operator, target, value, initial } = node;
222
430
 
@@ -226,7 +434,13 @@ export function evaluateExpression(
226
434
 
227
435
  // ─── Unary ───
228
436
  if (UNARY_OPS.has(operator) && !("value" in node)) {
229
- const operand: unknown = resolveOperand(target, state, event, iterCtx);
437
+ const operand: unknown = resolveOperand(
438
+ target,
439
+ state,
440
+ event,
441
+ iterCtx,
442
+ subTrace(trace, "target"),
443
+ );
230
444
  if (operator === "!") {
231
445
  return !operand;
232
446
  }
@@ -235,10 +449,124 @@ export function evaluateExpression(
235
449
  }
236
450
  }
237
451
 
452
+ // ─── Call (spec §19.4c) ───
453
+ if (operator === "call") {
454
+ const calleeRef = (target as { $ref?: string } | null)?.$ref;
455
+ if (typeof calleeRef !== "string") {
456
+ throw new TypeError("$expression: call target must be a $ref pointer");
457
+ }
458
+ const callDepth = (iterCtx?.callDepth ?? 0) + 1;
459
+ if (callDepth > MAX_CALL_DEPTH) {
460
+ throw new Error(`$expression: call depth exceeded (${MAX_CALL_DEPTH})`);
461
+ }
462
+ const argValues = Array.isArray(value)
463
+ ? value.map((o: unknown, i: number) =>
464
+ resolveOperand(o, state, event, iterCtx, subTrace(trace, ["value", i])),
465
+ )
466
+ : [];
467
+
468
+ // Blessed pure global via the window#/ scheme (Math.max, JSON.parse, …).
469
+ if (calleeRef.startsWith("window#/")) {
470
+ if (!isBlessedGlobal(calleeRef)) {
471
+ throw new Error(`$expression: "${calleeRef}" is not a blessed pure global`);
472
+ }
473
+ const globalPath = calleeRef.slice("window#/".length);
474
+ const helper = BLESSED_HELPERS[globalPath];
475
+ if (helper) {
476
+ return helper(...argValues);
477
+ }
478
+ const fn = getPath(globalThis.window, globalPath) as (...a: unknown[]) => unknown;
479
+ const lastSlash = globalPath.lastIndexOf("/");
480
+ const thisArg =
481
+ lastSlash === -1
482
+ ? globalThis.window
483
+ : getPath(globalThis.window, globalPath.slice(0, lastSlash));
484
+ return fn.apply(thisArg, argValues);
485
+ }
486
+
487
+ const callee = resolveExprRef(calleeRef, state, event, iterCtx);
488
+ // A scope callable (buildScope lowers parameterized formula entries to functions).
489
+ if (typeof callee === "function") {
490
+ return (callee as (...a: unknown[]) => unknown)(...argValues);
491
+ }
492
+ // A raw named-formula def (standalone evaluation, e.g. editor preview).
493
+ if (callee && typeof callee === "object" && "$expression" in callee) {
494
+ const def = callee as { $expression: ExpressionNode; parameters?: unknown[] };
495
+ const args: Record<string, unknown> = {};
496
+ for (const [i, p] of (def.parameters ?? []).entries()) {
497
+ const name = typeof p === "string" ? p : ((p as { name?: string } | null)?.name ?? "");
498
+ if (!name) {
499
+ continue;
500
+ }
501
+ args[name] =
502
+ argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
503
+ ? (p as { default?: unknown }).default
504
+ : argValues[i];
505
+ }
506
+ // The callee body's node paths are its own tree, not the call site's — no trace inside.
507
+ return evaluateExpression(def.$expression, state, event, { args, callDepth });
508
+ }
509
+ throw new TypeError(`$expression: call target "${calleeRef}" is not callable`);
510
+ }
511
+
512
+ // ─── Conditional (pure) ───
513
+ if (CONDITIONAL_OPS.has(operator)) {
514
+ if (operator === "?:") {
515
+ const test = resolveOperand(target, state, event, iterCtx, subTrace(trace, "target"));
516
+ if (trace) {
517
+ // Editor mode: evaluate both branches so each carries a live value.
518
+ const consequent = resolveOperand(value, state, event, iterCtx, subTrace(trace, "value"));
519
+ const alternate = resolveOperand(
520
+ initial,
521
+ state,
522
+ event,
523
+ iterCtx,
524
+ subTrace(trace, "initial"),
525
+ );
526
+ return test ? consequent : alternate;
527
+ }
528
+ return test
529
+ ? resolveOperand(value, state, event, iterCtx)
530
+ : resolveOperand(initial, state, event, iterCtx);
531
+ }
532
+ // Switch — value-keyed selection; discriminant matched against case keys by string form.
533
+ const discriminant = resolveOperand(target, state, event, iterCtx, subTrace(trace, "target"));
534
+ const key = String(discriminant);
535
+ const cases = node.cases ?? {};
536
+ const matched = Object.hasOwn(cases, key);
537
+ if (trace) {
538
+ let result: unknown;
539
+ for (const [k, caseOperand] of Object.entries(cases)) {
540
+ const v = resolveOperand(caseOperand, state, event, iterCtx, subTrace(trace, ["cases", k]));
541
+ if (k === key) {
542
+ result = v;
543
+ }
544
+ }
545
+ const fallback = resolveOperand(
546
+ node.default,
547
+ state,
548
+ event,
549
+ iterCtx,
550
+ subTrace(trace, "default"),
551
+ );
552
+ return matched ? result : fallback;
553
+ }
554
+ return matched
555
+ ? resolveOperand(cases[key], state, event, iterCtx)
556
+ : resolveOperand(node.default, state, event, iterCtx);
557
+ }
558
+
238
559
  // ─── Binary (pure) ───
239
560
  if (BINARY_OPS.has(operator)) {
240
- const left = resolveOperand(target, state, event, iterCtx) as number & string;
241
- const right = resolveOperand(value, state, event, iterCtx) as number & string;
561
+ const left = resolveOperand(
562
+ target,
563
+ state,
564
+ event,
565
+ iterCtx,
566
+ subTrace(trace, "target"),
567
+ ) as number & string;
568
+ const right = resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")) as number &
569
+ string;
242
570
  switch (operator) {
243
571
  case "+": {
244
572
  return left + right;
@@ -279,6 +607,9 @@ export function evaluateExpression(
279
607
  case "||": {
280
608
  return left || right;
281
609
  }
610
+ case "??": {
611
+ return (left as unknown) ?? (right as unknown);
612
+ }
282
613
  default: {
283
614
  break;
284
615
  }
@@ -287,7 +618,7 @@ export function evaluateExpression(
287
618
 
288
619
  // ─── Assignment ───
289
620
  if (ASSIGNMENT_OPS.has(operator)) {
290
- const rhs = resolveOperand(value, state, event, iterCtx) as number;
621
+ const rhs = resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")) as number;
291
622
  const { obj, key } = resolveWritableRef(
292
623
  (target as { $ref: string }).$ref,
293
624
  state,
@@ -328,13 +659,19 @@ export function evaluateExpression(
328
659
 
329
660
  // ─── Array methods ───
330
661
  if (ARRAY_METHOD_OPS.has(operator)) {
331
- const arr: unknown[] = resolveOperand(target, state, event, iterCtx) as unknown[];
662
+ const arr: unknown[] = resolveOperand(
663
+ target,
664
+ state,
665
+ event,
666
+ iterCtx,
667
+ subTrace(trace, "target"),
668
+ ) as unknown[];
332
669
  switch (operator) {
333
670
  case "push": {
334
- return arr.push(resolveOperand(value, state, event, iterCtx));
671
+ return arr.push(resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")));
335
672
  }
336
673
  case "unshift": {
337
- return arr.unshift(resolveOperand(value, state, event, iterCtx));
674
+ return arr.unshift(resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")));
338
675
  }
339
676
  case "pop": {
340
677
  return arr.pop();
@@ -343,7 +680,13 @@ export function evaluateExpression(
343
680
  return arr.shift();
344
681
  }
345
682
  case "splice": {
346
- const args: unknown[] = resolveOperand(value, state, event, iterCtx) as unknown[];
683
+ const args: unknown[] = resolveOperand(
684
+ value,
685
+ state,
686
+ event,
687
+ iterCtx,
688
+ subTrace(trace, "value"),
689
+ ) as unknown[];
347
690
  return (arr.splice as (...a: unknown[]) => unknown[])(...args);
348
691
  }
349
692
  default: {
@@ -352,36 +695,67 @@ export function evaluateExpression(
352
695
  }
353
696
  }
354
697
 
698
+ // ─── Pure standard-library methods (spec §19.4d) ───
699
+ if (PURE_METHOD_OPS.has(operator)) {
700
+ const receiver = resolveOperand(target, state, event, iterCtx, subTrace(trace, "target"));
701
+ const args =
702
+ value === undefined
703
+ ? []
704
+ : Array.isArray(value)
705
+ ? (resolveOperand(value, state, event, iterCtx, subTrace(trace, "value")) as unknown[])
706
+ : [resolveOperand(value, state, event, iterCtx, subTrace(trace, "value"))];
707
+ const method = (receiver as Record<string, unknown> | null | undefined)?.[operator];
708
+ if (typeof method !== "function") {
709
+ // Null-safe, like path reads: a missing receiver or method yields undefined, not a throw.
710
+ return undefined;
711
+ }
712
+ return (method as (...a: unknown[]) => unknown).apply(receiver, args);
713
+ }
714
+
355
715
  // ─── Aggregates (pure) ───
356
716
  if (AGGREGATE_OPS.has(operator)) {
357
- const arr: unknown[] = resolveOperand(target, state, event, iterCtx) as unknown[];
717
+ const arr: unknown[] = resolveOperand(
718
+ target,
719
+ state,
720
+ event,
721
+ iterCtx,
722
+ subTrace(trace, "target"),
723
+ ) as unknown[];
724
+ // Trace only the first iteration's per-item expression — one sample badge per node,
725
+ // Not one report per array element.
358
726
  if (operator === "reduce") {
359
- let acc: unknown = resolveOperand(initial, state, event, iterCtx);
727
+ let acc: unknown = resolveOperand(initial, state, event, iterCtx, subTrace(trace, "initial"));
360
728
  for (const [index, item] of arr.entries()) {
361
- acc = evaluateExpression(value as ExpressionNode, state, event, {
362
- acc,
363
- index,
364
- item,
365
- });
729
+ acc = evaluateExpression(
730
+ value as ExpressionNode,
731
+ state,
732
+ event,
733
+ { acc, index, item },
734
+ index === 0 ? subTrace(trace, "value") : undefined,
735
+ );
366
736
  }
367
737
  return acc;
368
738
  }
369
739
  if (operator === "map") {
370
740
  return arr.map((item: unknown, index: number) =>
371
- evaluateExpression(value as ExpressionNode, state, event, {
372
- ...iterCtx,
373
- index,
374
- item,
375
- }),
741
+ evaluateExpression(
742
+ value as ExpressionNode,
743
+ state,
744
+ event,
745
+ { ...iterCtx, index, item },
746
+ index === 0 ? subTrace(trace, "value") : undefined,
747
+ ),
376
748
  );
377
749
  }
378
750
  if (operator === "filter") {
379
751
  return arr.filter((item: unknown, index: number) =>
380
- evaluateExpression(value as ExpressionNode, state, event, {
381
- ...iterCtx,
382
- index,
383
- item,
384
- }),
752
+ evaluateExpression(
753
+ value as ExpressionNode,
754
+ state,
755
+ event,
756
+ { ...iterCtx, index, item },
757
+ index === 0 ? subTrace(trace, "value") : undefined,
758
+ ),
385
759
  );
386
760
  }
387
761
  }
@@ -419,6 +793,17 @@ function compileOperand(operand: unknown, opts: CompileOpts): string {
419
793
  return JSON.stringify(operand);
420
794
  }
421
795
 
796
+ /**
797
+ * The module-scope function name a named formula compiles to. Shared by call-site emission here and
798
+ * the declaration emission in the compiler targets.
799
+ *
800
+ * @param {string} key
801
+ * @returns {string}
802
+ */
803
+ export function formulaFnName(key: string) {
804
+ return `_fx_${key.replaceAll(/[^A-Za-z0-9_$]/g, "_")}`;
805
+ }
806
+
422
807
  /**
423
808
  * Compile a $ref to its JS equivalent.
424
809
  *
@@ -436,6 +821,10 @@ function compileRef(ref: string, opts: CompileOpts) {
436
821
  if (ref.startsWith("$reduce/")) {
437
822
  return "_acc";
438
823
  }
824
+ if (ref.startsWith("$args/")) {
825
+ const parts = ref.slice("$args/".length).split("/");
826
+ return `_args${parts.map((p) => `[${JSON.stringify(p)}]`).join("")}`;
827
+ }
439
828
 
440
829
  if (ref.startsWith("event#/")) {
441
830
  const path = ref.slice("event#/".length);
@@ -488,6 +877,32 @@ function compileTarget(target: unknown, opts: CompileOpts): string {
488
877
  * @param {CompileOpts} [opts]
489
878
  * @returns {string}
490
879
  */
880
+ /**
881
+ * Emit the inline JS for a synthetic blessed helper (see BLESSED_HELPERS), or null for ordinary
882
+ * globals that compile to a plain `window.<path>` call.
883
+ *
884
+ * @param {string} path - The `window#/`-stripped callee path (e.g. "Intl/formatNumber")
885
+ * @param {string[]} args - Already-compiled argument expressions
886
+ * @returns {string | null}
887
+ */
888
+ function compileHelperCall(path: string, args: string[]): string | null {
889
+ const a = (i: number) => args[i] ?? "undefined";
890
+ switch (path) {
891
+ case "Intl/formatNumber": {
892
+ return `new Intl.NumberFormat(${a(1)}, ${a(2)}).format(${a(0)})`;
893
+ }
894
+ case "Intl/formatDate": {
895
+ return `new Intl.DateTimeFormat(${a(1)}, ${a(2)}).format(new Date(${a(0)}))`;
896
+ }
897
+ case "Intl/formatRelativeTime": {
898
+ return `new Intl.RelativeTimeFormat(${a(2)}, ${a(3)}).format(${a(0)}, ${a(1)})`;
899
+ }
900
+ default: {
901
+ return null;
902
+ }
903
+ }
904
+ }
905
+
491
906
  export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}): string {
492
907
  const { operator, target, value, initial } = node;
493
908
 
@@ -502,6 +917,51 @@ export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}):
502
917
  }
503
918
  }
504
919
 
920
+ // ─── Call (spec §19.4c) ───
921
+ if (operator === "call") {
922
+ const calleeRef = (target as { $ref?: string } | null)?.$ref ?? "";
923
+ const args = Array.isArray(value) ? value.map((o: unknown) => compileOperand(o, opts)) : [];
924
+ if (calleeRef.startsWith("window#/")) {
925
+ if (!isBlessedGlobal(calleeRef)) {
926
+ throw new Error(`$expression: "${calleeRef}" is not a blessed pure global`);
927
+ }
928
+ const helperJs = compileHelperCall(calleeRef.slice("window#/".length), args);
929
+ if (helperJs) {
930
+ return helperJs;
931
+ }
932
+ return `window.${calleeRef.slice("window#/".length).replaceAll("/", ".")}(${args.join(", ")})`;
933
+ }
934
+ if (calleeRef.startsWith("#/state/")) {
935
+ const key = calleeRef.slice("#/state/".length);
936
+ const params = opts.formulaParams?.[key];
937
+ if (params) {
938
+ const named = params
939
+ .map((p, i) => `${JSON.stringify(p)}: ${args[i] ?? "undefined"}`)
940
+ .join(", ");
941
+ return `${formulaFnName(key)}(${opts.statePrefix ?? "state"}, { ${named} })`;
942
+ }
943
+ }
944
+ // Fallback: the scope member itself is callable.
945
+ return `${compileRef(calleeRef, opts)}(${args.join(", ")})`;
946
+ }
947
+
948
+ // ─── Conditional (pure) ───
949
+ if (CONDITIONAL_OPS.has(operator)) {
950
+ if (operator === "?:") {
951
+ const test: string = compileOperand(target, opts);
952
+ const consequent: string = compileOperand(value, opts);
953
+ const alternate: string = compileOperand(initial, opts);
954
+ return `(${test} ? ${consequent} : ${alternate})`;
955
+ }
956
+ // Switch — bind the discriminant's string form once, then chain strict-equality tests.
957
+ const discriminant: string = compileOperand(target, opts);
958
+ const chain = Object.entries(node.cases ?? {})
959
+ .map(([k, v]) => `_d === ${JSON.stringify(k)} ? ${compileOperand(v, opts)} : `)
960
+ .join("");
961
+ const fallback = "default" in node ? compileOperand(node.default, opts) : "undefined";
962
+ return `((_d) => ${chain}${fallback})(String(${discriminant}))`;
963
+ }
964
+
505
965
  // ─── Binary (pure) ───
506
966
  if (BINARY_OPS.has(operator)) {
507
967
  const left: string = compileOperand(target, opts);
@@ -544,6 +1004,19 @@ export function compileExpression(node: ExpressionNode, opts: CompileOpts = {}):
544
1004
  }
545
1005
  }
546
1006
 
1007
+ // ─── Pure standard-library methods (spec §19.4d) ───
1008
+ if (PURE_METHOD_OPS.has(operator)) {
1009
+ const receiver: string = compileOperand(target, opts);
1010
+ const args: string =
1011
+ value === undefined
1012
+ ? ""
1013
+ : Array.isArray(value)
1014
+ ? value.map((o: unknown) => compileOperand(o, opts)).join(", ")
1015
+ : compileOperand(value, opts);
1016
+ // ?.method?.() matches the interpreter: missing receiver or method yields undefined.
1017
+ return `(${receiver})?.${operator}?.(${args})`;
1018
+ }
1019
+
547
1020
  // ─── Aggregates (pure) ───
548
1021
  if (AGGREGATE_OPS.has(operator)) {
549
1022
  const arr: string = compileOperand(target, opts);
@@ -573,3 +1046,25 @@ function getPath(obj: unknown, path: string): unknown {
573
1046
  }
574
1047
  return current;
575
1048
  }
1049
+
1050
+ // ─── Statement-Engine Surface (spec §20) ─────────────────────────────────────
1051
+
1052
+ /**
1053
+ * Evaluate a bare operand (pointer, literal, or nested node) outside a node position — used by
1054
+ * statement execution (spec §20) for `if` tests and `$switch` discriminants.
1055
+ */
1056
+ export function evaluateOperand(
1057
+ operand: unknown,
1058
+ state: JxScope,
1059
+ event: Event | null,
1060
+ iterCtx?: IterCtx,
1061
+ ): unknown {
1062
+ return resolveOperand(operand, state, event, iterCtx);
1063
+ }
1064
+
1065
+ /** Compile a bare operand to JS source — the statement compiler's counterpart to evaluateOperand. */
1066
+ export function compileOperandSource(operand: unknown, opts: CompileOpts = {}): string {
1067
+ return compileOperand(operand, opts);
1068
+ }
1069
+
1070
+ export type { CompileOpts, IterCtx };