diff --git a/packages/engine/src/__tests__/expressions/cache.test.ts b/packages/engine/src/__tests__/expressions/cache.test.ts new file mode 100644 index 0000000..d89c5e9 --- /dev/null +++ b/packages/engine/src/__tests__/expressions/cache.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest' +import { LRUCache } from '../../expressions/cache.js' + +describe('LRUCache', () => { + it('stores and retrieves values', () => { + const cache = new LRUCache(10) + cache.set('a', 1) + cache.set('b', 2) + expect(cache.get('a')).toBe(1) + expect(cache.get('b')).toBe(2) + }) + + it('returns undefined for missing keys', () => { + const cache = new LRUCache(10) + expect(cache.get('missing')).toBeUndefined() + }) + + it('reports correct size', () => { + const cache = new LRUCache(10) + expect(cache.size).toBe(0) + cache.set('a', 1) + expect(cache.size).toBe(1) + cache.set('b', 2) + expect(cache.size).toBe(2) + }) + + it('evicts least recently used when at capacity', () => { + const cache = new LRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + // At capacity — adding 'd' should evict 'a' (oldest) + cache.set('d', 4) + expect(cache.size).toBe(3) + expect(cache.get('a')).toBeUndefined() + expect(cache.get('b')).toBe(2) + expect(cache.get('c')).toBe(3) + expect(cache.get('d')).toBe(4) + }) + + it('get() makes entry recently used (not evicted next)', () => { + const cache = new LRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + // Access 'a' to make it recently used + cache.get('a') + // Adding 'd' should evict 'b' (now the oldest), not 'a' + cache.set('d', 4) + expect(cache.get('a')).toBe(1) + expect(cache.get('b')).toBeUndefined() + expect(cache.get('c')).toBe(3) + expect(cache.get('d')).toBe(4) + }) + + it('updating an existing key moves it to recently used', () => { + const cache = new LRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + // Update 'a' with new value + cache.set('a', 10) + // Adding 'd' should evict 'b' (now the oldest) + cache.set('d', 4) + expect(cache.get('a')).toBe(10) + expect(cache.get('b')).toBeUndefined() + }) + + it('has() returns correct presence without affecting order', () => { + const cache = new LRUCache(3) + cache.set('a', 1) + expect(cache.has('a')).toBe(true) + expect(cache.has('b')).toBe(false) + }) + + it('clear() empties the cache', () => { + const cache = new LRUCache(10) + cache.set('a', 1) + cache.set('b', 2) + cache.clear() + expect(cache.size).toBe(0) + expect(cache.get('a')).toBeUndefined() + }) + + it('throws on capacity < 1', () => { + expect(() => new LRUCache(0)).toThrow('capacity must be at least 1') + expect(() => new LRUCache(-5)).toThrow('capacity must be at least 1') + }) + + it('works with capacity of 1', () => { + const cache = new LRUCache(1) + cache.set('a', 1) + expect(cache.get('a')).toBe(1) + cache.set('b', 2) + expect(cache.get('a')).toBeUndefined() + expect(cache.get('b')).toBe(2) + }) + + it('evicts correctly at capacity 1000', () => { + const cache = new LRUCache(1000) + // Fill to capacity + for (let i = 0; i < 1000; i++) { + cache.set(i, i * 10) + } + expect(cache.size).toBe(1000) + // Adding one more should evict key 0 + cache.set(1000, 10000) + expect(cache.size).toBe(1000) + expect(cache.get(0)).toBeUndefined() + expect(cache.get(1)).toBe(10) + expect(cache.get(1000)).toBe(10000) + }) +}) diff --git a/packages/engine/src/__tests__/expressions/interpreter.test.ts b/packages/engine/src/__tests__/expressions/interpreter.test.ts new file mode 100644 index 0000000..85e280d --- /dev/null +++ b/packages/engine/src/__tests__/expressions/interpreter.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from 'vitest' +import { interpretExpression, InterpreterError } from '../../expressions/interpreter.js' +import type { InterpreterContext } from '../../expressions/interpreter.js' + +function makeCtx( + input: unknown = {}, + results: Record = {}, +): InterpreterContext { + return { + input, + results: new Map(Object.entries(results)), + } +} + +describe('interpretExpression', () => { + describe('arithmetic', () => { + it('evaluates 2 + 3 to 5', () => { + const ctx = makeCtx() + expect(interpretExpression('2 + 3', ctx)).toBe(5) + }) + + it('evaluates input.qty * input.price', () => { + const ctx = makeCtx({ qty: 5, price: 10 }) + expect(interpretExpression('input.qty * input.price', ctx)).toBe(50) + }) + + it('evaluates 10 / 3 to floating point', () => { + const ctx = makeCtx() + const result = interpretExpression('10 / 3', ctx) + expect(typeof result).toBe('number') + expect(result).toBeCloseTo(3.3333, 3) + }) + + it('evaluates 10 % 3 to 1', () => { + const ctx = makeCtx() + expect(interpretExpression('10 % 3', ctx)).toBe(1) + }) + + it('division by zero yields Infinity', () => { + const ctx = makeCtx() + expect(interpretExpression('1 / 0', ctx)).toBe(Infinity) + }) + + it('negative division by zero yields -Infinity', () => { + const ctx = makeCtx({ n: -1 }) + expect(interpretExpression('input.n / 0', ctx)).toBe(-Infinity) + }) + + it('preserves operator precedence (2 + 3 * 4 = 14)', () => { + const ctx = makeCtx() + expect(interpretExpression('2 + 3 * 4', ctx)).toBe(14) + }) + + it('evaluates subtraction', () => { + const ctx = makeCtx() + expect(interpretExpression('10 - 3', ctx)).toBe(7) + }) + + it('evaluates string concatenation with +', () => { + const ctx = makeCtx({ name: 'Alice' }) + expect(interpretExpression("'Hello ' + input.name", ctx)).toBe('Hello Alice') + }) + }) + + describe('mixed arithmetic + comparison', () => { + it('evaluates input.qty * input.price > 1000', () => { + const ctx = makeCtx({ qty: 5, price: 300 }) + expect(interpretExpression('input.qty * input.price > 1000', ctx)).toBe(true) + }) + + it('evaluates input.qty * input.price > 1000 (false)', () => { + const ctx = makeCtx({ qty: 2, price: 100 }) + expect(interpretExpression('input.qty * input.price > 1000', ctx)).toBe(false) + }) + + it('evaluates total % 2 === 0 (even check)', () => { + const ctx = makeCtx({ total: 42 }) + expect(interpretExpression('input.total % 2 === 0', ctx)).toBe(true) + }) + }) + + describe('comparison operators', () => { + it('evaluates strict equality', () => { + const ctx = makeCtx({ status: 'active' }) + expect(interpretExpression("input.status === 'active'", ctx)).toBe(true) + }) + + it('evaluates strict inequality', () => { + const ctx = makeCtx({ value: 5 }) + expect(interpretExpression('input.value !== 10', ctx)).toBe(true) + }) + + it('evaluates greater than', () => { + const ctx = makeCtx({ score: 85 }) + expect(interpretExpression('input.score > 50', ctx)).toBe(true) + }) + + it('evaluates less than or equal', () => { + const ctx = makeCtx({ value: 100 }) + expect(interpretExpression('input.value <= 100', ctx)).toBe(true) + }) + }) + + describe('logical operators', () => { + it('evaluates logical AND', () => { + const ctx = makeCtx({ a: true, b: false }) + expect(interpretExpression('input.a && input.b', ctx)).toBe(false) + }) + + it('evaluates logical OR', () => { + const ctx = makeCtx({ a: false, b: true }) + expect(interpretExpression('input.a || input.b', ctx)).toBe(true) + }) + }) + + describe('unary operators', () => { + it('evaluates logical NOT', () => { + const ctx = makeCtx({ cancelled: false }) + expect(interpretExpression('!input.cancelled', ctx)).toBe(true) + }) + + it('evaluates typeof', () => { + const ctx = makeCtx({ value: 42 }) + expect(interpretExpression("typeof input.value === 'number'", ctx)).toBe(true) + }) + }) + + describe('ternary / conditional', () => { + it('evaluates conditional expression (truthy)', () => { + const ctx = makeCtx({ vip: true }) + expect(interpretExpression("input.vip ? 'fast' : 'normal'", ctx)).toBe('fast') + }) + + it('evaluates conditional expression (falsy)', () => { + const ctx = makeCtx({ vip: false }) + expect(interpretExpression("input.vip ? 'fast' : 'normal'", ctx)).toBe('normal') + }) + }) + + describe('template literals', () => { + it('evaluates template literal with interpolation', () => { + const ctx = makeCtx({ status: 'active' }) + expect(interpretExpression('`Status: ${input.status}`', ctx)).toBe('Status: active') + }) + }) + + describe('method calls', () => { + it('evaluates includes()', () => { + const ctx = makeCtx({ name: 'hello world' }) + expect(interpretExpression("input.name.includes('world')", ctx)).toBe(true) + }) + + it('evaluates startsWith()', () => { + const ctx = makeCtx({ name: 'hello' }) + expect(interpretExpression("input.name.startsWith('hel')", ctx)).toBe(true) + }) + + it('evaluates trim()', () => { + const ctx = makeCtx({ value: ' spaced ' }) + expect(interpretExpression('input.value.trim()', ctx)).toBe('spaced') + }) + }) + + describe('Math functions', () => { + it('evaluates Math.abs()', () => { + const ctx = makeCtx({ diff: -42 }) + expect(interpretExpression('Math.abs(input.diff)', ctx)).toBe(42) + }) + + it('evaluates Math.max()', () => { + const ctx = makeCtx({ a: 3, b: 7 }) + expect(interpretExpression('Math.max(input.a, input.b)', ctx)).toBe(7) + }) + + it('evaluates Math.floor()', () => { + const ctx = makeCtx() + expect(interpretExpression('Math.floor(3.7)', ctx)).toBe(3) + }) + + it('accesses Math.PI', () => { + const ctx = makeCtx() + expect(interpretExpression('Math.PI', ctx)).toBeCloseTo(Math.PI, 10) + }) + }) + + describe('node results', () => { + it('accesses previous node result', () => { + const ctx = makeCtx({}, { validate_order: { isValid: true, total: 150 } }) + expect(interpretExpression('validate_order.isValid', ctx)).toBe(true) + expect(interpretExpression('validate_order.total', ctx)).toBe(150) + }) + + it('uses node results in arithmetic', () => { + const ctx = makeCtx( + { taxRate: 0.1 }, + { calc: { subtotal: 100 } }, + ) + expect(interpretExpression('calc.subtotal * input.taxRate', ctx)).toBe(10) + }) + }) + + describe('error handling', () => { + it('throws InterpreterError for unknown identifier', () => { + const ctx = makeCtx() + expect(() => interpretExpression('unknown.value', ctx)).toThrow(InterpreterError) + }) + + it('throws on property access on null', () => { + const ctx = makeCtx({ value: null }) + expect(() => interpretExpression('input.value.x', ctx)).toThrow(InterpreterError) + }) + + it('throws on disallowed method', () => { + const ctx = makeCtx({ items: [1, 2] }) + expect(() => interpretExpression('input.items.forEach()', ctx)).toThrow() + }) + }) +}) diff --git a/packages/engine/src/__tests__/expressions/parse-cache.test.ts b/packages/engine/src/__tests__/expressions/parse-cache.test.ts new file mode 100644 index 0000000..ec5b839 --- /dev/null +++ b/packages/engine/src/__tests__/expressions/parse-cache.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { parseExpression, clearParseCache } from '../../expressions/parser.js' + +describe('expression parse cache', () => { + beforeEach(() => { + clearParseCache() + }) + + it('returns identical result objects for the same expression', () => { + const result1 = parseExpression('input.x > 0') + const result2 = parseExpression('input.x > 0') + // Cached results are the same object reference + expect(result1).toBe(result2) + }) + + it('caches successful parse results', () => { + const result1 = parseExpression("input.priority === 'rush'") + expect(result1.success).toBe(true) + const result2 = parseExpression("input.priority === 'rush'") + expect(result2).toBe(result1) + }) + + it('caches failed parse results', () => { + const result1 = parseExpression('function foo() {}') + expect(result1.success).toBe(false) + const result2 = parseExpression('function foo() {}') + expect(result2).toBe(result1) + }) + + it('caches empty expression errors', () => { + const result1 = parseExpression('') + expect(result1.success).toBe(false) + const result2 = parseExpression('') + expect(result2).toBe(result1) + }) + + it('clearParseCache() resets the cache', () => { + const result1 = parseExpression('input.x > 0') + clearParseCache() + const result2 = parseExpression('input.x > 0') + // After clearing, result is a new object (not the same reference) + expect(result2).not.toBe(result1) + // But structurally equivalent + expect(result2).toEqual(result1) + }) + + it('different expressions have different cache entries', () => { + const result1 = parseExpression('input.a > 0') + const result2 = parseExpression('input.b > 0') + expect(result1).not.toBe(result2) + if (result1.success && result2.success) { + expect(result1.expression.memberPaths).toContain('input.a') + expect(result2.expression.memberPaths).toContain('input.b') + } + }) +}) diff --git a/packages/engine/src/__tests__/expressions/parser-security.test.ts b/packages/engine/src/__tests__/expressions/parser-security.test.ts index 6c23ffe..a2bd97d 100644 --- a/packages/engine/src/__tests__/expressions/parser-security.test.ts +++ b/packages/engine/src/__tests__/expressions/parser-security.test.ts @@ -2,15 +2,11 @@ import { describe, it, expect } from 'vitest' import { parseExpression } from '../../expressions/parser.js' describe('parser security', () => { - it('rejects arithmetic operators', () => { - // ALLOWED_BINARY_OPS only includes ===, !==, >, <, >=, <= - // Arithmetic operators (+, -, *, /, %) are NOT in the allowlist + it('allows arithmetic operators', () => { + // Arithmetic operators (+, -, *, /, %) are in the allowlist for (const op of ['+', '-', '*', '/', '%']) { const result = parseExpression(`input.a ${op} input.b`) - expect(result.success).toBe(false) - if (!result.success) { - expect(result.errors[0]?.message).toContain('Disallowed binary operator') - } + expect(result.success).toBe(true) } }) diff --git a/packages/engine/src/__tests__/expressions/parser.test.ts b/packages/engine/src/__tests__/expressions/parser.test.ts index e5fbd69..b8e151b 100644 --- a/packages/engine/src/__tests__/expressions/parser.test.ts +++ b/packages/engine/src/__tests__/expressions/parser.test.ts @@ -91,6 +91,55 @@ describe('parseExpression', () => { }) }) + describe('arithmetic expressions', () => { + it('parses simple addition', () => { + const result = parseExpression('2 + 3') + expect(result.success).toBe(true) + }) + + it('parses member access with multiplication', () => { + const result = parseExpression('input.qty * input.price') + expect(result.success).toBe(true) + if (result.success) { + expect(result.expression.identifiers).toContain('input') + expect(result.expression.memberPaths).toContain('input.qty') + expect(result.expression.memberPaths).toContain('input.price') + } + }) + + it('parses modulo operator', () => { + const result = parseExpression('total % 100') + expect(result.success).toBe(true) + if (result.success) { + expect(result.expression.identifiers).toContain('total') + } + }) + + it('parses subtraction', () => { + const result = parseExpression('input.a - input.b') + expect(result.success).toBe(true) + }) + + it('parses division', () => { + const result = parseExpression('input.total / input.count') + expect(result.success).toBe(true) + }) + + it('preserves operator precedence (2 + 3 * 4)', () => { + // Acorn parses with correct JS precedence: 2 + (3 * 4) = 14 + const result = parseExpression('2 + 3 * 4') + expect(result.success).toBe(true) + }) + + it('parses mixed arithmetic and comparison', () => { + const result = parseExpression('input.qty * input.price > 1000') + expect(result.success).toBe(true) + if (result.success) { + expect(result.expression.identifiers).toContain('input') + } + }) + }) + describe('invalid expressions', () => { it('rejects assignment', () => { const result = parseExpression('x = 5') diff --git a/packages/engine/src/expressions/allowlist.ts b/packages/engine/src/expressions/allowlist.ts index 4549b9e..66d1b05 100644 --- a/packages/engine/src/expressions/allowlist.ts +++ b/packages/engine/src/expressions/allowlist.ts @@ -11,7 +11,19 @@ export const ALLOWED_AST_TYPES = new Set([ 'Literal', ]) -export const ALLOWED_BINARY_OPS = new Set(['===', '!==', '>', '<', '>=', '<=']) +export const ALLOWED_BINARY_OPS = new Set([ + '===', + '!==', + '>', + '<', + '>=', + '<=', + '+', + '-', + '*', + '/', + '%', +]) export const ALLOWED_LOGICAL_OPS = new Set(['&&', '||']) export const ALLOWED_UNARY_OPS = new Set(['!', 'typeof']) diff --git a/packages/engine/src/expressions/cache.ts b/packages/engine/src/expressions/cache.ts new file mode 100644 index 0000000..4c5cbf7 --- /dev/null +++ b/packages/engine/src/expressions/cache.ts @@ -0,0 +1,55 @@ +/** + * Minimal LRU (Least Recently Used) cache. + * + * Uses a Map's insertion-order iteration to track recency. + * On get: deletes and re-inserts the entry to move it to the end (most recent). + * On set at capacity: deletes the first entry (least recently used). + * + * Zero external dependencies. + */ +export class LRUCache { + private readonly map = new Map() + readonly capacity: number + + constructor(capacity: number) { + if (capacity < 1) { + throw new Error('LRU cache capacity must be at least 1') + } + this.capacity = capacity + } + + get(key: K): V | undefined { + if (!this.map.has(key)) { + return undefined + } + // Move to end (most recently used) by delete + re-insert + const value = this.map.get(key)! + this.map.delete(key) + this.map.set(key, value) + return value + } + + set(key: K, value: V): void { + if (this.map.has(key)) { + // Update existing: delete + re-insert to move to end + this.map.delete(key) + } else if (this.map.size >= this.capacity) { + // Evict least recently used (first entry in Map iteration order) + const oldest = this.map.keys().next().value as K + this.map.delete(oldest) + } + this.map.set(key, value) + } + + has(key: K): boolean { + return this.map.has(key) + } + + get size(): number { + return this.map.size + } + + clear(): void { + this.map.clear() + } +} diff --git a/packages/engine/src/expressions/index.ts b/packages/engine/src/expressions/index.ts index 61348dd..1af244a 100644 --- a/packages/engine/src/expressions/index.ts +++ b/packages/engine/src/expressions/index.ts @@ -1,5 +1,8 @@ -export { parseExpression } from './parser.js' +export { parseExpression, clearParseCache } from './parser.js' export { validateExpressions } from './validator.js' +export { interpretExpression, InterpreterError } from './interpreter.js' +export type { InterpreterContext } from './interpreter.js' +export { LRUCache } from './cache.js' export type { ParseResult, ExpressionError, ParsedExpression } from './types.js' export type { ExpressionValidationResult, ExpressionValidationError } from './validator.js' export { diff --git a/packages/engine/src/expressions/interpreter.ts b/packages/engine/src/expressions/interpreter.ts new file mode 100644 index 0000000..ad8f673 --- /dev/null +++ b/packages/engine/src/expressions/interpreter.ts @@ -0,0 +1,229 @@ +/** + * Browser-safe AST interpreter for flowprint expressions. + * + * Walks the acorn AST and evaluates expressions without `node:vm`. + * Only supports the allowlisted AST types and operators. + */ +import * as acorn from 'acorn' +import { + ALLOWED_BINARY_OPS, + ALLOWED_LOGICAL_OPS, + ALLOWED_UNARY_OPS, + ALLOWED_METHODS, + ALLOWED_MATH_MEMBERS, +} from './allowlist.js' + +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +export class InterpreterError extends Error { + constructor(message: string) { + super(message) + this.name = 'InterpreterError' + } +} + +/** + * Build a frozen Math object with only allowlisted methods/constants. + */ +function buildSafeMath(): Readonly> { + const safeMath: Record = {} + for (const key of ALLOWED_MATH_MEMBERS) { + safeMath[key] = Math[key as keyof typeof Math] + } + return Object.freeze(safeMath) +} + +const SAFE_MATH = buildSafeMath() + +export interface InterpreterContext { + /** Workflow input */ + input: unknown + /** Previous node results keyed by node ID */ + results: Map +} + +/** + * Interpret a flowprint expression by parsing and walking the AST. + * + * Browser-safe: no `node:vm` dependency. Uses only the allowlisted + * operators and methods from the expression allowlist. + * + * @param source - The expression source string + * @param context - Execution context with input and node results + * @returns The evaluated result + */ +export function interpretExpression(source: string, context: InterpreterContext): unknown { + const ast = acorn.parseExpressionAt(source, 0, { ecmaVersion: 2022 }) + return evaluate(ast as any, context) +} + +function evaluate(node: any, ctx: InterpreterContext): unknown { + switch (node.type as string) { + case 'Literal': + return node.value + + case 'TemplateLiteral': + return evaluateTemplateLiteral(node, ctx) + + case 'Identifier': + return resolveIdentifier(node.name as string, ctx) + + case 'MemberExpression': + return evaluateMemberExpression(node, ctx) + + case 'BinaryExpression': + return evaluateBinaryExpression(node, ctx) + + case 'LogicalExpression': + return evaluateLogicalExpression(node, ctx) + + case 'UnaryExpression': + return evaluateUnaryExpression(node, ctx) + + case 'ConditionalExpression': + return evaluate(node.test, ctx) ? evaluate(node.consequent, ctx) : evaluate(node.alternate, ctx) + + case 'CallExpression': + return evaluateCallExpression(node, ctx) + + default: + throw new InterpreterError(`Unsupported AST node type: ${node.type as string}`) + } +} + +function resolveIdentifier(name: string, ctx: InterpreterContext): unknown { + if (name === 'input') return ctx.input + if (name === 'Math') return SAFE_MATH + if (ctx.results.has(name)) return ctx.results.get(name) + throw new InterpreterError(`Unknown identifier: ${name}`) +} + +function evaluateMemberExpression(node: any, ctx: InterpreterContext): unknown { + const object = evaluate(node.object, ctx) + + let property: string + if (node.computed) { + property = String(evaluate(node.property, ctx)) + } else { + property = node.property.name as string + } + + if (object === null || object === undefined) { + throw new InterpreterError(`Cannot read property '${property}' of ${String(object)}`) + } + + return (object as any)[property] +} + +function evaluateBinaryExpression(node: any, ctx: InterpreterContext): unknown { + const op = node.operator as string + if (!ALLOWED_BINARY_OPS.has(op)) { + throw new InterpreterError(`Disallowed binary operator: ${op}`) + } + + const left = evaluate(node.left, ctx) as any + const right = evaluate(node.right, ctx) as any + + switch (op) { + case '===': + return left === right + case '!==': + return left !== right + case '>': + return left > right + case '<': + return left < right + case '>=': + return left >= right + case '<=': + return left <= right + case '+': + return left + right + case '-': + return left - right + case '*': + return left * right + case '/': + return left / right + case '%': + return left % right + default: + throw new InterpreterError(`Unhandled binary operator: ${op}`) + } +} + +function evaluateLogicalExpression(node: any, ctx: InterpreterContext): unknown { + const op = node.operator as string + if (!ALLOWED_LOGICAL_OPS.has(op)) { + throw new InterpreterError(`Disallowed logical operator: ${op}`) + } + + if (op === '&&') { + return evaluate(node.left, ctx) && evaluate(node.right, ctx) + } + // op === '||' + return evaluate(node.left, ctx) || evaluate(node.right, ctx) +} + +function evaluateUnaryExpression(node: any, ctx: InterpreterContext): unknown { + const op = node.operator as string + if (!ALLOWED_UNARY_OPS.has(op)) { + throw new InterpreterError(`Disallowed unary operator: ${op}`) + } + + const arg = evaluate(node.argument, ctx) + + if (op === '!') return !arg + if (op === 'typeof') return typeof arg + throw new InterpreterError(`Unhandled unary operator: ${op}`) +} + +function evaluateTemplateLiteral(node: any, ctx: InterpreterContext): string { + const quasis: string[] = (node.quasis as any[]).map((q: any) => q.value.cooked as string) + const expressions: unknown[] = (node.expressions as any[]).map((e: any) => evaluate(e, ctx)) + + let result = quasis[0]! + for (let i = 0; i < expressions.length; i++) { + result += String(expressions[i]) + result += quasis[i + 1]! + } + return result +} + +function evaluateCallExpression(node: any, ctx: InterpreterContext): unknown { + const callee = node.callee + if (callee?.type !== 'MemberExpression') { + throw new InterpreterError('Only method calls on objects are supported') + } + + const object = evaluate(callee.object, ctx) + const method = callee.computed + ? String(evaluate(callee.property, ctx)) + : (callee.property.name as string) + + // Math.fn() calls + if (object === SAFE_MATH) { + if (!ALLOWED_MATH_MEMBERS.has(method)) { + throw new InterpreterError(`Disallowed Math member: Math.${method}`) + } + const fn = (SAFE_MATH as any)[method] + if (typeof fn !== 'function') { + throw new InterpreterError(`Math.${method} is not a function`) + } + const args = (node.arguments as any[]).map((a: any) => evaluate(a, ctx)) + return fn(...args) + } + + // obj.method() calls + if (!ALLOWED_METHODS.has(method)) { + throw new InterpreterError(`Disallowed method call: ${method}`) + } + + const fn = (object as any)[method] + if (typeof fn !== 'function') { + throw new InterpreterError(`${method} is not a function`) + } + + const args = (node.arguments as any[]).map((a: any) => evaluate(a, ctx)) + return fn.call(object, ...args) +} diff --git a/packages/engine/src/expressions/parser.ts b/packages/engine/src/expressions/parser.ts index c049c8f..5b1fad1 100644 --- a/packages/engine/src/expressions/parser.ts +++ b/packages/engine/src/expressions/parser.ts @@ -8,14 +8,30 @@ import { ALLOWED_METHODS, ALLOWED_MATH_MEMBERS, } from './allowlist.js' +import { LRUCache } from './cache.js' /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ const FORBIDDEN_IDENTIFIERS = new Set(['Date', 'this', 'globalThis', 'window', 'self', 'process']) +const PARSE_CACHE = new LRUCache(1000) + +/** + * Clear the expression parse cache. + * Useful for testing or when allowlist changes at runtime. + */ +export function clearParseCache(): void { + PARSE_CACHE.clear() +} + export function parseExpression(source: string): ParseResult { + const cached = PARSE_CACHE.get(source) + if (cached) return cached + if (source.trim().length === 0) { - return { success: false, errors: [{ message: 'Expression is empty' }] } + const result: ParseResult = { success: false, errors: [{ message: 'Expression is empty' }] } + PARSE_CACHE.set(source, result) + return result } let ast: acorn.Node @@ -24,15 +40,19 @@ export function parseExpression(source: string): ParseResult { } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Parse error' const pos = e instanceof SyntaxError ? ((e as any).pos as number | undefined) : undefined - return { success: false, errors: [{ message: msg, position: pos }] } + const result: ParseResult = { success: false, errors: [{ message: msg, position: pos }] } + PARSE_CACHE.set(source, result) + return result } // Ensure the entire source was consumed (no trailing content except whitespace) if (ast.end < source.trimEnd().length) { - return { + const result: ParseResult = { success: false, errors: [{ message: 'Unexpected content after expression', position: ast.end }], } + PARSE_CACHE.set(source, result) + return result } const errors: ExpressionError[] = [] @@ -230,10 +250,12 @@ export function parseExpression(source: string): ParseResult { walk(ast, false) if (errors.length > 0) { - return { success: false, errors } + const result: ParseResult = { success: false, errors } + PARSE_CACHE.set(source, result) + return result } - return { + const result: ParseResult = { success: true, expression: { source, @@ -241,6 +263,8 @@ export function parseExpression(source: string): ParseResult { memberPaths: [...memberPaths], }, } + PARSE_CACHE.set(source, result) + return result } /** diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 0bdebab..d91e1dd 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -1,11 +1,19 @@ // Expressions -export { parseExpression, validateExpressions } from './expressions/index.js' +export { + parseExpression, + clearParseCache, + validateExpressions, + interpretExpression, + InterpreterError, + LRUCache, +} from './expressions/index.js' export type { ParseResult, ExpressionError, ParsedExpression, ExpressionValidationResult, ExpressionValidationError, + InterpreterContext, } from './expressions/index.js' // Runner