@mmapp/player-core 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +4828 -0
- package/dist/index.mjs +4762 -0
- package/package.json +35 -0
- package/package.json.backup +35 -0
- package/src/__tests__/actions.test.ts +187 -0
- package/src/__tests__/blueprint-e2e.test.ts +706 -0
- package/src/__tests__/blueprint-test-runner.test.ts +680 -0
- package/src/__tests__/core-functions.test.ts +78 -0
- package/src/__tests__/dsl-compiler.test.ts +1382 -0
- package/src/__tests__/dsl-grammar.test.ts +1682 -0
- package/src/__tests__/events.test.ts +200 -0
- package/src/__tests__/expression.test.ts +296 -0
- package/src/__tests__/failure-policies.test.ts +110 -0
- package/src/__tests__/frontend-context.test.ts +182 -0
- package/src/__tests__/integration.test.ts +256 -0
- package/src/__tests__/security.test.ts +190 -0
- package/src/__tests__/state-machine.test.ts +450 -0
- package/src/__tests__/testing-engine.test.ts +671 -0
- package/src/actions/dispatcher.ts +80 -0
- package/src/actions/index.ts +7 -0
- package/src/actions/types.ts +25 -0
- package/src/dsl/compiler/component-mapper.ts +289 -0
- package/src/dsl/compiler/field-mapper.ts +187 -0
- package/src/dsl/compiler/index.ts +82 -0
- package/src/dsl/compiler/manifest-compiler.ts +76 -0
- package/src/dsl/compiler/symbol-table.ts +214 -0
- package/src/dsl/compiler/utils.ts +48 -0
- package/src/dsl/compiler/view-compiler.ts +286 -0
- package/src/dsl/compiler/workflow-compiler.ts +600 -0
- package/src/dsl/index.ts +66 -0
- package/src/dsl/ir-migration.ts +221 -0
- package/src/dsl/ir-types.ts +416 -0
- package/src/dsl/lexer.ts +579 -0
- package/src/dsl/parser.ts +115 -0
- package/src/dsl/types.ts +256 -0
- package/src/events/event-bus.ts +68 -0
- package/src/events/index.ts +9 -0
- package/src/events/pattern-matcher.ts +61 -0
- package/src/events/types.ts +27 -0
- package/src/expression/evaluator.ts +676 -0
- package/src/expression/functions.ts +214 -0
- package/src/expression/index.ts +13 -0
- package/src/expression/types.ts +64 -0
- package/src/index.ts +61 -0
- package/src/state-machine/index.ts +16 -0
- package/src/state-machine/interpreter.ts +319 -0
- package/src/state-machine/types.ts +89 -0
- package/src/testing/action-trace.ts +209 -0
- package/src/testing/blueprint-test-runner.ts +214 -0
- package/src/testing/graph-walker.ts +249 -0
- package/src/testing/index.ts +69 -0
- package/src/testing/nrt-comparator.ts +199 -0
- package/src/testing/nrt-types.ts +230 -0
- package/src/testing/test-actions.ts +645 -0
- package/src/testing/test-compiler.ts +278 -0
- package/src/testing/test-runner.ts +444 -0
- package/src/testing/types.ts +231 -0
- package/src/validation/definition-validator.ts +812 -0
- package/src/validation/index.ts +13 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expression Evaluator — createEvaluator factory and evaluation engine.
|
|
3
|
+
*
|
|
4
|
+
* Parses and evaluates expressions with a safe recursive descent parser.
|
|
5
|
+
* NO dynamic code compilation (no new Function(), no eval(), no with()).
|
|
6
|
+
*
|
|
7
|
+
* Supported syntax:
|
|
8
|
+
* - Path resolution: `state_data.title`, `$instance.fields.name`
|
|
9
|
+
* - Function calls: `add(1, 2)`, `if(eq(x, 1), 'yes', 'no')`
|
|
10
|
+
* - Template interpolation: `"Hello {{name}}, you have {{count}} items"`
|
|
11
|
+
* - Ternary expressions: `condition ? 'yes' : 'no'`
|
|
12
|
+
* - Operators: `==`, `!=`, `>`, `<`, `>=`, `<=`, `&&`, `||`, `!`
|
|
13
|
+
* - Literals: strings, numbers, booleans, null
|
|
14
|
+
*
|
|
15
|
+
* Grammar:
|
|
16
|
+
* expression → ternary
|
|
17
|
+
* ternary → logicalOr ('?' expression ':' expression)?
|
|
18
|
+
* logicalOr → logicalAnd ('||' logicalAnd)*
|
|
19
|
+
* logicalAnd → equality ('&&' equality)*
|
|
20
|
+
* equality → comparison (('=='|'!=') comparison)*
|
|
21
|
+
* comparison → unary (('>'|'<'|'>='|'<=') unary)*
|
|
22
|
+
* unary → ('!' unary) | call
|
|
23
|
+
* call → primary ('(' args? ')')? ('.' IDENT ('(' args? ')')?)*
|
|
24
|
+
* primary → NUMBER | STRING | BOOL | NULL | IDENT | '(' expression ')'
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
Evaluator,
|
|
29
|
+
EvaluatorConfig,
|
|
30
|
+
ExpressionContext,
|
|
31
|
+
ExpressionResult,
|
|
32
|
+
FailurePolicy,
|
|
33
|
+
ExpressionFunction,
|
|
34
|
+
} from './types';
|
|
35
|
+
import { CORE_FUNCTIONS, buildFunctionMap } from './functions';
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// AST Node Types
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
type ASTNode =
|
|
42
|
+
| { type: 'number'; value: number }
|
|
43
|
+
| { type: 'string'; value: string }
|
|
44
|
+
| { type: 'boolean'; value: boolean }
|
|
45
|
+
| { type: 'null' }
|
|
46
|
+
| { type: 'identifier'; name: string }
|
|
47
|
+
| { type: 'path'; segments: string[] }
|
|
48
|
+
| { type: 'call'; name: string; args: ASTNode[] }
|
|
49
|
+
| { type: 'method_call'; object: ASTNode; method: string; args: ASTNode[] }
|
|
50
|
+
| { type: 'member'; object: ASTNode; property: string }
|
|
51
|
+
| { type: 'unary'; operator: '!'; operand: ASTNode }
|
|
52
|
+
| { type: 'binary'; operator: string; left: ASTNode; right: ASTNode }
|
|
53
|
+
| { type: 'ternary'; condition: ASTNode; consequent: ASTNode; alternate: ASTNode };
|
|
54
|
+
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// Parser
|
|
57
|
+
// =============================================================================
|
|
58
|
+
|
|
59
|
+
const MAX_DEPTH = 50;
|
|
60
|
+
|
|
61
|
+
class Parser {
|
|
62
|
+
private pos = 0;
|
|
63
|
+
private depth = 0;
|
|
64
|
+
private readonly input: string;
|
|
65
|
+
|
|
66
|
+
constructor(input: string) {
|
|
67
|
+
this.input = input;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
parse(): ASTNode {
|
|
71
|
+
this.skipWhitespace();
|
|
72
|
+
const node = this.parseExpression();
|
|
73
|
+
this.skipWhitespace();
|
|
74
|
+
if (this.pos < this.input.length) {
|
|
75
|
+
throw new Error(`Unexpected character at position ${this.pos}: '${this.input[this.pos]}'`);
|
|
76
|
+
}
|
|
77
|
+
return node;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private guardDepth(): void {
|
|
81
|
+
if (++this.depth > MAX_DEPTH) {
|
|
82
|
+
throw new Error('Expression too deeply nested');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private parseExpression(): ASTNode {
|
|
87
|
+
this.guardDepth();
|
|
88
|
+
try {
|
|
89
|
+
return this.parseTernary();
|
|
90
|
+
} finally {
|
|
91
|
+
this.depth--;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private parseTernary(): ASTNode {
|
|
96
|
+
let node = this.parseLogicalOr();
|
|
97
|
+
this.skipWhitespace();
|
|
98
|
+
if (this.peek() === '?') {
|
|
99
|
+
this.advance(); // consume '?'
|
|
100
|
+
const consequent = this.parseExpression();
|
|
101
|
+
this.skipWhitespace();
|
|
102
|
+
this.expect(':');
|
|
103
|
+
const alternate = this.parseExpression();
|
|
104
|
+
node = { type: 'ternary', condition: node, consequent, alternate };
|
|
105
|
+
}
|
|
106
|
+
return node;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private parseLogicalOr(): ASTNode {
|
|
110
|
+
let left = this.parseLogicalAnd();
|
|
111
|
+
this.skipWhitespace();
|
|
112
|
+
while (this.match('||')) {
|
|
113
|
+
const right = this.parseLogicalAnd();
|
|
114
|
+
left = { type: 'binary', operator: '||', left, right };
|
|
115
|
+
this.skipWhitespace();
|
|
116
|
+
}
|
|
117
|
+
return left;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private parseLogicalAnd(): ASTNode {
|
|
121
|
+
let left = this.parseEquality();
|
|
122
|
+
this.skipWhitespace();
|
|
123
|
+
while (this.match('&&')) {
|
|
124
|
+
const right = this.parseEquality();
|
|
125
|
+
left = { type: 'binary', operator: '&&', left, right };
|
|
126
|
+
this.skipWhitespace();
|
|
127
|
+
}
|
|
128
|
+
return left;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private parseEquality(): ASTNode {
|
|
132
|
+
let left = this.parseComparison();
|
|
133
|
+
this.skipWhitespace();
|
|
134
|
+
while (true) {
|
|
135
|
+
if (this.match('==')) {
|
|
136
|
+
const right = this.parseComparison();
|
|
137
|
+
left = { type: 'binary', operator: '==', left, right };
|
|
138
|
+
} else if (this.match('!=')) {
|
|
139
|
+
const right = this.parseComparison();
|
|
140
|
+
left = { type: 'binary', operator: '!=', left, right };
|
|
141
|
+
} else {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
this.skipWhitespace();
|
|
145
|
+
}
|
|
146
|
+
return left;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private parseComparison(): ASTNode {
|
|
150
|
+
let left = this.parseUnary();
|
|
151
|
+
this.skipWhitespace();
|
|
152
|
+
while (true) {
|
|
153
|
+
if (this.match('>=')) {
|
|
154
|
+
const right = this.parseUnary();
|
|
155
|
+
left = { type: 'binary', operator: '>=', left, right };
|
|
156
|
+
} else if (this.match('<=')) {
|
|
157
|
+
const right = this.parseUnary();
|
|
158
|
+
left = { type: 'binary', operator: '<=', left, right };
|
|
159
|
+
} else if (this.peek() === '>' && !this.lookAhead('>=')) {
|
|
160
|
+
this.advance();
|
|
161
|
+
const right = this.parseUnary();
|
|
162
|
+
left = { type: 'binary', operator: '>', left, right };
|
|
163
|
+
} else if (this.peek() === '<' && !this.lookAhead('<=')) {
|
|
164
|
+
this.advance();
|
|
165
|
+
const right = this.parseUnary();
|
|
166
|
+
left = { type: 'binary', operator: '<', left, right };
|
|
167
|
+
} else {
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
this.skipWhitespace();
|
|
171
|
+
}
|
|
172
|
+
return left;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private parseUnary(): ASTNode {
|
|
176
|
+
this.skipWhitespace();
|
|
177
|
+
if (this.peek() === '!') {
|
|
178
|
+
this.advance();
|
|
179
|
+
const operand = this.parseUnary();
|
|
180
|
+
return { type: 'unary', operator: '!', operand };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Handle unary minus for negative numbers
|
|
184
|
+
if (this.peek() === '-') {
|
|
185
|
+
const nextChar = this.input[this.pos + 1];
|
|
186
|
+
if (nextChar !== undefined && (nextChar >= '0' && nextChar <= '9' || nextChar === '.')) {
|
|
187
|
+
// Parse as negative number in primary
|
|
188
|
+
return this.parseCallChain();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return this.parseCallChain();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private parseCallChain(): ASTNode {
|
|
196
|
+
let node = this.parsePrimary();
|
|
197
|
+
|
|
198
|
+
// Handle call chains: ident(args), ident.prop, ident.method(args)
|
|
199
|
+
while (true) {
|
|
200
|
+
this.skipWhitespace();
|
|
201
|
+
if (this.peek() === '(') {
|
|
202
|
+
// Function call — only valid if node is identifier or path
|
|
203
|
+
this.advance(); // consume '('
|
|
204
|
+
const args = this.parseArgList();
|
|
205
|
+
this.expect(')');
|
|
206
|
+
|
|
207
|
+
if (node.type === 'identifier') {
|
|
208
|
+
node = { type: 'call', name: node.name, args };
|
|
209
|
+
} else if (node.type === 'path') {
|
|
210
|
+
// e.g. module.fn(args) — treat the last segment as the function name
|
|
211
|
+
const name = node.segments.join('.');
|
|
212
|
+
node = { type: 'call', name, args };
|
|
213
|
+
} else if (node.type === 'member') {
|
|
214
|
+
node = { type: 'method_call', object: node.object, method: node.property, args };
|
|
215
|
+
} else {
|
|
216
|
+
throw new Error('Cannot call non-function');
|
|
217
|
+
}
|
|
218
|
+
} else if (this.peek() === '.') {
|
|
219
|
+
this.advance(); // consume '.'
|
|
220
|
+
const prop = this.parseIdentifierName();
|
|
221
|
+
node = { type: 'member', object: node, property: prop };
|
|
222
|
+
} else {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return node;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private parsePrimary(): ASTNode {
|
|
231
|
+
this.skipWhitespace();
|
|
232
|
+
const ch = this.peek();
|
|
233
|
+
|
|
234
|
+
// Parenthesized expression
|
|
235
|
+
if (ch === '(') {
|
|
236
|
+
this.advance();
|
|
237
|
+
const expr = this.parseExpression();
|
|
238
|
+
this.skipWhitespace();
|
|
239
|
+
this.expect(')');
|
|
240
|
+
return expr;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// String literal (single or double quoted)
|
|
244
|
+
if (ch === "'" || ch === '"') {
|
|
245
|
+
return this.parseString();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Number literal (including negative)
|
|
249
|
+
if (ch === '-' || (ch >= '0' && ch <= '9')) {
|
|
250
|
+
return this.parseNumber();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Identifier, boolean, null, or path
|
|
254
|
+
if (this.isIdentStart(ch)) {
|
|
255
|
+
return this.parseIdentifierOrPath();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
throw new Error(
|
|
259
|
+
`Unexpected character at position ${this.pos}: '${ch || 'EOF'}'`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private parseString(): ASTNode {
|
|
264
|
+
const quote = this.advance(); // consume opening quote
|
|
265
|
+
let value = '';
|
|
266
|
+
while (this.pos < this.input.length && this.peek() !== quote) {
|
|
267
|
+
if (this.peek() === '\\') {
|
|
268
|
+
this.advance(); // consume backslash
|
|
269
|
+
const esc = this.advance();
|
|
270
|
+
if (esc === 'n') value += '\n';
|
|
271
|
+
else if (esc === 't') value += '\t';
|
|
272
|
+
else if (esc === 'r') value += '\r';
|
|
273
|
+
else value += esc;
|
|
274
|
+
} else {
|
|
275
|
+
value += this.advance();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (this.pos >= this.input.length) {
|
|
279
|
+
throw new Error('Unterminated string literal');
|
|
280
|
+
}
|
|
281
|
+
this.advance(); // consume closing quote
|
|
282
|
+
return { type: 'string', value };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private parseNumber(): ASTNode {
|
|
286
|
+
let numStr = '';
|
|
287
|
+
if (this.peek() === '-') {
|
|
288
|
+
numStr += this.advance();
|
|
289
|
+
}
|
|
290
|
+
while (this.pos < this.input.length && (this.input[this.pos] >= '0' && this.input[this.pos] <= '9')) {
|
|
291
|
+
numStr += this.advance();
|
|
292
|
+
}
|
|
293
|
+
if (this.peek() === '.' && this.pos + 1 < this.input.length && this.input[this.pos + 1] >= '0' && this.input[this.pos + 1] <= '9') {
|
|
294
|
+
numStr += this.advance(); // consume '.'
|
|
295
|
+
while (this.pos < this.input.length && (this.input[this.pos] >= '0' && this.input[this.pos] <= '9')) {
|
|
296
|
+
numStr += this.advance();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { type: 'number', value: Number(numStr) };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private parseIdentifierOrPath(): ASTNode {
|
|
303
|
+
const name = this.parseIdentifierName();
|
|
304
|
+
|
|
305
|
+
// Check for keywords
|
|
306
|
+
if (name === 'true') return { type: 'boolean', value: true };
|
|
307
|
+
if (name === 'false') return { type: 'boolean', value: false };
|
|
308
|
+
if (name === 'null') return { type: 'null' };
|
|
309
|
+
if (name === 'undefined') return { type: 'null' };
|
|
310
|
+
|
|
311
|
+
return { type: 'identifier', name };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private parseIdentifierName(): string {
|
|
315
|
+
let name = '';
|
|
316
|
+
if (this.peek() === '$') name += this.advance();
|
|
317
|
+
while (this.pos < this.input.length && this.isIdentPart(this.input[this.pos])) {
|
|
318
|
+
name += this.advance();
|
|
319
|
+
}
|
|
320
|
+
if (!name) {
|
|
321
|
+
throw new Error(`Expected identifier at position ${this.pos}`);
|
|
322
|
+
}
|
|
323
|
+
return name;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private parseArgList(): ASTNode[] {
|
|
327
|
+
this.skipWhitespace();
|
|
328
|
+
if (this.peek() === ')') return [];
|
|
329
|
+
|
|
330
|
+
const args: ASTNode[] = [];
|
|
331
|
+
args.push(this.parseExpression());
|
|
332
|
+
this.skipWhitespace();
|
|
333
|
+
while (this.peek() === ',') {
|
|
334
|
+
this.advance(); // consume ','
|
|
335
|
+
args.push(this.parseExpression());
|
|
336
|
+
this.skipWhitespace();
|
|
337
|
+
}
|
|
338
|
+
return args;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Character utilities
|
|
342
|
+
|
|
343
|
+
private peek(): string {
|
|
344
|
+
return this.input[this.pos] ?? '';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private advance(): string {
|
|
348
|
+
return this.input[this.pos++] ?? '';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private match(str: string): boolean {
|
|
352
|
+
if (this.input.startsWith(str, this.pos)) {
|
|
353
|
+
this.pos += str.length;
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private lookAhead(str: string): boolean {
|
|
360
|
+
return this.input.startsWith(str, this.pos);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private expect(ch: string): void {
|
|
364
|
+
this.skipWhitespace();
|
|
365
|
+
if (this.peek() !== ch) {
|
|
366
|
+
throw new Error(`Expected '${ch}' at position ${this.pos}, got '${this.peek() || 'EOF'}'`);
|
|
367
|
+
}
|
|
368
|
+
this.advance();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private skipWhitespace(): void {
|
|
372
|
+
while (this.pos < this.input.length && ' \t\n\r'.includes(this.input[this.pos])) {
|
|
373
|
+
this.pos++;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private isIdentStart(ch: string): boolean {
|
|
378
|
+
return (
|
|
379
|
+
(ch >= 'a' && ch <= 'z') ||
|
|
380
|
+
(ch >= 'A' && ch <= 'Z') ||
|
|
381
|
+
ch === '_' ||
|
|
382
|
+
ch === '$'
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private isIdentPart(ch: string): boolean {
|
|
387
|
+
return this.isIdentStart(ch) || (ch >= '0' && ch <= '9');
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// =============================================================================
|
|
392
|
+
// AST Evaluator
|
|
393
|
+
// =============================================================================
|
|
394
|
+
|
|
395
|
+
function evaluateAST(
|
|
396
|
+
node: ASTNode,
|
|
397
|
+
context: ExpressionContext,
|
|
398
|
+
fnMap: Map<string, ExpressionFunction['fn']>,
|
|
399
|
+
): unknown {
|
|
400
|
+
switch (node.type) {
|
|
401
|
+
case 'number':
|
|
402
|
+
return node.value;
|
|
403
|
+
case 'string':
|
|
404
|
+
return node.value;
|
|
405
|
+
case 'boolean':
|
|
406
|
+
return node.value;
|
|
407
|
+
case 'null':
|
|
408
|
+
return null;
|
|
409
|
+
|
|
410
|
+
case 'identifier':
|
|
411
|
+
return resolvePath(node.name, context);
|
|
412
|
+
|
|
413
|
+
case 'path':
|
|
414
|
+
return resolvePath(node.segments.join('.'), context);
|
|
415
|
+
|
|
416
|
+
case 'member': {
|
|
417
|
+
const obj = evaluateAST(node.object, context, fnMap);
|
|
418
|
+
if (obj == null || typeof obj !== 'object') return undefined;
|
|
419
|
+
return (obj as Record<string, unknown>)[node.property];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
case 'call': {
|
|
423
|
+
const fn = fnMap.get(node.name);
|
|
424
|
+
if (!fn) return undefined;
|
|
425
|
+
const args = node.args.map(a => evaluateAST(a, context, fnMap));
|
|
426
|
+
return fn(...args);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
case 'method_call': {
|
|
430
|
+
// Try to resolve as a dotted function name first (e.g., Math.abs)
|
|
431
|
+
const obj = evaluateAST(node.object, context, fnMap);
|
|
432
|
+
if (obj != null && typeof obj === 'object') {
|
|
433
|
+
const method = (obj as Record<string, unknown>)[node.method];
|
|
434
|
+
if (typeof method === 'function') {
|
|
435
|
+
const args = node.args.map(a => evaluateAST(a, context, fnMap));
|
|
436
|
+
return method.apply(obj, args);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
case 'unary': {
|
|
443
|
+
const operand = evaluateAST(node.operand, context, fnMap);
|
|
444
|
+
return !operand;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
case 'binary': {
|
|
448
|
+
// Short-circuit for logical operators
|
|
449
|
+
if (node.operator === '&&') {
|
|
450
|
+
const left = evaluateAST(node.left, context, fnMap);
|
|
451
|
+
if (!left) return left;
|
|
452
|
+
return evaluateAST(node.right, context, fnMap);
|
|
453
|
+
}
|
|
454
|
+
if (node.operator === '||') {
|
|
455
|
+
const left = evaluateAST(node.left, context, fnMap);
|
|
456
|
+
if (left) return left;
|
|
457
|
+
return evaluateAST(node.right, context, fnMap);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const left = evaluateAST(node.left, context, fnMap);
|
|
461
|
+
const right = evaluateAST(node.right, context, fnMap);
|
|
462
|
+
|
|
463
|
+
switch (node.operator) {
|
|
464
|
+
// eslint-disable-next-line eqeqeq
|
|
465
|
+
case '==': return left == right;
|
|
466
|
+
// eslint-disable-next-line eqeqeq
|
|
467
|
+
case '!=': return left != right;
|
|
468
|
+
case '>': return Number(left) > Number(right);
|
|
469
|
+
case '<': return Number(left) < Number(right);
|
|
470
|
+
case '>=': return Number(left) >= Number(right);
|
|
471
|
+
case '<=': return Number(left) <= Number(right);
|
|
472
|
+
default: return undefined;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
case 'ternary': {
|
|
477
|
+
const condition = evaluateAST(node.condition, context, fnMap);
|
|
478
|
+
return condition
|
|
479
|
+
? evaluateAST(node.consequent, context, fnMap)
|
|
480
|
+
: evaluateAST(node.alternate, context, fnMap);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// =============================================================================
|
|
486
|
+
// AST Cache
|
|
487
|
+
// =============================================================================
|
|
488
|
+
|
|
489
|
+
const MAX_CACHE = 500;
|
|
490
|
+
|
|
491
|
+
/** Parsed AST cache — shared across evaluator instances */
|
|
492
|
+
const astCache = new Map<string, ASTNode>();
|
|
493
|
+
|
|
494
|
+
function evictIfNeeded(): void {
|
|
495
|
+
if (astCache.size > MAX_CACHE) {
|
|
496
|
+
const keys = Array.from(astCache.keys());
|
|
497
|
+
const evictCount = Math.floor(MAX_CACHE * 0.25);
|
|
498
|
+
for (let i = 0; i < evictCount; i++) {
|
|
499
|
+
astCache.delete(keys[i]!);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function parseAndCache(expr: string): ASTNode {
|
|
505
|
+
const cached = astCache.get(expr);
|
|
506
|
+
if (cached) return cached;
|
|
507
|
+
|
|
508
|
+
const parser = new Parser(expr);
|
|
509
|
+
const ast = parser.parse();
|
|
510
|
+
astCache.set(expr, ast);
|
|
511
|
+
evictIfNeeded();
|
|
512
|
+
return ast;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// =============================================================================
|
|
516
|
+
// Public API (unchanged signatures)
|
|
517
|
+
// =============================================================================
|
|
518
|
+
|
|
519
|
+
/** Template regex: matches {{expression}} */
|
|
520
|
+
const TEMPLATE_RE = /\{\{(.+?)\}\}/g;
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Resolve a dot-path from a context object.
|
|
524
|
+
* Handles: "state_data.title", "$instance.fields.name"
|
|
525
|
+
*/
|
|
526
|
+
function resolvePath(path: string, context: ExpressionContext): unknown {
|
|
527
|
+
const parts = path.split('.');
|
|
528
|
+
let current: unknown = context;
|
|
529
|
+
for (const part of parts) {
|
|
530
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
531
|
+
current = (current as Record<string, unknown>)[part];
|
|
532
|
+
}
|
|
533
|
+
return current;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Evaluate a single expression string against a context.
|
|
538
|
+
*/
|
|
539
|
+
function evaluateExpression(
|
|
540
|
+
expr: string,
|
|
541
|
+
context: ExpressionContext,
|
|
542
|
+
fnMap: Map<string, ExpressionFunction['fn']>,
|
|
543
|
+
): unknown {
|
|
544
|
+
const trimmed = expr.trim();
|
|
545
|
+
|
|
546
|
+
// Fast paths for common patterns
|
|
547
|
+
if (trimmed === 'true') return true;
|
|
548
|
+
if (trimmed === 'false') return false;
|
|
549
|
+
if (trimmed === 'null') return null;
|
|
550
|
+
if (trimmed === 'undefined') return undefined;
|
|
551
|
+
|
|
552
|
+
// Numeric literal
|
|
553
|
+
const num = Number(trimmed);
|
|
554
|
+
if (!isNaN(num) && trimmed !== '') return num;
|
|
555
|
+
|
|
556
|
+
// String literal (single or double quoted)
|
|
557
|
+
if (
|
|
558
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
|
559
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
560
|
+
) {
|
|
561
|
+
return trimmed.slice(1, -1);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Simple path (no operators, no function calls)
|
|
565
|
+
if (/^[a-zA-Z_$][\w$.]*$/.test(trimmed)) {
|
|
566
|
+
return resolvePath(trimmed, context);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Parse expression to AST and evaluate
|
|
570
|
+
const ast = parseAndCache(trimmed);
|
|
571
|
+
return evaluateAST(ast, context, fnMap);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* WEB_FAILURE_POLICIES — predefined failure policies for Web Player contexts.
|
|
576
|
+
*/
|
|
577
|
+
export const WEB_FAILURE_POLICIES: Record<string, FailurePolicy> = {
|
|
578
|
+
VIEW_BINDING: {
|
|
579
|
+
on_error: 'return_fallback',
|
|
580
|
+
fallback_value: '',
|
|
581
|
+
log_level: 'warn',
|
|
582
|
+
},
|
|
583
|
+
EVENT_REACTION: {
|
|
584
|
+
on_error: 'log_and_skip',
|
|
585
|
+
fallback_value: undefined,
|
|
586
|
+
log_level: 'error',
|
|
587
|
+
},
|
|
588
|
+
DURING_ACTION: {
|
|
589
|
+
on_error: 'log_and_skip',
|
|
590
|
+
fallback_value: undefined,
|
|
591
|
+
log_level: 'error',
|
|
592
|
+
},
|
|
593
|
+
CONDITIONAL_VISIBILITY: {
|
|
594
|
+
on_error: 'return_fallback',
|
|
595
|
+
fallback_value: true, // Show by default if condition fails
|
|
596
|
+
log_level: 'warn',
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* createEvaluator — factory function that builds a configured evaluator.
|
|
602
|
+
*
|
|
603
|
+
* @param config - Function registry and failure policy
|
|
604
|
+
* @returns Evaluator instance with evaluate, evaluateTemplate, and validate methods
|
|
605
|
+
*/
|
|
606
|
+
export function createEvaluator(config: EvaluatorConfig): Evaluator {
|
|
607
|
+
const allFunctions = [...CORE_FUNCTIONS, ...config.functions];
|
|
608
|
+
const fnMap = buildFunctionMap(allFunctions);
|
|
609
|
+
const policy = config.failurePolicy;
|
|
610
|
+
|
|
611
|
+
function handleError(expr: string, error: unknown): ExpressionResult {
|
|
612
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
613
|
+
|
|
614
|
+
if (policy.log_level === 'error') {
|
|
615
|
+
console.error(`[player-core] Expression error: "${expr}" — ${message}`);
|
|
616
|
+
} else if (policy.log_level === 'warn') {
|
|
617
|
+
console.warn(`[player-core] Expression error: "${expr}" — ${message}`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
switch (policy.on_error) {
|
|
621
|
+
case 'throw':
|
|
622
|
+
throw error;
|
|
623
|
+
case 'return_fallback':
|
|
624
|
+
return { value: policy.fallback_value, status: 'fallback', error: message };
|
|
625
|
+
case 'log_and_skip':
|
|
626
|
+
default:
|
|
627
|
+
return { value: policy.fallback_value, status: 'error', error: message };
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
evaluate<T = unknown>(expression: string, context: ExpressionContext): ExpressionResult<T> {
|
|
633
|
+
try {
|
|
634
|
+
const value = evaluateExpression(expression, context, fnMap);
|
|
635
|
+
return { value: value as T, status: 'ok' };
|
|
636
|
+
} catch (error) {
|
|
637
|
+
return handleError(expression, error) as ExpressionResult<T>;
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
evaluateTemplate(template: string, context: ExpressionContext): ExpressionResult<string> {
|
|
642
|
+
try {
|
|
643
|
+
// If no template markers, return as-is
|
|
644
|
+
if (!template.includes('{{')) {
|
|
645
|
+
return { value: template, status: 'ok' };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const result = template.replace(TEMPLATE_RE, (_match, expr: string) => {
|
|
649
|
+
const value = evaluateExpression(expr, context, fnMap);
|
|
650
|
+
return value != null ? String(value) : '';
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
return { value: result, status: 'ok' };
|
|
654
|
+
} catch (error) {
|
|
655
|
+
return handleError(template, error) as ExpressionResult<string>;
|
|
656
|
+
}
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
validate(expression: string): { valid: boolean; errors: string[] } {
|
|
660
|
+
const errors: string[] = [];
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
parseAndCache(expression);
|
|
664
|
+
} catch (e) {
|
|
665
|
+
errors.push(e instanceof Error ? e.message : String(e));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return { valid: errors.length === 0, errors };
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Clear parsed AST cache (for testing) */
|
|
674
|
+
export function clearExpressionCache(): void {
|
|
675
|
+
astCache.clear();
|
|
676
|
+
}
|