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.
427 lines
12 KiB
TypeScript
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)}`,
|
|
);
|
|
}
|
|
} |