@object-ui/core 3.1.5 → 3.3.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.
@@ -0,0 +1,893 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * @object-ui/core - Safe Expression Parser
11
+ *
12
+ * CSP-safe recursive-descent expression parser for the ObjectUI expression engine.
13
+ * Replaces the `new Function()` / `eval()`-based expression compilation with a
14
+ * sandboxed interpreter that works under strict Content Security Policy headers.
15
+ *
16
+ * Operator precedence (lowest → highest):
17
+ * 1. Ternary a ? b : c
18
+ * 2. Nullish a ?? b
19
+ * 3. Logical OR a || b
20
+ * 4. Logical AND a && b
21
+ * 5. Equality === !== == != > < >= <=
22
+ * 6. Addition a + b a - b
23
+ * 7. Multiplication a * b a / b a % b
24
+ * 8. Unary !a -a +a typeof a
25
+ * 9. Member a.b a[b] a?.b a?.[b] a(…) a.b(…)
26
+ * 10. Primary literals · identifiers · ( expr ) · [ … ]
27
+ *
28
+ * @module evaluator
29
+ * @packageDocumentation
30
+ */
31
+
32
+ /**
33
+ * A safe subset of global JavaScript objects that are always available in
34
+ * expressions regardless of the provided context.
35
+ *
36
+ * SECURITY: Only read-only, non-executable primitive utilities are exposed.
37
+ * Constructors (`String`, `Number`, `Boolean`, `Array`) are intentionally
38
+ * omitted: they expose a `.constructor` property that resolves to `Function`,
39
+ * creating a sandbox-escape path even when `Function` itself is not listed.
40
+ * `eval`, `Function`, `window`, `document`, `process`, etc. are NOT included.
41
+ */
42
+ const SAFE_GLOBALS: Record<string, unknown> = {
43
+ Math,
44
+ JSON,
45
+ parseInt,
46
+ parseFloat,
47
+ isNaN,
48
+ isFinite,
49
+ };
50
+
51
+ /**
52
+ * Property keys that must never be accessed on any object in an expression.
53
+ *
54
+ * SECURITY: `constructor` reaches `Function`; `__proto__`/`prototype` allow
55
+ * prototype-chain manipulation. Access is blocked on both dot and bracket
56
+ * notation to prevent sandbox escapes like `obj['constructor']('...')`.
57
+ */
58
+ const BLOCKED_PROPS: ReadonlySet<string> = new Set([
59
+ 'constructor',
60
+ '__proto__',
61
+ 'prototype',
62
+ '__defineGetter__',
63
+ '__defineSetter__',
64
+ '__lookupGetter__',
65
+ '__lookupSetter__',
66
+ ]);
67
+
68
+ /**
69
+ * CSP-safe recursive-descent expression parser.
70
+ *
71
+ * Call `evaluate(expression, context)` to parse and execute an expression
72
+ * string against a data context object without any use of `eval()` or
73
+ * `new Function()`.
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * const parser = new SafeExpressionParser();
78
+ * parser.evaluate('data.amount > 1000', { data: { amount: 1500 } }); // true
79
+ * parser.evaluate('stage !== "closed_won" && stage !== "closed_lost"', { stage: 'open' }); // true
80
+ * parser.evaluate('items.filter(i => i.active).length', { items: [{active:true},{active:false}] }); // 1
81
+ * ```
82
+ */
83
+ export class SafeExpressionParser {
84
+ private source = '';
85
+ private pos = 0;
86
+ private context: Record<string, unknown> = {};
87
+
88
+ /**
89
+ * Evaluation guard.
90
+ *
91
+ * When `false` the parser still advances `this.pos` through the source
92
+ * (maintaining correct position for the caller) but suppresses:
93
+ * - ReferenceErrors from undefined identifiers
94
+ * - actual function / method invocations
95
+ * - constructor calls
96
+ *
97
+ * This implements proper short-circuit semantics for `||`, `&&`, `??`, and
98
+ * the ternary operator without needing a separate AST pass.
99
+ */
100
+ private _evaluating = true;
101
+
102
+ // ─── Public API ────────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Evaluate an expression string against a data context.
106
+ *
107
+ * Safe for use under strict CSP — never uses `eval()` or `new Function()`.
108
+ *
109
+ * @param expression - The expression to evaluate (without `${}` wrapper)
110
+ * @param context - Variables available to the expression
111
+ * @returns The evaluated result
112
+ * @throws {ReferenceError} When an identifier is not found in the context
113
+ * @throws {TypeError} On type mismatches (e.g., calling a non-function)
114
+ * @throws {SyntaxError} On malformed expression syntax
115
+ */
116
+ evaluate(expression: string, context: Record<string, unknown>): unknown {
117
+ this.source = expression.trim();
118
+ this.pos = 0;
119
+ // Safe globals are available but user-provided context takes priority.
120
+ this.context = { ...SAFE_GLOBALS, ...context };
121
+
122
+ const result = this.parseTernary();
123
+ this.skipWhitespace();
124
+
125
+ if (this.pos < this.source.length) {
126
+ throw new SyntaxError(
127
+ `Unexpected token "${this.source[this.pos]}" at position ${this.pos} in expression "${expression}"`
128
+ );
129
+ }
130
+
131
+ return result;
132
+ }
133
+
134
+ // ─── Character helpers ────────────────────────────────────────────────────
135
+
136
+ private skipWhitespace(): void {
137
+ while (this.pos < this.source.length && /\s/.test(this.source[this.pos])) {
138
+ this.pos++;
139
+ }
140
+ }
141
+
142
+ private peek(offset = 0): string {
143
+ return this.source[this.pos + offset] ?? '';
144
+ }
145
+
146
+ private consume(): string {
147
+ return this.source[this.pos++] ?? '';
148
+ }
149
+
150
+ // ─── Evaluation control helpers ───────────────────────────────────────────
151
+
152
+ /**
153
+ * Execute `fn` with `_evaluating` temporarily set to `enabled`.
154
+ * Restores the previous value even if `fn` throws.
155
+ *
156
+ * Used to implement short-circuit evaluation: when a branch should not be
157
+ * executed we call `withEvaluation(false, parseX)` which advances the source
158
+ * position without performing any side-effectful evaluations.
159
+ */
160
+ private withEvaluation<T>(enabled: boolean, fn: () => T): T {
161
+ const prev = this._evaluating;
162
+ this._evaluating = enabled;
163
+ try {
164
+ return fn();
165
+ } finally {
166
+ this._evaluating = prev;
167
+ }
168
+ }
169
+
170
+ // ─── Security helpers ─────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Guard property accesses against sandbox-escape keys.
174
+ * Throws `TypeError` when the key is in `BLOCKED_PROPS`.
175
+ *
176
+ * Only string keys need checking: all blocked property names are strings,
177
+ * and `BLOCKED_PROPS.has()` with a number or symbol can never match them.
178
+ * Numeric indices (e.g. `arr[0]`) and symbol-keyed properties are therefore
179
+ * safe to access and are intentionally left unchecked.
180
+ */
181
+ private assertSafeProp(key: unknown): void {
182
+ if (typeof key === 'string' && BLOCKED_PROPS.has(key)) {
183
+ throw new TypeError(
184
+ `Access to property "${key}" is not permitted in expressions`
185
+ );
186
+ }
187
+ }
188
+
189
+ // ─── Parsing levels ───────────────────────────────────────────────────────
190
+
191
+ /** Level 1 — Ternary: `cond ? trueVal : falseVal` (right-associative) */
192
+ private parseTernary(): unknown {
193
+ const cond = this.parseNullish();
194
+ this.skipWhitespace();
195
+
196
+ if (this.peek() === '?' && this.peek(1) !== '?') {
197
+ this.pos++; // consume '?'
198
+ this.skipWhitespace();
199
+
200
+ if (!this._evaluating) {
201
+ // Dry-run mode: parse both branches for position tracking only.
202
+ this.parseTernary(); // true branch (positional advance)
203
+ this.skipWhitespace();
204
+ if (this.peek() !== ':') {
205
+ throw new SyntaxError('Expected ":" in ternary expression');
206
+ }
207
+ this.pos++; // consume ':'
208
+ this.skipWhitespace();
209
+ this.parseTernary(); // false branch (positional advance)
210
+ return undefined;
211
+ }
212
+
213
+ if (cond) {
214
+ // Evaluate true branch; skip false branch without side effects.
215
+ const trueVal = this.parseTernary();
216
+ this.skipWhitespace();
217
+ if (this.peek() !== ':') {
218
+ throw new SyntaxError('Expected ":" in ternary expression');
219
+ }
220
+ this.pos++; // consume ':'
221
+ this.skipWhitespace();
222
+ this.withEvaluation(false, () => this.parseTernary()); // skip false
223
+ return trueVal;
224
+ } else {
225
+ // Skip true branch without side effects; evaluate false branch.
226
+ this.withEvaluation(false, () => this.parseTernary()); // skip true
227
+ this.skipWhitespace();
228
+ if (this.peek() !== ':') {
229
+ throw new SyntaxError('Expected ":" in ternary expression');
230
+ }
231
+ this.pos++; // consume ':'
232
+ this.skipWhitespace();
233
+ return this.parseTernary(); // evaluate false
234
+ }
235
+ }
236
+
237
+ return cond;
238
+ }
239
+
240
+ /** Level 2 — Nullish coalescing: `a ?? b` */
241
+ private parseNullish(): unknown {
242
+ let left = this.parseOr();
243
+ this.skipWhitespace();
244
+
245
+ while (this.peek() === '?' && this.peek(1) === '?') {
246
+ this.pos += 2;
247
+ this.skipWhitespace();
248
+
249
+ // Short-circuit: left is non-nullish — skip RHS without evaluating it.
250
+ if (this._evaluating && left != null) {
251
+ this.withEvaluation(false, () => this.parseOr());
252
+ } else {
253
+ const right = this.parseOr();
254
+ if (this._evaluating) left = left ?? right;
255
+ }
256
+
257
+ this.skipWhitespace();
258
+ }
259
+
260
+ return left;
261
+ }
262
+
263
+ /** Level 3 — Logical OR: `a || b` */
264
+ private parseOr(): unknown {
265
+ let left = this.parseAnd();
266
+ this.skipWhitespace();
267
+
268
+ while (this.peek() === '|' && this.peek(1) === '|') {
269
+ this.pos += 2;
270
+ this.skipWhitespace();
271
+
272
+ // Short-circuit: left is truthy — skip RHS without evaluating it.
273
+ if (this._evaluating && left) {
274
+ this.withEvaluation(false, () => this.parseAnd());
275
+ } else {
276
+ const right = this.parseAnd();
277
+ if (this._evaluating) left = left || right;
278
+ }
279
+
280
+ this.skipWhitespace();
281
+ }
282
+
283
+ return left;
284
+ }
285
+
286
+ /** Level 4 — Logical AND: `a && b` */
287
+ private parseAnd(): unknown {
288
+ let left = this.parseEquality();
289
+ this.skipWhitespace();
290
+
291
+ while (this.peek() === '&' && this.peek(1) === '&') {
292
+ this.pos += 2;
293
+ this.skipWhitespace();
294
+
295
+ // Short-circuit: left is falsy — skip RHS without evaluating it.
296
+ if (this._evaluating && !left) {
297
+ this.withEvaluation(false, () => this.parseEquality());
298
+ } else {
299
+ const right = this.parseEquality();
300
+ if (this._evaluating) left = left && right;
301
+ }
302
+
303
+ this.skipWhitespace();
304
+ }
305
+
306
+ return left;
307
+ }
308
+
309
+ /** Level 5 — Equality and relational comparisons */
310
+ private parseEquality(): unknown {
311
+ let left = this.parseAddition();
312
+ this.skipWhitespace();
313
+
314
+ // eslint-disable-next-line no-constant-condition
315
+ while (true) {
316
+ let op: string | null = null;
317
+
318
+ if (this.peek() === '=' && this.peek(1) === '=' && this.peek(2) === '=') {
319
+ op = '==='; this.pos += 3;
320
+ } else if (this.peek() === '!' && this.peek(1) === '=' && this.peek(2) === '=') {
321
+ op = '!=='; this.pos += 3;
322
+ } else if (this.peek() === '=' && this.peek(1) === '=') {
323
+ op = '=='; this.pos += 2;
324
+ } else if (this.peek() === '!' && this.peek(1) === '=') {
325
+ op = '!='; this.pos += 2;
326
+ } else if (this.peek() === '>' && this.peek(1) === '=') {
327
+ op = '>='; this.pos += 2;
328
+ } else if (this.peek() === '<' && this.peek(1) === '=') {
329
+ op = '<='; this.pos += 2;
330
+ } else if (this.peek() === '>' && this.peek(1) !== '>') {
331
+ op = '>'; this.pos++;
332
+ } else if (this.peek() === '<' && this.peek(1) !== '<') {
333
+ op = '<'; this.pos++;
334
+ } else {
335
+ break;
336
+ }
337
+
338
+ this.skipWhitespace();
339
+ const right = this.parseAddition();
340
+
341
+ switch (op) {
342
+ case '===': left = left === right; break;
343
+ case '!==': left = left !== right; break;
344
+ // eslint-disable-next-line eqeqeq
345
+ case '==': left = (left as any) == (right as any); break;
346
+ // eslint-disable-next-line eqeqeq
347
+ case '!=': left = (left as any) != (right as any); break;
348
+ case '>': left = (left as any) > (right as any); break;
349
+ case '<': left = (left as any) < (right as any); break;
350
+ case '>=': left = (left as any) >= (right as any); break;
351
+ case '<=': left = (left as any) <= (right as any); break;
352
+ }
353
+
354
+ this.skipWhitespace();
355
+ }
356
+
357
+ return left;
358
+ }
359
+
360
+ /** Level 6 — Addition / Subtraction */
361
+ private parseAddition(): unknown {
362
+ let left = this.parseMultiplication();
363
+ this.skipWhitespace();
364
+
365
+ while (
366
+ (this.peek() === '+' || this.peek() === '-') &&
367
+ this.peek(1) !== '=' // avoid consuming += / -=
368
+ ) {
369
+ const op = this.consume();
370
+ this.skipWhitespace();
371
+ const right = this.parseMultiplication();
372
+ left = op === '+' ? (left as any) + (right as any) : (left as any) - (right as any);
373
+ this.skipWhitespace();
374
+ }
375
+
376
+ return left;
377
+ }
378
+
379
+ /** Level 7 — Multiplication / Division / Modulo */
380
+ private parseMultiplication(): unknown {
381
+ let left = this.parseUnary();
382
+ this.skipWhitespace();
383
+
384
+ while (
385
+ (this.peek() === '*' || this.peek() === '/' || this.peek() === '%') &&
386
+ this.peek(1) !== '='
387
+ ) {
388
+ const op = this.consume();
389
+ this.skipWhitespace();
390
+ const right = this.parseUnary();
391
+ if (op === '*') left = (left as any) * (right as any);
392
+ else if (op === '/') left = (left as any) / (right as any);
393
+ else left = (left as any) % (right as any);
394
+ this.skipWhitespace();
395
+ }
396
+
397
+ return left;
398
+ }
399
+
400
+ /** Level 8 — Unary operators: `!`, `-`, `+`, `typeof` */
401
+ private parseUnary(): unknown {
402
+ this.skipWhitespace();
403
+
404
+ // typeof (must be checked before identifier parsing to avoid consuming it)
405
+ if (
406
+ this.source.startsWith('typeof', this.pos) &&
407
+ !/[\w$]/.test(this.source[this.pos + 6] ?? '')
408
+ ) {
409
+ this.pos += 6;
410
+ this.skipWhitespace();
411
+ try {
412
+ return typeof this.parseUnary();
413
+ } catch {
414
+ // typeof undeclaredVar === 'undefined' in real JS
415
+ return 'undefined';
416
+ }
417
+ }
418
+
419
+ if (this.peek() === '!') {
420
+ this.pos++;
421
+ return !this.parseUnary();
422
+ }
423
+
424
+ if (this.peek() === '-') {
425
+ this.pos++;
426
+ return -(this.parseUnary() as any);
427
+ }
428
+
429
+ if (this.peek() === '+') {
430
+ this.pos++;
431
+ return +(this.parseUnary() as any);
432
+ }
433
+
434
+ return this.parseMember();
435
+ }
436
+
437
+ /** Level 9 — Member access, method calls, function calls */
438
+ private parseMember(): unknown {
439
+ let obj = this.parsePrimary();
440
+
441
+ // eslint-disable-next-line no-constant-condition
442
+ while (true) {
443
+ this.skipWhitespace();
444
+
445
+ const isOptionalDot = this.peek() === '?' && this.peek(1) === '.';
446
+ const isDot = this.peek() === '.' && this.peek(1) !== '.';
447
+
448
+ if (isDot || isOptionalDot) {
449
+ // Property / method access: obj.prop or obj?.prop
450
+ if (isOptionalDot) this.pos++; // consume '?' for ?.
451
+ this.pos++; // consume '.'
452
+ this.skipWhitespace();
453
+
454
+ const prop = this.parseIdentifierName();
455
+ if (!prop) break;
456
+
457
+ // Block sandbox-escape properties regardless of evaluation mode.
458
+ this.assertSafeProp(prop);
459
+
460
+ this.skipWhitespace();
461
+
462
+ if (this.peek() === '(') {
463
+ // Method call: obj.method(args)
464
+ this.pos++; // consume '('
465
+ const args = this.parseArgList();
466
+ if (this.peek() !== ')') {
467
+ throw new SyntaxError(`Expected ")" after argument list at position ${this.pos}`);
468
+ }
469
+ this.pos++; // consume ')'
470
+
471
+ if (!this._evaluating) { obj = undefined; continue; }
472
+
473
+ if (obj != null && typeof (obj as any)[prop] === 'function') {
474
+ obj = ((obj as any)[prop] as (...a: unknown[]) => unknown)(...args);
475
+ } else {
476
+ obj = undefined;
477
+ }
478
+ } else {
479
+ // Property access
480
+ if (!this._evaluating) { obj = undefined; continue; }
481
+ obj = obj != null ? (obj as any)[prop] : undefined;
482
+ }
483
+
484
+ continue;
485
+ }
486
+
487
+ const isOptionalBracket = this.peek() === '?' && this.peek(1) === '[';
488
+ const isBracket = this.peek() === '[';
489
+
490
+ if (isBracket || isOptionalBracket) {
491
+ // Bracket access: obj[key] or obj?.[key]
492
+ if (isOptionalBracket) this.pos++; // consume '?' for ?.[
493
+ this.pos++; // consume '['
494
+ this.skipWhitespace();
495
+ const key = this.parseTernary();
496
+ this.skipWhitespace();
497
+ if (this.peek() !== ']') {
498
+ throw new SyntaxError(`Expected "]" after bracket expression at position ${this.pos}`);
499
+ }
500
+ this.pos++; // consume ']'
501
+
502
+ // Block sandbox-escape properties regardless of evaluation mode.
503
+ this.assertSafeProp(key);
504
+
505
+ if (!this._evaluating) { obj = undefined; continue; }
506
+ obj = obj != null ? (obj as any)[key as string | number] : undefined;
507
+ continue;
508
+ }
509
+
510
+ if (this.peek() === '(') {
511
+ // Direct function call on a returned value, e.g. (getFunc())(args)
512
+ this.pos++; // consume '('
513
+ const args = this.parseArgList();
514
+ if (this.peek() !== ')') {
515
+ throw new SyntaxError(`Expected ")" after argument list at position ${this.pos}`);
516
+ }
517
+ this.pos++; // consume ')'
518
+
519
+ if (!this._evaluating) { obj = undefined; continue; }
520
+
521
+ if (typeof obj === 'function') {
522
+ obj = (obj as (...a: unknown[]) => unknown)(...args);
523
+ } else {
524
+ throw new TypeError(`${String(obj)} is not a function`);
525
+ }
526
+ continue;
527
+ }
528
+
529
+ break;
530
+ }
531
+
532
+ return obj;
533
+ }
534
+
535
+ /** Level 10 — Primary expressions: literals, identifiers, `(expr)`, `[…]` */
536
+ private parsePrimary(): unknown {
537
+ this.skipWhitespace();
538
+ const ch = this.peek();
539
+
540
+ // Parenthesized expression
541
+ if (ch === '(') {
542
+ this.pos++;
543
+ const val = this.parseTernary();
544
+ this.skipWhitespace();
545
+ if (this.peek() !== ')') {
546
+ throw new SyntaxError(
547
+ `Expected ")" to close "(" expression at position ${this.pos}`
548
+ );
549
+ }
550
+ this.pos++;
551
+ return val;
552
+ }
553
+
554
+ // Array literal
555
+ if (ch === '[') {
556
+ return this.parseArrayLiteral();
557
+ }
558
+
559
+ // String literals
560
+ if (ch === '"' || ch === "'") {
561
+ return this.parseString(ch);
562
+ }
563
+
564
+ // Number literals (unary `-` is handled in parseUnary, not here)
565
+ if (/\d/.test(ch) || (ch === '.' && /\d/.test(this.peek(1)))) {
566
+ return this.parseNumber();
567
+ }
568
+
569
+ // Identifiers and keywords
570
+ if (/[a-zA-Z_$]/.test(ch)) {
571
+ return this.parseIdentifierOrKeyword();
572
+ }
573
+
574
+ throw new SyntaxError(
575
+ `Unexpected character "${ch}" at position ${this.pos}`
576
+ );
577
+ }
578
+
579
+ // ─── Literal parsers ──────────────────────────────────────────────────────
580
+
581
+ private parseArrayLiteral(): unknown[] {
582
+ this.pos++; // consume '['
583
+ const items: unknown[] = [];
584
+ this.skipWhitespace();
585
+
586
+ while (this.peek() !== ']' && this.pos < this.source.length) {
587
+ items.push(this.parseTernary());
588
+ this.skipWhitespace();
589
+ if (this.peek() === ',') {
590
+ this.pos++;
591
+ this.skipWhitespace();
592
+ }
593
+ }
594
+
595
+ if (this.peek() !== ']') {
596
+ throw new SyntaxError(`Expected "]" to close array literal at position ${this.pos}`);
597
+ }
598
+ this.pos++;
599
+ return items;
600
+ }
601
+
602
+ private parseString(quote: string): string {
603
+ this.pos++; // consume opening quote
604
+ let str = '';
605
+
606
+ while (this.pos < this.source.length) {
607
+ const ch = this.source[this.pos];
608
+
609
+ if (ch === '\\') {
610
+ this.pos++;
611
+ const esc = this.source[this.pos++] ?? '';
612
+ switch (esc) {
613
+ case 'n': str += '\n'; break;
614
+ case 't': str += '\t'; break;
615
+ case 'r': str += '\r'; break;
616
+ case '\\': str += '\\'; break;
617
+ case '"': str += '"'; break;
618
+ case "'": str += "'"; break;
619
+ case '`': str += '`'; break;
620
+ default: str += esc;
621
+ }
622
+ } else if (ch === quote) {
623
+ this.pos++; // consume closing quote
624
+ break;
625
+ } else {
626
+ str += ch;
627
+ this.pos++;
628
+ }
629
+ }
630
+
631
+ return str;
632
+ }
633
+
634
+ private parseNumber(): number {
635
+ const start = this.pos;
636
+ let hasDigits = false;
637
+
638
+ // Note: `parsePrimary` only calls this method when the first character is
639
+ // a digit OR when it is '.' followed immediately by a digit, so the case
640
+ // of a bare '.' (e.g. `.toString()`) can never reach here.
641
+
642
+ // Integer part
643
+ while (this.pos < this.source.length && /\d/.test(this.source[this.pos])) {
644
+ hasDigits = true;
645
+ this.pos++;
646
+ }
647
+
648
+ // Optional fractional part (only one decimal point is consumed; a second
649
+ // '.' is left in the stream and will cause an "unexpected token" error at
650
+ // the `evaluate()` level, correctly rejecting inputs like `1.2.3`).
651
+ if (this.source[this.pos] === '.') {
652
+ this.pos++; // consume '.'
653
+ while (this.pos < this.source.length && /\d/.test(this.source[this.pos])) {
654
+ hasDigits = true;
655
+ this.pos++;
656
+ }
657
+ }
658
+
659
+ // Optional exponent: e+5, E-3
660
+ if (/[eE]/.test(this.source[this.pos] ?? '')) {
661
+ this.pos++; // consume 'e' or 'E'
662
+ if (/[+\-]/.test(this.source[this.pos] ?? '')) this.pos++; // optional sign
663
+
664
+ let expDigits = 0;
665
+ while (this.pos < this.source.length && /\d/.test(this.source[this.pos])) {
666
+ expDigits++;
667
+ this.pos++;
668
+ }
669
+ if (expDigits === 0) {
670
+ throw new SyntaxError(`Invalid numeric literal exponent at position ${start}`);
671
+ }
672
+ }
673
+
674
+ if (!hasDigits) {
675
+ throw new SyntaxError(`Invalid numeric literal at position ${start}`);
676
+ }
677
+
678
+ const raw = this.source.slice(start, this.pos);
679
+ const value = Number(raw);
680
+
681
+ // Defensive final check: the strict loop above should never produce a
682
+ // non-finite result, but we guard here so any latent bug surfaces as a
683
+ // clear SyntaxError rather than silently propagating NaN.
684
+ if (!Number.isFinite(value)) {
685
+ throw new SyntaxError(`Invalid numeric literal "${raw}" at position ${start}`);
686
+ }
687
+
688
+ return value;
689
+ }
690
+
691
+ // ─── Identifier / keyword parsing ────────────────────────────────────────
692
+
693
+ /**
694
+ * Parse an identifier name (stops at non-word characters).
695
+ * Does NOT consume any trailing whitespace or operators.
696
+ */
697
+ private parseIdentifierName(): string {
698
+ const start = this.pos;
699
+ while (this.pos < this.source.length && /[\w$]/.test(this.source[this.pos])) {
700
+ this.pos++;
701
+ }
702
+ return this.source.slice(start, this.pos);
703
+ }
704
+
705
+ /** Parse an identifier and resolve keywords, `new`, arrows, calls, lookups. */
706
+ private parseIdentifierOrKeyword(): unknown {
707
+ const id = this.parseIdentifierName();
708
+
709
+ // Literal keywords
710
+ switch (id) {
711
+ case 'true': return true;
712
+ case 'false': return false;
713
+ case 'null': return null;
714
+ case 'undefined': return undefined;
715
+ case 'NaN': return NaN;
716
+ case 'Infinity': return Infinity;
717
+ }
718
+
719
+ // `new` keyword: new Date(), new RegExp(...)
720
+ if (id === 'new') {
721
+ return this.parseNewExpression();
722
+ }
723
+
724
+ this.skipWhitespace();
725
+
726
+ // Single-param arrow function without parentheses: `param => body`
727
+ if (this.peek() === '=' && this.peek(1) === '>') {
728
+ return this.parseArrowFunction(id);
729
+ }
730
+
731
+ // Function call: FN(args)
732
+ if (this.peek() === '(') {
733
+ this.pos++; // consume '('
734
+ const args = this.parseArgList();
735
+ if (this.peek() !== ')') {
736
+ throw new SyntaxError(`Expected ")" after argument list at position ${this.pos}`);
737
+ }
738
+ this.pos++; // consume ')'
739
+
740
+ if (!this._evaluating) return undefined;
741
+
742
+ const fn = this.context[id];
743
+ if (typeof fn === 'function') {
744
+ return (fn as (...a: unknown[]) => unknown)(...args);
745
+ }
746
+ throw new TypeError(`"${id}" is not a function`);
747
+ }
748
+
749
+ // Variable lookup.
750
+ // In not-evaluating mode return undefined instead of throwing ReferenceError,
751
+ // so that short-circuited branches do not cause spurious errors.
752
+ if (!this._evaluating) return undefined;
753
+
754
+ if (!(id in this.context)) {
755
+ throw new ReferenceError(`${id} is not defined`);
756
+ }
757
+
758
+ return this.context[id];
759
+ }
760
+
761
+ /**
762
+ * Handle `new ConstructorName(args)` expressions.
763
+ * Only safe constructors (Date, RegExp) are permitted.
764
+ */
765
+ private parseNewExpression(): unknown {
766
+ this.skipWhitespace();
767
+ const constructorName = this.parseIdentifierName();
768
+ this.skipWhitespace();
769
+
770
+ let args: unknown[] = [];
771
+ if (this.peek() === '(') {
772
+ this.pos++; // consume '('
773
+ args = this.parseArgList();
774
+ if (this.peek() !== ')') {
775
+ throw new SyntaxError(`Expected ")" after new ${constructorName}() at position ${this.pos}`);
776
+ }
777
+ this.pos++; // consume ')'
778
+ }
779
+
780
+ if (!this._evaluating) return undefined;
781
+
782
+ switch (constructorName) {
783
+ case 'Date':
784
+ return new Date(...(args as ConstructorParameters<typeof Date>));
785
+ case 'RegExp':
786
+ return new RegExp(args[0] as string, args[1] as string | undefined);
787
+ default:
788
+ throw new TypeError(
789
+ `new ${constructorName}() is not supported in expressions`
790
+ );
791
+ }
792
+ }
793
+
794
+ /**
795
+ * Parse a single-param arrow function: `param => bodyExpression`
796
+ *
797
+ * The body is captured as a source substring (without evaluating it at
798
+ * parse time), so that the parameter is properly bound when the returned
799
+ * function is later invoked (e.g., inside `.filter()`, `.map()`, etc.).
800
+ */
801
+ private parseArrowFunction(param: string): (...args: unknown[]) => unknown {
802
+ this.pos += 2; // consume '=>'
803
+ this.skipWhitespace();
804
+
805
+ // Scan the source to find where the body expression ends (depth-0 comma
806
+ // or closing bracket/paren), without evaluating it.
807
+ const bodyStart = this.pos;
808
+ const bodyEnd = this.scanExpressionEnd();
809
+ const bodyStr = this.source.slice(bodyStart, bodyEnd).trim();
810
+ this.pos = bodyEnd;
811
+
812
+ // Capture the outer context by value so the returned function can use it.
813
+ const capturedContext = this.context;
814
+
815
+ return (arg: unknown) => {
816
+ const parser = new SafeExpressionParser();
817
+ return parser.evaluate(bodyStr, {
818
+ ...capturedContext,
819
+ [param]: arg,
820
+ });
821
+ };
822
+ }
823
+
824
+ /**
825
+ * Scan forward from the current position to find the end of a sub-expression
826
+ * without evaluating it. Stops when a depth-0 `,`, `)`, or `]` is found.
827
+ * Correctly skips over string literals and nested brackets.
828
+ *
829
+ * @returns The index just past the last character of the sub-expression.
830
+ */
831
+ private scanExpressionEnd(): number {
832
+ let i = this.pos;
833
+ let depth = 0;
834
+ let inString = false;
835
+ let stringChar = '';
836
+
837
+ while (i < this.source.length) {
838
+ const ch = this.source[i];
839
+
840
+ if (inString) {
841
+ if (ch === '\\') {
842
+ i += 2; // skip escaped character
843
+ continue;
844
+ }
845
+ if (ch === stringChar) {
846
+ inString = false;
847
+ }
848
+ i++;
849
+ continue;
850
+ }
851
+
852
+ if (ch === '"' || ch === "'") {
853
+ inString = true;
854
+ stringChar = ch;
855
+ i++;
856
+ continue;
857
+ }
858
+
859
+ if (ch === '(' || ch === '[') {
860
+ depth++;
861
+ } else if (ch === ')' || ch === ']') {
862
+ if (depth === 0) break; // end of this sub-expression
863
+ depth--;
864
+ } else if (ch === ',' && depth === 0) {
865
+ break; // argument separator
866
+ }
867
+
868
+ i++;
869
+ }
870
+
871
+ return i;
872
+ }
873
+
874
+ /**
875
+ * Parse a comma-separated argument list up to (but not including) the
876
+ * closing `)`.
877
+ */
878
+ private parseArgList(): unknown[] {
879
+ const args: unknown[] = [];
880
+ this.skipWhitespace();
881
+
882
+ while (this.peek() !== ')' && this.pos < this.source.length) {
883
+ args.push(this.parseTernary());
884
+ this.skipWhitespace();
885
+ if (this.peek() === ',') {
886
+ this.pos++;
887
+ this.skipWhitespace();
888
+ }
889
+ }
890
+
891
+ return args;
892
+ }
893
+ }