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.
This commit is contained in:
427
packages/ion/src/engine/condition-evaluator.ts
Normal file
427
packages/ion/src/engine/condition-evaluator.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user