Files
boocode/packages/ion/src/engine/condition-evaluator.ts
indifferentketchup b1e4e5fd2a chore: add ion package, codesight wiki, work plans, ascli config
New @boocode/ion package (v0.0.1) for inference optimization network.
.codesight/ wiki artifacts for codebase documentation.
.omo/ work plans for openspec cleanup and enhanced file panel.
2026-06-07 22:16:45 +00:00

427 lines
12 KiB
TypeScript

/**
* 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<string, Record<string, unknown>>,
) {}
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<string, Record<string, unknown>>,
): 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)}`,
);
}
}