/** * Condition evaluator for the Ion workflow engine. * * Parses and evaluates `when:` conditions that reference node outputs. * Supports comparison operators, AND/OR compounds, and literal values. * * Grammar (informal): * condition = orExpr * orExpr = andExpr ( "OR" andExpr )* * andExpr = comparison ( "AND" comparison )* * comparison = value operator value * value = nodeRef | literal * nodeRef = "$" nodeId "." field * literal = number | boolean | quotedString * operator = "==" | "!=" | "<" | ">" | "<=" | ">=" */ import { resolveNodeOutputField, OutputRefError } from './output-ref.js'; // --------------------------------------------------------------------------- // Error type // --------------------------------------------------------------------------- export class ConditionError extends Error { public readonly expression: string; constructor(expression: string, message: string) { super(`Condition evaluation error in "${expression}": ${message}`); this.name = 'ConditionError'; this.expression = expression; } } // --------------------------------------------------------------------------- // Token types // --------------------------------------------------------------------------- type TokenType = | 'NODE_REF' // $nodeId.field | 'NUMBER' // 42, 3.14 | 'BOOLEAN' // true, false | 'STRING' // "hello" or 'hello' | 'OPERATOR' // ==, !=, <, >, <=, >= | 'AND' // AND keyword | 'OR' // OR keyword | 'LPAREN' // ( | 'RPAREN' // ) | 'EOF'; interface Token { type: TokenType; value: string; } // --------------------------------------------------------------------------- // Tokenizer // --------------------------------------------------------------------------- const OPERATORS = new Set(['==', '!=', '<=', '>=', '<', '>']); function tokenize(expression: string): Token[] { const tokens: Token[] = []; let pos = 0; while (pos < expression.length) { // Skip whitespace. if (/\s/.test(expression[pos]!)) { pos++; continue; } // Parentheses. if (expression[pos] === '(') { tokens.push({ type: 'LPAREN', value: '(' }); pos++; continue; } if (expression[pos] === ')') { tokens.push({ type: 'RPAREN', value: ')' }); pos++; continue; } // Node reference: $nodeId.field if (expression[pos] === '$') { const start = pos; pos++; // skip $ let field = ''; // Read the nodeId (alphanumeric, underscores, hyphens). while (pos < expression.length && /[\w-]/.test(expression[pos]!)) { pos++; } const nodeId = expression.slice(start + 1, pos); if (nodeId.length === 0) { throw new ConditionError(expression, `Expected node identifier after $ at position ${start}`); } // Expect a dot then field name. if (expression[pos] !== '.') { throw new ConditionError(expression, `Expected "." after node reference $${nodeId} at position ${pos}`); } pos++; // skip dot const fieldStart = pos; while (pos < expression.length && /[\w-]/.test(expression[pos]!)) { pos++; } field = expression.slice(fieldStart, pos); if (field.length === 0) { throw new ConditionError(expression, `Expected field name after $${nodeId}. at position ${fieldStart}`); } tokens.push({ type: 'NODE_REF', value: `${nodeId}.${field}` }); continue; } // Quoted string. if (expression[pos] === '"' || expression[pos] === "'") { const quote = expression[pos]!; const start = pos; pos++; let str = ''; while (pos < expression.length && expression[pos] !== quote) { if (expression[pos] === '\\' && pos + 1 < expression.length) { pos++; // skip escape str += expression[pos]!; } else { str += expression[pos]!; } pos++; } if (pos >= expression.length) { throw new ConditionError(expression, `Unterminated string starting at position ${start}`); } pos++; // skip closing quote tokens.push({ type: 'STRING', value: str }); continue; } // Two-character operators. if (pos + 1 < expression.length) { const twoChar = expression.slice(pos, pos + 2); if (OPERATORS.has(twoChar)) { tokens.push({ type: 'OPERATOR', value: twoChar }); pos += 2; continue; } } // Single-character operators. const oneChar = expression[pos]!; if (OPERATORS.has(oneChar)) { tokens.push({ type: 'OPERATOR', value: oneChar }); pos++; continue; } // AND / OR keywords. const remaining = expression.slice(pos); const andMatch = remaining.match(/^AND(?=\s|\(|$)/i); if (andMatch) { tokens.push({ type: 'AND', value: 'AND' }); pos += 3; continue; } const orMatch = remaining.match(/^OR(?=\s|\(|$)/i); if (orMatch) { tokens.push({ type: 'OR', value: 'OR' }); pos += 2; continue; } // Boolean literals. const trueMatch = remaining.match(/^true(?=\s|\)|$)/i); if (trueMatch) { tokens.push({ type: 'BOOLEAN', value: 'true' }); pos += 4; continue; } const falseMatch = remaining.match(/^false(?=\s|\)|$)/i); if (falseMatch) { tokens.push({ type: 'BOOLEAN', value: 'false' }); pos += 5; continue; } // Number literal. const numMatch = remaining.match(/^(-?\d+\.?\d*)/); if (numMatch && numMatch[1] !== undefined) { tokens.push({ type: 'NUMBER', value: numMatch[1] }); pos += numMatch[1].length; continue; } throw new ConditionError( expression, `Unexpected character "${expression[pos]}" at position ${pos}`, ); } tokens.push({ type: 'EOF', value: '' }); return tokens; } // --------------------------------------------------------------------------- // Parser (recursive descent) // --------------------------------------------------------------------------- class ConditionParser { private pos = 0; constructor( private tokens: Token[], private expression: string, private nodeOutputs: Record>, ) {} parse(): boolean { const result = this.parseOr(); if (this.tokens[this.pos]!.type !== 'EOF') { throw new ConditionError( this.expression, `Unexpected token "${this.tokens[this.pos]!.value}" after expression`, ); } return result; } // orExpr = andExpr ( "OR" andExpr )* private parseOr(): boolean { let result = this.parseAnd(); while (this.tokens[this.pos]!.type === 'OR') { this.pos++; // consume OR const right = this.parseAnd(); result = result || right; } return result; } // andExpr = comparison ( "AND" comparison )* private parseAnd(): boolean { let result = this.parseComparison(); while (this.tokens[this.pos]!.type === 'AND') { this.pos++; // consume AND const right = this.parseComparison(); result = result && right; } return result; } // comparison = value operator value | "(" orExpr ")" private parseComparison(): boolean { // Parenthesized expression. if (this.tokens[this.pos]!.type === 'LPAREN') { this.pos++; // consume ( const result = this.parseOr(); if (this.tokens[this.pos]!.type !== 'RPAREN') { throw new ConditionError(this.expression, 'Expected closing ")"'); } this.pos++; // consume ) return result; } // value operator value const left = this.resolveValue(); const opToken = this.tokens[this.pos]!; if (opToken.type !== 'OPERATOR') { throw new ConditionError( this.expression, `Expected comparison operator, got "${opToken.value}" (${opToken.type})`, ); } this.pos++; // consume operator const right = this.resolveValue(); return this.compare(left, opToken.value, right); } private resolveValue(): string | number | boolean { const token = this.tokens[this.pos]!; switch (token.type) { case 'NODE_REF': { this.pos++; const dotIndex = token.value.indexOf('.'); if (dotIndex === -1) { throw new ConditionError( this.expression, `Invalid node reference: ${token.value}`, ); } const nodeId = token.value.slice(0, dotIndex); const field = token.value.slice(dotIndex + 1); const output = this.nodeOutputs[nodeId]; if (!output) { throw new ConditionError( this.expression, `Node "${nodeId}" has no output available. Available nodes: ${Object.keys(this.nodeOutputs).join(', ') || '(none)'}`, ); } try { const result = resolveNodeOutputField(output, nodeId, field); // For comparison, we need the raw value, not the stringified version. const rawValue = output[field]; if (typeof rawValue === 'number') return rawValue; if (typeof rawValue === 'boolean') return rawValue; return result.value; } catch (err) { if (err instanceof OutputRefError) { throw new ConditionError(this.expression, err.message); } throw err; } } case 'NUMBER': { this.pos++; const num = Number(token.value); if (Number.isNaN(num)) { throw new ConditionError( this.expression, `Invalid number literal: ${token.value}`, ); } return num; } case 'BOOLEAN': { this.pos++; return token.value.toLowerCase() === 'true'; } case 'STRING': { this.pos++; return token.value; } default: throw new ConditionError( this.expression, `Expected value (node reference, number, boolean, or string), got "${token.value}" (${token.type})`, ); } } private compare( left: string | number | boolean, op: string, right: string | number | boolean, ): boolean { // Coerce types for comparison. const leftNum = typeof left === 'number' ? left : Number(left); const rightNum = typeof right === 'number' ? right : Number(right); switch (op) { case '==': return left === right; case '!=': return left !== right; case '<': if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) { return leftNum < rightNum; } return String(left) < String(right); case '>': if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) { return leftNum > rightNum; } return String(left) > String(right); case '<=': if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) { return leftNum <= rightNum; } return String(left) <= String(right); case '>=': if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) { return leftNum >= rightNum; } return String(left) >= String(right); default: throw new ConditionError(this.expression, `Unknown operator: ${op}`); } } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Evaluate a `when:` condition expression against node outputs. * * Supports: * - Node output references: `$nodeId.field` * - Comparison operators: `==`, `!=`, `<`, `>`, `<=`, `>=` * - Logical compounds: `AND`, `OR` * - Parenthesized sub-expressions * - Literal values: numbers, booleans, quoted strings * * Returns `true` or `false`. Throws `ConditionError` on parse failure (fail-closed). */ export function evaluateCondition( expression: string, nodeOutputs: Record>, ): boolean { if (!expression || expression.trim().length === 0) { // Empty condition is always true (no guard = proceed). return true; } const trimmed = expression.trim(); const tokens = tokenize(trimmed); const parser = new ConditionParser(tokens, trimmed, nodeOutputs); try { return parser.parse(); } catch (err) { if (err instanceof ConditionError) { throw err; } throw new ConditionError( trimmed, `Unexpected error: ${err instanceof Error ? err.message : String(err)}`, ); } }