@flexiberry/berrycore 0.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapter/cli-adapter.d.ts +37 -0
  3. package/dist/adapter/cli-adapter.js +119 -0
  4. package/dist/berry-core.d.ts +108 -0
  5. package/dist/berry-core.js +258 -0
  6. package/dist/index.d.ts +13 -0
  7. package/dist/index.js +18 -0
  8. package/dist/interpreter/environment.d.ts +45 -0
  9. package/dist/interpreter/environment.js +96 -0
  10. package/dist/interpreter/errors.d.ts +16 -0
  11. package/dist/interpreter/errors.js +27 -0
  12. package/dist/interpreter/interpreter.d.ts +111 -0
  13. package/dist/interpreter/interpreter.js +682 -0
  14. package/dist/interpreter/interpreter.types.d.ts +182 -0
  15. package/dist/interpreter/interpreter.types.js +73 -0
  16. package/dist/parser/ast/ast.engine.d.ts +103 -0
  17. package/dist/parser/ast/ast.engine.js +526 -0
  18. package/dist/parser/ast/ast.types.d.ts +242 -0
  19. package/dist/parser/ast/ast.types.js +37 -0
  20. package/dist/parser/formatter/formatter.d.ts +44 -0
  21. package/dist/parser/formatter/formatter.js +214 -0
  22. package/dist/parser/tokenizer/reader/grammer/api.grammer.d.ts +2 -0
  23. package/dist/parser/tokenizer/reader/grammer/api.grammer.js +102 -0
  24. package/dist/parser/tokenizer/reader/grammer/capture.grammer.d.ts +2 -0
  25. package/dist/parser/tokenizer/reader/grammer/capture.grammer.js +21 -0
  26. package/dist/parser/tokenizer/reader/grammer/check.grammer.d.ts +2 -0
  27. package/dist/parser/tokenizer/reader/grammer/check.grammer.js +21 -0
  28. package/dist/parser/tokenizer/reader/grammer/comment.grammer.d.ts +2 -0
  29. package/dist/parser/tokenizer/reader/grammer/comment.grammer.js +13 -0
  30. package/dist/parser/tokenizer/reader/grammer/conditions.grammer.d.ts +2 -0
  31. package/dist/parser/tokenizer/reader/grammer/conditions.grammer.js +68 -0
  32. package/dist/parser/tokenizer/reader/grammer/input.grammer.d.ts +2 -0
  33. package/dist/parser/tokenizer/reader/grammer/input.grammer.js +17 -0
  34. package/dist/parser/tokenizer/reader/grammer/keyvalue.grammer.d.ts +2 -0
  35. package/dist/parser/tokenizer/reader/grammer/keyvalue.grammer.js +240 -0
  36. package/dist/parser/tokenizer/reader/grammer/link.grammer.d.ts +2 -0
  37. package/dist/parser/tokenizer/reader/grammer/link.grammer.js +17 -0
  38. package/dist/parser/tokenizer/reader/grammer/params.grammer.d.ts +2 -0
  39. package/dist/parser/tokenizer/reader/grammer/params.grammer.js +21 -0
  40. package/dist/parser/tokenizer/reader/grammer/step.grammer.d.ts +2 -0
  41. package/dist/parser/tokenizer/reader/grammer/step.grammer.js +25 -0
  42. package/dist/parser/tokenizer/reader/grammer/task.grammer.d.ts +2 -0
  43. package/dist/parser/tokenizer/reader/grammer/task.grammer.js +17 -0
  44. package/dist/parser/tokenizer/reader/grammer/var.grammer.d.ts +2 -0
  45. package/dist/parser/tokenizer/reader/grammer/var.grammer.js +47 -0
  46. package/dist/parser/tokenizer/reader/lexer.engine.d.ts +43 -0
  47. package/dist/parser/tokenizer/reader/lexer.engine.js +178 -0
  48. package/dist/parser/tokenizer/reader/lexer.types.d.ts +18 -0
  49. package/dist/parser/tokenizer/reader/lexer.types.js +1 -0
  50. package/dist/parser/tokenizer/token.d.ts +13 -0
  51. package/dist/parser/tokenizer/token.js +13 -0
  52. package/dist/parser/tokenizer/tokenType.d.ts +58 -0
  53. package/dist/parser/tokenizer/tokenType.js +64 -0
  54. package/dist/script/format-util.d.ts +33 -0
  55. package/dist/script/format-util.js +94 -0
  56. package/dist/script/postman.util.d.ts +88 -0
  57. package/dist/script/postman.util.js +176 -0
  58. package/dist/script/swagger.util.d.ts +80 -0
  59. package/dist/script/swagger.util.js +202 -0
  60. package/dist/util/store-util.d.ts +5 -0
  61. package/dist/util/store-util.js +22 -0
  62. package/package.json +25 -0
  63. package/readme.md +107 -0
@@ -0,0 +1,682 @@
1
+ /**
2
+ * Berry Interpreter
3
+ *
4
+ * Walks the ProgramNode AST and executes the Berry program.
5
+ * Uses the Visitor pattern — one visit* method per AST node type.
6
+ *
7
+ * Architecture rules:
8
+ * - Interpreter only walks AST — no parsing, no tokenizing
9
+ * - Maintains environment for variable scoping
10
+ * - Emits typed events for status reporting
11
+ * - Uses IOAdapter for user input (CLI or UI)
12
+ */
13
+ import { NodeType, } from "../parser/ast/ast.types.js";
14
+ import { InterpreterEvent, ExecutionCommand, ExecutionState, ExecutionStatus, } from "./interpreter.types.js";
15
+ import { Environment } from "./environment.js";
16
+ import { RuntimeError, ApiNotFoundError } from "./errors.js";
17
+ const DEFAULT_OPTIONS = {
18
+ apiTimeout: 30000,
19
+ continueOnError: true,
20
+ dryRun: false,
21
+ };
22
+ // ─── Interpreter ────────────────────────────────────────────────────────────
23
+ export class Interpreter {
24
+ ast;
25
+ options;
26
+ globalEnv = new Environment();
27
+ apiRegistry = new Map();
28
+ listeners = new Map();
29
+ ioAdapter = null;
30
+ // ── Execution Control State ──────────────────────────────────────────────
31
+ state = ExecutionState.Idle;
32
+ pendingCommand = ExecutionCommand.Continue;
33
+ pauseResolver = null;
34
+ /** Check if killed — bypasses TS narrowing since state changes async */
35
+ isKilled() { return this.state === ExecutionState.Killed; }
36
+ constructor(ast, options = {}) {
37
+ this.ast = ast;
38
+ this.options = { ...DEFAULT_OPTIONS, ...options };
39
+ }
40
+ // ── Public API ──────────────────────────────────────────────────────────
41
+ /** Register an event listener */
42
+ on(event, listener) {
43
+ if (!this.listeners.has(event)) {
44
+ this.listeners.set(event, []);
45
+ }
46
+ this.listeners.get(event).push(listener);
47
+ return this;
48
+ }
49
+ /** Set the IO adapter for user input, output, and execution control */
50
+ setIOAdapter(adapter) {
51
+ this.ioAdapter = adapter;
52
+ // Wire up command handler so adapter can send commands to interpreter
53
+ if (adapter.onCommand) {
54
+ adapter.onCommand((command) => {
55
+ this.sendCommand(command);
56
+ });
57
+ }
58
+ return this;
59
+ }
60
+ /**
61
+ * Send an execution command to the interpreter.
62
+ * Can be called externally (from adapter, UI, or programmatically).
63
+ */
64
+ sendCommand(command) {
65
+ this.pendingCommand = command;
66
+ switch (command) {
67
+ case ExecutionCommand.Pause:
68
+ if (this.state === ExecutionState.Running) {
69
+ this.state = ExecutionState.Paused;
70
+ this.pushLog("info", "⏸ Execution paused");
71
+ this.emitStateChanged(ExecutionState.Paused, "User requested pause");
72
+ }
73
+ break;
74
+ case ExecutionCommand.Continue:
75
+ if (this.state === ExecutionState.Paused) {
76
+ this.state = ExecutionState.Running;
77
+ this.pushLog("info", "▶️ Execution resumed");
78
+ this.emitStateChanged(ExecutionState.Running, "User requested continue");
79
+ // Resolve the pause promise to unblock execution
80
+ if (this.pauseResolver) {
81
+ this.pauseResolver();
82
+ this.pauseResolver = null;
83
+ }
84
+ }
85
+ break;
86
+ case ExecutionCommand.Kill:
87
+ this.state = ExecutionState.Killed;
88
+ this.pushLog("error", "💀 Execution killed");
89
+ this.emitStateChanged(ExecutionState.Killed, "User requested kill");
90
+ // Unblock pause if paused
91
+ if (this.pauseResolver) {
92
+ this.pauseResolver();
93
+ this.pauseResolver = null;
94
+ }
95
+ this.cleanup();
96
+ break;
97
+ case ExecutionCommand.Skip:
98
+ this.pushLog("info", "⏭ Skipping current step");
99
+ // If paused, resume so the skip can take effect
100
+ if (this.state === ExecutionState.Paused) {
101
+ this.state = ExecutionState.Running;
102
+ if (this.pauseResolver) {
103
+ this.pauseResolver();
104
+ this.pauseResolver = null;
105
+ }
106
+ }
107
+ break;
108
+ case ExecutionCommand.Stop:
109
+ this.pushLog("info", "⏹ Stopping current task");
110
+ // If paused, resume so the stop can take effect
111
+ if (this.state === ExecutionState.Paused) {
112
+ this.state = ExecutionState.Running;
113
+ if (this.pauseResolver) {
114
+ this.pauseResolver();
115
+ this.pauseResolver = null;
116
+ }
117
+ }
118
+ break;
119
+ }
120
+ }
121
+ /** Get the current execution state */
122
+ getState() {
123
+ return this.state;
124
+ }
125
+ /** Kill execution and clean up all resources */
126
+ kill() {
127
+ this.sendCommand(ExecutionCommand.Kill);
128
+ }
129
+ /** Execute the entire program */
130
+ async execute() {
131
+ this.state = ExecutionState.Running;
132
+ this.pendingCommand = ExecutionCommand.Continue;
133
+ // Phase 1: Register all declarations (Vars, APIs) — no execution
134
+ const tasks = [];
135
+ for (const node of this.ast.body) {
136
+ switch (node.type) {
137
+ case NodeType.VarDeclaration:
138
+ await this.visitVarDeclaration(node);
139
+ break;
140
+ case NodeType.ApiBlock:
141
+ this.visitApiBlock(node);
142
+ break;
143
+ case NodeType.TaskBlock:
144
+ tasks.push(node);
145
+ break;
146
+ case NodeType.Comment:
147
+ // skip comments
148
+ break;
149
+ default:
150
+ break;
151
+ }
152
+ }
153
+ const plan = tasks.map(t => ({
154
+ title: t.title ?? null,
155
+ steps: t.steps.map(s => ({ targetName: s.targetName }))
156
+ }));
157
+ // Emit start event
158
+ await this.emit(InterpreterEvent.Start, {
159
+ totalTasks: tasks.length,
160
+ totalApis: this.apiRegistry.size,
161
+ totalVars: this.globalEnv.getOwnEntries().size,
162
+ startTime: new Date(),
163
+ plan,
164
+ });
165
+ this.pushLog("info", `Starting execution: ${tasks.length} tasks, ${this.apiRegistry.size} APIs, ${this.globalEnv.getOwnEntries().size} vars`);
166
+ // Phase 2: Execute tasks sequentially
167
+ const taskResults = [];
168
+ for (let i = 0; i < tasks.length; i++) {
169
+ // Check for kill before each task
170
+ if (this.isKilled()) {
171
+ this.pushLog("warn", `Skipping remaining tasks (killed)`);
172
+ break;
173
+ }
174
+ // Wait if paused
175
+ await this.waitIfPaused();
176
+ if (this.isKilled())
177
+ break;
178
+ const result = await this.visitTask(tasks[i], i);
179
+ taskResults.push(result);
180
+ }
181
+ // Emit completed event
182
+ if (!this.isKilled()) {
183
+ this.state = ExecutionState.Completed;
184
+ }
185
+ await this.emit(InterpreterEvent.Completed, {
186
+ endTime: new Date(),
187
+ taskResults,
188
+ });
189
+ this.pushLog("info", `Execution ${this.isKilled() ? "killed" : "completed"}`);
190
+ return taskResults;
191
+ }
192
+ // ── Var Declaration Visitor ─────────────────────────────────────────────
193
+ async visitVarDeclaration(node) {
194
+ // Store each key-value entry as a global variable
195
+ for (const entry of node.entries) {
196
+ let value = entry.value;
197
+ // Handle Input mapping
198
+ if (value.startsWith("Input.")) {
199
+ const fieldName = value.substring(6);
200
+ if (this.options.inputRow && fieldName in this.options.inputRow) {
201
+ value = this.options.inputRow[fieldName];
202
+ }
203
+ }
204
+ // Handle Decryption
205
+ if (entry.isEncrypted) {
206
+ const provider = this.options.decryptionProvider ?? this.defaultDecryptionProvider.bind(this);
207
+ value = await provider(value);
208
+ }
209
+ this.globalEnv.declare(entry.key, value);
210
+ }
211
+ // If the var has a pointer, store the pointer reference
212
+ if (node.pointer) {
213
+ this.globalEnv.declare(`@${node.pointer.target}`, node.title ?? null);
214
+ }
215
+ this.emitSync(InterpreterEvent.Log, {
216
+ level: "debug",
217
+ message: `Var declared: ${node.title ?? "(unnamed)"} with ${node.entries.length} entries`,
218
+ });
219
+ }
220
+ // ── API Block Visitor ───────────────────────────────────────────────────
221
+ visitApiBlock(node) {
222
+ const definition = {
223
+ method: node.method ?? "GET",
224
+ name: node.name,
225
+ title: node.title ?? null,
226
+ url: node.url?.value ?? null,
227
+ headers: node.headers?.entries ?? [],
228
+ bodyType: node.body?.bodyType ?? null,
229
+ bodyContent: node.body?.content ?? null,
230
+ };
231
+ this.apiRegistry.set(node.name, definition);
232
+ this.emitSync(InterpreterEvent.Log, {
233
+ level: "debug",
234
+ message: `API registered: ${definition.method} #${definition.name}`,
235
+ });
236
+ }
237
+ // ── Task Visitor ────────────────────────────────────────────────────────
238
+ async visitTask(node, taskIndex) {
239
+ const startTime = new Date();
240
+ await this.emit(InterpreterEvent.TaskBegin, {
241
+ title: node.title,
242
+ index: taskIndex,
243
+ });
244
+ this.pushLog("task", `Task ${taskIndex + 1}: "${node.title ?? "(unnamed)"}"`);
245
+ const stepResults = [];
246
+ const taskEnv = this.globalEnv.createChild();
247
+ let overallStatus = ExecutionStatus.Pass;
248
+ for (let i = 0; i < node.steps.length; i++) {
249
+ // Check kill
250
+ if (this.isKilled()) {
251
+ overallStatus = ExecutionStatus.Killed;
252
+ break;
253
+ }
254
+ // Wait if paused
255
+ await this.waitIfPaused();
256
+ if (this.isKilled()) {
257
+ overallStatus = ExecutionStatus.Killed;
258
+ break;
259
+ }
260
+ // Check stop (stop current task, skip remaining steps)
261
+ if (this.pendingCommand === ExecutionCommand.Stop) {
262
+ this.pushLog("info", `Stopping task — skipping step ${i + 1} and remaining`);
263
+ overallStatus = ExecutionStatus.Stopped;
264
+ this.pendingCommand = ExecutionCommand.Continue; // reset for next task
265
+ break;
266
+ }
267
+ // Check skip (skip this step, continue to next)
268
+ if (this.pendingCommand === ExecutionCommand.Skip) {
269
+ this.pushLog("info", `Skipping step ${i + 1}: ${node.steps[i].targetName}`);
270
+ stepResults.push({
271
+ targetName: node.steps[i].targetName,
272
+ status: ExecutionStatus.Skipped,
273
+ startTime: new Date(),
274
+ endTime: new Date(),
275
+ error: null,
276
+ response: null,
277
+ checksPassed: null,
278
+ });
279
+ this.pendingCommand = ExecutionCommand.Continue; // reset after skip
280
+ continue;
281
+ }
282
+ const stepResult = await this.visitStep(node.steps[i], i, taskIndex, taskEnv);
283
+ stepResults.push(stepResult);
284
+ if (stepResult.status === ExecutionStatus.Failed) {
285
+ overallStatus = ExecutionStatus.Failed;
286
+ if (!this.options.continueOnError) {
287
+ break;
288
+ }
289
+ }
290
+ }
291
+ const result = {
292
+ title: node.title,
293
+ status: overallStatus,
294
+ startTime,
295
+ endTime: new Date(),
296
+ steps: stepResults,
297
+ };
298
+ await this.emit(InterpreterEvent.TaskDone, result);
299
+ const taskIcon = overallStatus === ExecutionStatus.Pass ? "✅" : "❌";
300
+ this.pushLog("task", `${taskIcon} Task "${node.title ?? "(unnamed)"}" → ${overallStatus}`);
301
+ return result;
302
+ }
303
+ // ── Step Visitor ────────────────────────────────────────────────────────
304
+ async visitStep(node, stepIndex, taskIndex, taskEnv) {
305
+ const startTime = new Date();
306
+ await this.emit(InterpreterEvent.StepBegin, {
307
+ targetName: node.targetName,
308
+ index: stepIndex,
309
+ taskIndex,
310
+ });
311
+ this.pushLog("step", `Step ${stepIndex + 1}: Call ${node.targetType} ${node.targetName}`);
312
+ const stepEnv = taskEnv.createChild();
313
+ let status = ExecutionStatus.Pass;
314
+ let error = null;
315
+ let apiResponse = null;
316
+ let checksPassed = null;
317
+ try {
318
+ // Look up the API definition
319
+ const apiDef = this.apiRegistry.get(node.targetName);
320
+ if (!apiDef) {
321
+ throw new ApiNotFoundError(node.targetName, node.position.line, node.position.column);
322
+ }
323
+ // Resolve params into step environment
324
+ if (node.params) {
325
+ this.resolveParams(node.params.entries, stepEnv, taskEnv);
326
+ }
327
+ // Make the API call
328
+ const response = await this.callApi(apiDef, stepEnv, taskEnv);
329
+ apiResponse = {
330
+ status: response.status,
331
+ headers: response.headers,
332
+ body: response.data,
333
+ };
334
+ // Store response metadata in step environment
335
+ stepEnv.declare("$.status", response.status);
336
+ stepEnv.declare("$.body", response.data ?? null);
337
+ // Process capture
338
+ if (node.capture) {
339
+ this.processCapture(node.capture.entries, response, stepIndex, taskEnv);
340
+ }
341
+ // Process check
342
+ if (node.check) {
343
+ checksPassed = this.processCheck(node.check.conditions, stepEnv, taskEnv);
344
+ if (!checksPassed) {
345
+ status = ExecutionStatus.Failed;
346
+ error = "Check validation failed";
347
+ }
348
+ }
349
+ }
350
+ catch (err) {
351
+ status = ExecutionStatus.Failed;
352
+ if (err instanceof RuntimeError) {
353
+ error = err.message;
354
+ }
355
+ else {
356
+ error = err instanceof Error ? err.message : String(err);
357
+ }
358
+ await this.emit(InterpreterEvent.Error, {
359
+ message: error ?? "Unknown error",
360
+ line: node.position.line,
361
+ column: node.position.column,
362
+ });
363
+ this.pushLog("error", error ?? "Unknown error");
364
+ }
365
+ const result = {
366
+ targetName: node.targetName,
367
+ status,
368
+ startTime,
369
+ endTime: new Date(),
370
+ error,
371
+ response: apiResponse,
372
+ checksPassed,
373
+ };
374
+ await this.emit(InterpreterEvent.StepDone, { ...result, taskIndex, index: stepIndex });
375
+ const stepIcon = status === ExecutionStatus.Pass ? "✅" : "❌";
376
+ this.pushLog("step", `${stepIcon} ${node.targetName} → ${status}`);
377
+ return result;
378
+ }
379
+ // ── API Call ────────────────────────────────────────────────────────────
380
+ async callApi(apiDef, stepEnv, taskEnv) {
381
+ const allVars = taskEnv.getAllEntries();
382
+ // Merge step env on top
383
+ for (const [k, v] of stepEnv.getOwnEntries()) {
384
+ allVars.set(k, v);
385
+ }
386
+ // Resolve URL with interpolation
387
+ const url = this.interpolate(apiDef.url ?? "", allVars);
388
+ // Resolve headers
389
+ const headers = {};
390
+ for (const entry of apiDef.headers) {
391
+ headers[entry.key] = this.interpolate(entry.value, allVars);
392
+ }
393
+ // Resolve body
394
+ let data;
395
+ if (apiDef.bodyContent) {
396
+ data = this.interpolate(apiDef.bodyContent, allVars);
397
+ }
398
+ await this.emit(InterpreterEvent.ApiCallBegin, {
399
+ method: apiDef.method,
400
+ url,
401
+ apiName: apiDef.name,
402
+ });
403
+ this.pushLog("api", `${apiDef.method} ${url}`);
404
+ if (this.options.dryRun) {
405
+ await this.emit(InterpreterEvent.Log, {
406
+ level: "info",
407
+ message: `[DRY RUN] ${apiDef.method} ${url}`,
408
+ });
409
+ // Return a mock response for dry run
410
+ return {
411
+ status: 200,
412
+ data: { dryRun: true },
413
+ headers: {},
414
+ };
415
+ }
416
+ const callStart = Date.now();
417
+ // Create abort controller for the request timeout
418
+ const controller = new AbortController();
419
+ const timeoutId = setTimeout(() => controller.abort(), this.options.apiTimeout);
420
+ try {
421
+ const response = await fetch(url, {
422
+ method: apiDef.method.toUpperCase(),
423
+ headers,
424
+ body: data,
425
+ signal: controller.signal,
426
+ });
427
+ const responseHeaders = {};
428
+ response.headers.forEach((val, key) => {
429
+ responseHeaders[key] = val;
430
+ });
431
+ let responseData = null;
432
+ const text = await response.text();
433
+ const contentType = response.headers.get("content-type") || "";
434
+ if (contentType.includes("application/json")) {
435
+ try {
436
+ responseData = JSON.parse(text);
437
+ }
438
+ catch {
439
+ responseData = text;
440
+ }
441
+ }
442
+ else {
443
+ responseData = text;
444
+ }
445
+ const duration = Date.now() - callStart;
446
+ await this.emit(InterpreterEvent.ApiCallDone, {
447
+ apiName: apiDef.name,
448
+ status: response.status,
449
+ duration,
450
+ });
451
+ this.pushLog("api", `${apiDef.name} → ${response.status} (${duration}ms)`);
452
+ return {
453
+ status: response.status,
454
+ data: responseData,
455
+ headers: responseHeaders,
456
+ };
457
+ }
458
+ finally {
459
+ clearTimeout(timeoutId);
460
+ }
461
+ }
462
+ // ── Params Resolution ───────────────────────────────────────────────────
463
+ resolveParams(entries, stepEnv, taskEnv) {
464
+ for (const entry of entries) {
465
+ // Check if the value references another variable (e.g., "Step.1.id")
466
+ const resolved = taskEnv.tryLookup(entry.value);
467
+ stepEnv.declare(entry.key, resolved !== undefined ? resolved : entry.value);
468
+ }
469
+ }
470
+ // ── Capture Processing ──────────────────────────────────────────────────
471
+ processCapture(entries, response, stepIndex, taskEnv) {
472
+ for (const entry of entries) {
473
+ const value = this.resolveResponsePath(response.data, entry.value);
474
+ // Store as Step.<1-based index>.<key>
475
+ taskEnv.declare(`Step.${stepIndex + 1}.${entry.key}`, value);
476
+ }
477
+ }
478
+ // ── Check Processing ────────────────────────────────────────────────────
479
+ processCheck(conditions, stepEnv, taskEnv) {
480
+ for (const condition of conditions) {
481
+ const result = this.evaluateCondition(condition, stepEnv, taskEnv);
482
+ if (!result)
483
+ return false;
484
+ }
485
+ return true;
486
+ }
487
+ evaluateCondition(condition, stepEnv, taskEnv) {
488
+ const lhs = this.resolveValue(condition.lhs, stepEnv, taskEnv);
489
+ const rhs = this.resolveValue(condition.rhs, stepEnv, taskEnv);
490
+ let result = this.compareValues(lhs, condition.operator, rhs);
491
+ // Process OR chains — any one passing makes the condition pass
492
+ if (!result && condition.orConditions.length > 0) {
493
+ for (const orExpr of condition.orConditions) {
494
+ const orLhs = this.resolveValue(orExpr.lhs, stepEnv, taskEnv);
495
+ const orRhs = this.resolveValue(orExpr.rhs, stepEnv, taskEnv);
496
+ if (this.compareValues(orLhs, orExpr.operator, orRhs)) {
497
+ result = true;
498
+ break;
499
+ }
500
+ }
501
+ }
502
+ return result;
503
+ }
504
+ compareValues(lhs, operator, rhs) {
505
+ // Coerce to numbers if both are numeric
506
+ const numLhs = Number(lhs);
507
+ const numRhs = Number(rhs);
508
+ const useNumbers = !isNaN(numLhs) && !isNaN(numRhs);
509
+ switch (operator) {
510
+ case "==":
511
+ return useNumbers ? numLhs === numRhs : String(lhs) === String(rhs);
512
+ case "!=":
513
+ return useNumbers ? numLhs !== numRhs : String(lhs) !== String(rhs);
514
+ case ">":
515
+ return useNumbers ? numLhs > numRhs : String(lhs) > String(rhs);
516
+ case "<":
517
+ return useNumbers ? numLhs < numRhs : String(lhs) < String(rhs);
518
+ case ">=":
519
+ return useNumbers ? numLhs >= numRhs : String(lhs) >= String(rhs);
520
+ case "<=":
521
+ return useNumbers ? numLhs <= numRhs : String(lhs) <= String(rhs);
522
+ default:
523
+ return false;
524
+ }
525
+ }
526
+ // ── Value Resolution ────────────────────────────────────────────────────
527
+ resolveValue(raw, stepEnv, taskEnv) {
528
+ // Special keyword: null
529
+ if (raw === "null")
530
+ return null;
531
+ // Special keyword: true/false
532
+ if (raw === "true")
533
+ return true;
534
+ if (raw === "false")
535
+ return false;
536
+ // Try step env first (for $.status, $.body)
537
+ const stepVal = stepEnv.tryLookup(raw);
538
+ if (stepVal !== undefined)
539
+ return stepVal;
540
+ // Try task env (for Step.1.id etc.)
541
+ const taskVal = taskEnv.tryLookup(raw);
542
+ if (taskVal !== undefined)
543
+ return taskVal;
544
+ // Try global env
545
+ const globalVal = this.globalEnv.tryLookup(raw);
546
+ if (globalVal !== undefined)
547
+ return globalVal;
548
+ // If numeric, return as number
549
+ const num = Number(raw);
550
+ if (!isNaN(num))
551
+ return num;
552
+ // Return as raw string
553
+ return raw;
554
+ }
555
+ /**
556
+ * Resolve a dot-separated path from response data.
557
+ * e.g., "response.id" → data.id
558
+ */
559
+ resolveResponsePath(data, path) {
560
+ // Strip "response." prefix if present
561
+ const cleanPath = path.startsWith("response.")
562
+ ? path.substring("response.".length)
563
+ : path;
564
+ const parts = cleanPath.split(".");
565
+ let current = data;
566
+ for (const part of parts) {
567
+ if (current === null || current === undefined)
568
+ return null;
569
+ if (typeof current === "object") {
570
+ current = current[part];
571
+ }
572
+ else {
573
+ return null;
574
+ }
575
+ }
576
+ return current ?? null;
577
+ }
578
+ // ── Interpolation ──────────────────────────────────────────────────────
579
+ /**
580
+ * Replace {{varName}} placeholders with values from the variable map.
581
+ */
582
+ interpolate(template, vars) {
583
+ return template.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_match, varName) => {
584
+ const value = vars.get(varName);
585
+ if (value !== undefined && value !== null) {
586
+ return String(value);
587
+ }
588
+ // Try dot-path lookup
589
+ const parts = varName.split(".");
590
+ if (parts.length > 1) {
591
+ const rootVal = vars.get(parts[0]);
592
+ if (rootVal !== undefined && rootVal !== null) {
593
+ return String(this.resolveResponsePath(rootVal, parts.slice(1).join(".")));
594
+ }
595
+ }
596
+ return `{{${varName}}}`; // leave unresolved
597
+ });
598
+ }
599
+ // ── Event Emission ─────────────────────────────────────────────────────
600
+ async emit(event, payload) {
601
+ const eventListeners = this.listeners.get(event);
602
+ if (!eventListeners)
603
+ return;
604
+ for (const listener of eventListeners) {
605
+ await listener(payload);
606
+ }
607
+ }
608
+ emitSync(event, payload) {
609
+ const eventListeners = this.listeners.get(event);
610
+ if (!eventListeners)
611
+ return;
612
+ for (const listener of eventListeners) {
613
+ listener(payload);
614
+ }
615
+ }
616
+ // ── Decryption Provider ──────────────────────────────────────────────────
617
+ defaultDecryptionProvider(encrypted) {
618
+ const isNode = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
619
+ if (isNode) {
620
+ return Buffer.from(encrypted, "base64").toString("utf-8");
621
+ }
622
+ return atob(encrypted);
623
+ }
624
+ // ── Adapter Logging ─────────────────────────────────────────────────────
625
+ /** Push a log line through the IO adapter (if set and supports logging) */
626
+ pushLog(level, message) {
627
+ if (this.ioAdapter?.log) {
628
+ this.ioAdapter.log(level, message);
629
+ }
630
+ }
631
+ // ── Execution Control ───────────────────────────────────────────────────
632
+ /** Block execution while state is Paused */
633
+ waitIfPaused() {
634
+ if (this.state !== ExecutionState.Paused) {
635
+ return Promise.resolve();
636
+ }
637
+ return new Promise((resolve) => {
638
+ this.pauseResolver = resolve;
639
+ });
640
+ }
641
+ /** Emit a state change event */
642
+ emitStateChanged(newState, reason) {
643
+ this.emit(InterpreterEvent.StateChanged, { state: newState, reason });
644
+ }
645
+ /**
646
+ * Clean up all resources.
647
+ * Called on kill — clears environment, registry, listeners, adapter.
648
+ */
649
+ cleanup() {
650
+ this.pushLog("info", "🧹 Cleaning up resources...");
651
+ // Clear environment
652
+ for (const [key] of this.globalEnv.getOwnEntries()) {
653
+ this.globalEnv.assign(key, null);
654
+ }
655
+ // Clear API registry
656
+ this.apiRegistry.clear();
657
+ // Clear event listeners
658
+ this.listeners.clear();
659
+ // Dispose adapter
660
+ if (this.ioAdapter?.dispose) {
661
+ this.ioAdapter.dispose();
662
+ }
663
+ this.pushLog("info", "✅ Cleanup complete");
664
+ }
665
+ // ── Public Accessors ───────────────────────────────────────────────────
666
+ /** Get the global environment (for inspection/testing) */
667
+ getEnvironment() {
668
+ return this.globalEnv;
669
+ }
670
+ /** Get the API registry (for inspection/testing) */
671
+ getApiRegistry() {
672
+ return this.apiRegistry;
673
+ }
674
+ /** Prompt the user for input via the IOAdapter */
675
+ async promptUser(message) {
676
+ if (!this.ioAdapter) {
677
+ throw new RuntimeError("No IO adapter set — cannot prompt user");
678
+ }
679
+ await this.emit(InterpreterEvent.InputRequired, { prompt: message });
680
+ return this.ioAdapter.prompt(message);
681
+ }
682
+ }