From 0795b1870514745bc3db1693e6f01a783740a7d7 Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 12:34:30 +0800 Subject: [PATCH 01/16] feat: create shared types module and enhance symbol registry with variable resolution --- src/symbol/index.ts | 3 +- src/symbol/registry.ts | 164 +++++++++++++++++++++++++++++++++++++++++ src/types/index.ts | 155 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 src/types/index.ts diff --git a/src/symbol/index.ts b/src/symbol/index.ts index 3ff819d..c3d2fa0 100644 --- a/src/symbol/index.ts +++ b/src/symbol/index.ts @@ -3,6 +3,7 @@ export type { FunctionDefinition, ParameterSchema, SymbolInfo, + VariableContext, } from './registry'; -export { SymbolRegistry } from './registry'; +export { SymbolRegistry, VariableResolutionError } from './registry'; diff --git a/src/symbol/registry.ts b/src/symbol/registry.ts index 0d6c464..e7ceae1 100644 --- a/src/symbol/registry.ts +++ b/src/symbol/registry.ts @@ -29,6 +29,22 @@ export interface ParameterSchema { required?: boolean; } +export class VariableResolutionError extends Error { + constructor( + message: string, + public readonly variableName?: string + ) { + super(message); + this.name = 'VariableResolutionError'; + } +} + +export interface VariableContext { + inputs: Record; + constants: Record; + calculated: Record; +} + export class SymbolRegistry { private symbols: Map = new Map(); @@ -112,4 +128,152 @@ export class SymbolRegistry { } } } + + /** + * Resolves a variable reference to its actual value + * @param reference Variable reference (e.g., '$income', '$$tax_rate', 'taxable_income') + * @param context Variable context containing values + * @returns The resolved value + */ + resolveValue( + reference: string | number | boolean, + context: VariableContext + ): number | boolean { + // Return primitives as-is + if (typeof reference === 'number' || typeof reference === 'boolean') { + return reference; + } + + const varName = reference as string; + return this.resolveVariable(varName, context); + } + + /** + * Resolves a variable by name with prefix handling and symbol validation + */ + private resolveVariable( + varName: string, + context: VariableContext + ): number | boolean { + // Input variable ($prefix) + if (varName.startsWith('$') && !varName.startsWith('$$')) { + const inputName = varName.slice(1); + this.validateSymbolUsage(inputName, 'input_variable'); + return this.getInputValue(inputName, context); + } + + // Constant variable ($$prefix) + if (varName.startsWith('$$')) { + const constName = varName.slice(2); + this.validateSymbolUsage(constName, 'constant_variable'); + return this.getConstantValue(constName, context); + } + + // No prefix - try calculated, then inputs, then constants + return this.resolveUnprefixedVariable(varName, context); + } + + /** + * Validates that a symbol is being used correctly (as function vs variable) + */ + private validateSymbolUsage( + name: string, + expectedType: + | 'function' + | 'input_variable' + | 'constant_variable' + | 'calculated_variable' + ): void { + const symbol = this.getSymbol(name); + if (symbol && symbol.symbolType !== expectedType) { + const actualIsFunction = symbol.symbolType === 'function'; + const expectedIsFunction = expectedType === 'function'; + + throw new VariableResolutionError( + `Incorrect usage: '${name}' is ${actualIsFunction ? 'a function' : 'a variable'} but used as ${expectedIsFunction ? 'a function' : 'a variable'}`, + name + ); + } + } + + /** + * Resolves variables without prefixes (calculated, inputs, constants in that priority) + */ + private resolveUnprefixedVariable( + varName: string, + context: VariableContext + ): number | boolean { + // Priority: calculated > inputs > constants + if (varName in context.calculated) { + return context.calculated[varName]; + } + if (varName in context.inputs) { + return context.inputs[varName]; + } + if (varName in context.constants) { + return context.constants[varName]; + } + + throw new VariableResolutionError( + `Variable '${varName}' not found`, + varName + ); + } + + /** + * Gets input variable value with validation + */ + private getInputValue( + inputName: string, + context: VariableContext + ): number | boolean { + if (inputName in context.inputs) { + return context.inputs[inputName]; + } + throw new VariableResolutionError( + `Input variable '${inputName}' not found`, + inputName + ); + } + + /** + * Gets constant variable value with validation + */ + private getConstantValue( + constName: string, + context: VariableContext + ): number | boolean { + if (constName in context.constants) { + return context.constants[constName]; + } + throw new VariableResolutionError( + `Constant '${constName}' not found`, + constName + ); + } + + /** + * Batch resolves multiple values efficiently + */ + resolveBatch( + references: (string | number | boolean)[], + context: VariableContext + ): (number | boolean)[] { + return references.map((ref) => this.resolveValue(ref, context)); + } + + /** + * Checks if a variable reference can be resolved without throwing + */ + canResolve( + reference: string | number | boolean, + context: VariableContext + ): boolean { + try { + this.resolveValue(reference, context); + return true; + } catch { + return false; + } + } } diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..c0cc051 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,155 @@ +// Shared types used across multiple modules + +export const VALID_TAXPAYER_TYPES = [ + 'INDIVIDUAL', + 'CORPORATION', + 'PARTNERSHIP', + 'SOLE_PROPRIETORSHIP', +] as const; + +export const VALID_OPERATION_TYPES = [ + 'set', + 'add', + 'subtract', + 'deduct', + 'multiply', + 'divide', + 'min', + 'max', + 'lookup', +] as const; + +export const COMPARISON_OPERATORS = [ + 'eq', + 'ne', + 'gt', + 'lt', + 'gte', + 'lte', +] as const; + +export const LOGICAL_OPERATORS = ['and', 'or', 'not'] as const; +export const VALID_FREQUENCIES = ['quarterly', 'annual'] as const; + +export type TaxpayerType = (typeof VALID_TAXPAYER_TYPES)[number]; +export type OperationType = (typeof VALID_OPERATION_TYPES)[number]; +export type ComparisonOperatorType = (typeof COMPARISON_OPERATORS)[number]; +export type LogicalOperatorType = (typeof LOGICAL_OPERATORS)[number]; +export type FilingFrequency = (typeof VALID_FREQUENCIES)[number]; + +export interface VariableDeclaration { + type: 'number' | 'boolean'; + description: string; + minimum?: number; + maximum?: number; +} + +export interface TableBracket { + min: number; + max: number | null; + rate: number; + base_tax: number; +} + +export interface Table { + name: string; + brackets: TableBracket[]; +} + +export interface Form { + primary: string; + attachments?: string[]; +} + +export interface FilingSchedule { + name: string; + frequency: 'monthly' | 'quarterly' | 'annually'; + filing_day: number; + when?: Condition; + forms: Form; +} + +export interface BaseOperation { + type: string; + target: string; +} + +export interface SetOperation extends BaseOperation { + type: 'set'; + value: string | number | boolean; +} + +export interface ArithmeticOperation extends BaseOperation { + type: 'add' | 'subtract' | 'deduct' | 'multiply' | 'divide'; + value: string | number | boolean; +} + +export interface MinMaxOperation extends BaseOperation { + type: 'min' | 'max'; + value: string | number | boolean; +} + +export interface LookupOperation extends BaseOperation { + type: 'lookup'; + table: string; + value: string | number | boolean; +} + +export type Operation = + | SetOperation + | ArithmeticOperation + | MinMaxOperation + | LookupOperation; + +export interface ComparisonCondition { + lt?: string | number | boolean; + lte?: string | number | boolean; + gt?: string | number | boolean; + gte?: string | number | boolean; + eq?: string | number | boolean; + ne?: string | number | boolean; +} + +export interface LogicalCondition { + and?: Condition[]; + or?: Condition[]; + not?: Condition; +} + +export type Condition = + | { [variable: string]: ComparisonCondition } + | LogicalCondition; + +export interface Case { + when: Condition; + operations: Operation[]; +} + +export interface FlowStep { + name: string; + operations?: Operation[]; + cases?: Case[]; +} + +export interface Rule { + $version: string; + name: string; + references?: string[]; + effective_from?: string; + jurisdiction: string; + taxpayer_type: string; + author: string; + constants: Record; + tables: Table[]; + inputs: Record; + outputs: Record; + filing_schedules: FilingSchedule[]; + flow: FlowStep[]; +} + +export interface EvaluationContext { + inputs: Record; + constants: Record; + calculated: Record; + tables: Record; +} From ec1e71fb60761ffcbc3c0d01e8bce7950631dab3 Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 12:35:10 +0800 Subject: [PATCH 02/16] refactor: update validator to use Raw* prefixed types for JSON validation --- src/validator/index.ts | 16 +++---- src/validator/schemas/conditions.ts | 10 ++-- src/validator/schemas/metadata.ts | 6 +-- src/validator/schemas/operations.ts | 23 +++++---- src/validator/schemas/schedules.ts | 6 +-- src/validator/schemas/tables.ts | 6 +-- src/validator/schemas/variables.ts | 10 ++-- src/validator/types.ts | 72 ++++++++--------------------- src/validator/validator.ts | 4 +- 9 files changed, 61 insertions(+), 92 deletions(-) diff --git a/src/validator/index.ts b/src/validator/index.ts index 36bfb87..fa55b06 100644 --- a/src/validator/index.ts +++ b/src/validator/index.ts @@ -7,14 +7,14 @@ export type { export { RuleValidationError } from './errors'; export type { - Rule, - VariableSchema, - Table, - TableBracket, - FilingSchedule, - FlowStep, - Operation, - ConditionalCase, + RawRule, + RawVariableSchema, + RawTable, + RawTableBracket, + RawFilingSchedule, + RawFlowStep, + RawOperation, + RawConditionalCase, ConditionalExpression, ComparisonOperator, LogicalExpression, diff --git a/src/validator/schemas/conditions.ts b/src/validator/schemas/conditions.ts index 9eef3e9..c3ee255 100644 --- a/src/validator/schemas/conditions.ts +++ b/src/validator/schemas/conditions.ts @@ -1,11 +1,11 @@ import type { - Rule, + RawRule, ConditionalExpression, ComparisonOperator, LogicalExpression, } from '../types'; -import { COMPARISON_OPERATORS, LOGICAL_OPERATORS } from '../types'; import type { ValidationIssue } from '../errors'; +import { COMPARISON_OPERATORS, LOGICAL_OPERATORS } from '../../types'; function validateComparisonOperator( operator: ComparisonOperator, @@ -64,7 +64,7 @@ function validateComparisonOperator( function validateLogicalExpression( expression: LogicalExpression, - rule: Rule, + rule: RawRule, path: string ): ValidationIssue[] { const issues: ValidationIssue[] = []; @@ -135,7 +135,7 @@ function validateLogicalExpression( function validateConditionalExpression( expression: ConditionalExpression, - rule: Rule, + rule: RawRule, path: string ): ValidationIssue[] { const issues: ValidationIssue[] = []; @@ -187,7 +187,7 @@ function validateConditionalExpression( return issues; } -export function validateConditionalLogic(rule: Rule): ValidationIssue[] { +export function validateConditionalLogic(rule: RawRule): ValidationIssue[] { const issues: ValidationIssue[] = []; rule.flow.forEach((step, stepIndex) => { diff --git a/src/validator/schemas/metadata.ts b/src/validator/schemas/metadata.ts index 71d6ed5..906ae92 100644 --- a/src/validator/schemas/metadata.ts +++ b/src/validator/schemas/metadata.ts @@ -1,5 +1,5 @@ -import type { Rule, ValidatorConfig } from '../types'; -import { VALID_TAXPAYER_TYPES } from '../types'; +import type { RawRule, ValidatorConfig } from '../types'; +import { VALID_TAXPAYER_TYPES } from '../../types'; import type { ValidationIssue } from '../errors'; const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; @@ -7,7 +7,7 @@ const ISO_COUNTRY_CODE_REGEX = /^[A-Z]{2}$/; const VERSION_REGEX = /^\d+\.\d+\.\d+$/; export function validateMetadata( - rule: Rule, + rule: RawRule, config: ValidatorConfig ): ValidationIssue[] { const issues: ValidationIssue[] = []; diff --git a/src/validator/schemas/operations.ts b/src/validator/schemas/operations.ts index d927c87..dead13b 100644 --- a/src/validator/schemas/operations.ts +++ b/src/validator/schemas/operations.ts @@ -1,6 +1,11 @@ -import type { Rule, FlowStep, Operation, ConditionalCase } from '../types'; -import { VALID_OPERATION_TYPES } from '../types'; +import type { + RawRule, + RawFlowStep, + RawOperation, + RawConditionalCase, +} from '../types'; import type { ValidationIssue } from '../errors'; +import { VALID_OPERATION_TYPES } from '../../types'; import { isRuleOnlyIdentifier } from '../../expression/identifiers'; function validateValueReference( @@ -69,8 +74,8 @@ function validateValueReference( } function validateOperation( - rule: Rule, - operation: Operation, + rule: RawRule, + operation: RawOperation, stepIndex: number, operationIndex: number ): ValidationIssue[] { @@ -156,8 +161,8 @@ function validateOperation( } function validateFlowStep( - rule: Rule, - step: FlowStep, + rule: RawRule, + step: RawFlowStep, index: number ): ValidationIssue[] { const issues: ValidationIssue[] = []; @@ -241,8 +246,8 @@ function validateFlowStep( } function validateConditionalCase( - rule: Rule, - caseItem: ConditionalCase, + rule: RawRule, + caseItem: RawConditionalCase, stepIndex: number, caseIndex: number ): ValidationIssue[] { @@ -278,7 +283,7 @@ function validateConditionalCase( return issues; } -export function validateOperationsAndFlow(rule: Rule): ValidationIssue[] { +export function validateOperationsAndFlow(rule: RawRule): ValidationIssue[] { const issues: ValidationIssue[] = []; if (!Array.isArray(rule.flow) || rule.flow.length === 0) { diff --git a/src/validator/schemas/schedules.ts b/src/validator/schemas/schedules.ts index 81df956..c7bc43b 100644 --- a/src/validator/schemas/schedules.ts +++ b/src/validator/schemas/schedules.ts @@ -1,8 +1,8 @@ -import type { Rule } from '../types'; -import { VALID_FREQUENCIES } from '../types'; +import type { RawRule } from '../types'; import type { ValidationIssue } from '../errors'; +import { VALID_FREQUENCIES } from '../../types'; -export function validateFilingSchedules(rule: Rule): ValidationIssue[] { +export function validateFilingSchedules(rule: RawRule): ValidationIssue[] { const issues: ValidationIssue[] = []; if (!rule.filing_schedules || rule.filing_schedules.length === 0) { diff --git a/src/validator/schemas/tables.ts b/src/validator/schemas/tables.ts index 02985f8..556d912 100644 --- a/src/validator/schemas/tables.ts +++ b/src/validator/schemas/tables.ts @@ -1,11 +1,11 @@ -import type { Rule, TableBracket } from '../types'; +import type { RawRule, RawTableBracket } from '../types'; import type { ValidationIssue } from '../errors'; import { isRuleOnlyIdentifier } from '../../expression'; const MAX_TAXABLE_INCOME_REFERENCE = '$$MAX_TAXABLE_INCOME'; function validateTableBracket( - bracket: TableBracket, + bracket: RawTableBracket, index: number, tableName: string ): ValidationIssue[] { @@ -110,7 +110,7 @@ function validateTableBracket( return issues; } -export function validateTables(rule: Rule): ValidationIssue[] { +export function validateTables(rule: RawRule): ValidationIssue[] { const issues: ValidationIssue[] = []; if (!rule.tables || rule.tables.length === 0) { diff --git a/src/validator/schemas/variables.ts b/src/validator/schemas/variables.ts index 7a91256..6c0c368 100644 --- a/src/validator/schemas/variables.ts +++ b/src/validator/schemas/variables.ts @@ -1,11 +1,11 @@ -import type { Rule, VariableSchema } from '../types'; +import type { RawRule, RawVariableSchema } from '../types'; import type { ValidationIssue } from '../errors'; import { isRuleOnlyIdentifier } from '../../expression/identifiers'; const VALID_SCHEMA_TYPES = ['number', 'string', 'boolean', 'array', 'object']; function validateVariableSchema( - schema: VariableSchema, + schema: RawVariableSchema, path: string ): ValidationIssue[] { const issues: ValidationIssue[] = []; @@ -81,7 +81,7 @@ function validateVariableSchema( return issues; } -export function validateVariables(rule: Rule): ValidationIssue[] { +export function validateVariables(rule: RawRule): ValidationIssue[] { const issues: ValidationIssue[] = []; const allNames = new Set(); @@ -164,7 +164,7 @@ function validateConstant( function validateInput( name: string, - schema: VariableSchema, + schema: RawVariableSchema, existingNames: Set ): ValidationIssue[] { const issues: ValidationIssue[] = []; @@ -199,7 +199,7 @@ function validateInput( function validateOutput( name: string, - schema: VariableSchema, + schema: RawVariableSchema, existingNames: Set ): ValidationIssue[] { const issues: ValidationIssue[] = []; diff --git a/src/validator/types.ts b/src/validator/types.ts index aef56f1..2b17ca2 100644 --- a/src/validator/types.ts +++ b/src/validator/types.ts @@ -1,42 +1,6 @@ -export const VALID_TAXPAYER_TYPES = [ - 'INDIVIDUAL', - 'CORPORATION', - 'PARTNERSHIP', - 'SOLE_PROPRIETORSHIP', -] as const; +import type { TaxpayerType, OperationType, FilingFrequency } from '@/types'; -// TODO: should be removed later when -// operation evaluation is implemented. -export const VALID_OPERATION_TYPES = [ - 'set', - 'add', - 'subtract', - 'deduct', - 'multiply', - 'divide', - 'min', - 'max', - 'lookup', -] as const; - -export const COMPARISON_OPERATORS = [ - 'eq', - 'ne', - 'gt', - 'lt', - 'gte', - 'lte', -] as const; -export const LOGICAL_OPERATORS = ['and', 'or', 'not'] as const; -export const VALID_FREQUENCIES = ['quarterly', 'annual'] as const; - -export type TaxpayerType = (typeof VALID_TAXPAYER_TYPES)[number]; -export type OperationType = (typeof VALID_OPERATION_TYPES)[number]; -export type ComparisonOperatorType = (typeof COMPARISON_OPERATORS)[number]; -export type LogicalOperatorType = (typeof LOGICAL_OPERATORS)[number]; -export type FilingFrequency = (typeof VALID_FREQUENCIES)[number]; - -export interface Rule { +export interface RawRule { $version: string; name: string; references?: string[]; @@ -47,14 +11,14 @@ export interface Rule { category?: string; author?: string; constants?: Record; - tables?: Table[]; - inputs?: Record; - outputs?: Record; - filing_schedules?: FilingSchedule[]; - flow: FlowStep[]; + tables?: RawTable[]; + inputs?: Record; + outputs?: Record; + filing_schedules?: RawFilingSchedule[]; + flow: RawFlowStep[]; } -export interface VariableSchema { +export interface RawVariableSchema { type: 'number' | 'string' | 'boolean' | 'array' | 'object'; description?: string; minimum?: number; @@ -66,19 +30,19 @@ export interface VariableSchema { }; } -export interface Table { +export interface RawTable { name: string; - brackets: TableBracket[]; + brackets: RawTableBracket[]; } -export interface TableBracket { +export interface RawTableBracket { min: number | string; max: number | string; rate: number; base_tax: number; } -export interface FilingSchedule { +export interface RawFilingSchedule { name: string; frequency: FilingFrequency; filing_day: number | string; @@ -89,22 +53,22 @@ export interface FilingSchedule { }; } -export interface FlowStep { +export interface RawFlowStep { name: string; - operations?: Operation[]; - cases?: ConditionalCase[]; + operations?: RawOperation[]; + cases?: RawConditionalCase[]; } -export interface Operation { +export interface RawOperation { type: OperationType; target: string; value?: string | number | boolean; table?: string; } -export interface ConditionalCase { +export interface RawConditionalCase { when?: ConditionalExpression; - operations: Operation[]; + operations: RawOperation[]; } export type ConditionalExpression = diff --git a/src/validator/validator.ts b/src/validator/validator.ts index 8736d59..915520d 100644 --- a/src/validator/validator.ts +++ b/src/validator/validator.ts @@ -1,4 +1,4 @@ -import type { Rule, ValidatorConfig } from './types'; +import type { RawRule, ValidatorConfig } from './types'; import type { ValidationIssue, ValidationSeverity } from './errors'; import { RuleValidationError } from './errors'; import { validateMetadata } from './schemas/metadata'; @@ -41,7 +41,7 @@ export class RuleValidator { return this.issues; } - const typedRule = ruleObj as unknown as Rule; + const typedRule = ruleObj as unknown as RawRule; this.runValidation( () => validateMetadata(typedRule, this.config), From 4c1444de3c3022da1714486059b96881d5b61b55 Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 12:35:32 +0800 Subject: [PATCH 03/16] feat: implement evaluator module with shared ExpressionEvaluator integration --- src/evaluator/conditional.ts | 238 ++++++++++++++++++++++ src/evaluator/errors.ts | 45 +++++ src/evaluator/evaluator.ts | 172 ++++++++++++++++ src/evaluator/index.ts | 14 ++ src/evaluator/operations.ts | 298 ++++++++++++++++++++++++++++ tests/evaluator/conditional.test.ts | 269 +++++++++++++++++++++++++ tests/evaluator/evaluator.test.ts | 260 ++++++++++++++++++++++++ 7 files changed, 1296 insertions(+) create mode 100644 src/evaluator/conditional.ts create mode 100644 src/evaluator/errors.ts create mode 100644 src/evaluator/evaluator.ts create mode 100644 src/evaluator/index.ts create mode 100644 src/evaluator/operations.ts create mode 100644 tests/evaluator/conditional.test.ts create mode 100644 tests/evaluator/evaluator.test.ts diff --git a/src/evaluator/conditional.ts b/src/evaluator/conditional.ts new file mode 100644 index 0000000..fd9c369 --- /dev/null +++ b/src/evaluator/conditional.ts @@ -0,0 +1,238 @@ +import type { + Condition, + ComparisonCondition, + LogicalCondition, + EvaluationContext, +} from '../types'; +import { RuleEvaluationError } from './errors'; +import { + ExpressionEvaluator, + ExpressionEvaluationError, + ExpressionParser, +} from '../expression'; + +export class ConditionalEvaluator { + private expressionEvaluator: ExpressionEvaluator; + + constructor(expressionEvaluator: ExpressionEvaluator) { + this.expressionEvaluator = expressionEvaluator; + } + evaluate(condition: Condition, context: EvaluationContext): boolean { + try { + return this.evaluateCondition(condition, context); + } catch (error) { + throw new RuleEvaluationError( + `Conditional evaluation failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + evaluateAll(conditions: Condition[], context: EvaluationContext): boolean { + return conditions.every((condition) => this.evaluate(condition, context)); + } + + evaluateAny(conditions: Condition[], context: EvaluationContext): boolean { + return conditions.some((condition) => this.evaluate(condition, context)); + } + + private evaluateCondition( + condition: Condition, + context: EvaluationContext + ): boolean { + // Check if it's a logical condition (and, or, not) + if (this.isLogicalCondition(condition)) { + return this.evaluateLogicalCondition( + condition as LogicalCondition, + context + ); + } + + // Otherwise it's a variable comparison condition + const varCondition = condition as { + [variable: string]: ComparisonCondition; + }; + for (const [varName, comparison] of Object.entries(varCondition)) { + const varValue = this.resolveVariableValue(varName, context); + return this.evaluateComparison(varValue, comparison, context); + } + + return false; + } + + private isLogicalCondition(condition: Condition): boolean { + return 'and' in condition || 'or' in condition || 'not' in condition; + } + + private evaluateLogicalCondition( + condition: LogicalCondition, + context: EvaluationContext + ): boolean { + if ('and' in condition && condition.and) { + return condition.and.every((c) => this.evaluateCondition(c, context)); + } + + if ('or' in condition && condition.or) { + return condition.or.some((c) => this.evaluateCondition(c, context)); + } + + if ('not' in condition && condition.not) { + return !this.evaluateCondition(condition.not, context); + } + + return false; + } + + private evaluateComparison( + value: number | boolean, + comparison: ComparisonCondition, + context: EvaluationContext + ): boolean { + // Handle equality comparisons (work for both numbers and booleans) + if (comparison.eq !== undefined) { + const compareValue = this.resolveComparisonValue(comparison.eq, context); + return value === compareValue; + } + + if (comparison.ne !== undefined) { + const compareValue = this.resolveComparisonValue(comparison.ne, context); + return value !== compareValue; + } + + // Handle numeric comparisons (only for numbers) + if (typeof value === 'number') { + if (comparison.lt !== undefined) { + const compareValue = this.resolveComparisonValue( + comparison.lt, + context + ); + if (typeof compareValue !== 'number') { + throw new RuleEvaluationError( + `Cannot compare number with ${typeof compareValue} using 'lt'` + ); + } + return value < compareValue; + } + + if (comparison.lte !== undefined) { + const compareValue = this.resolveComparisonValue( + comparison.lte, + context + ); + if (typeof compareValue !== 'number') { + throw new RuleEvaluationError( + `Cannot compare number with ${typeof compareValue} using 'lte'` + ); + } + return value <= compareValue; + } + + if (comparison.gt !== undefined) { + const compareValue = this.resolveComparisonValue( + comparison.gt, + context + ); + if (typeof compareValue !== 'number') { + throw new RuleEvaluationError( + `Cannot compare number with ${typeof compareValue} using 'gt'` + ); + } + return value > compareValue; + } + + if (comparison.gte !== undefined) { + const compareValue = this.resolveComparisonValue( + comparison.gte, + context + ); + if (typeof compareValue !== 'number') { + throw new RuleEvaluationError( + `Cannot compare number with ${typeof compareValue} using 'gte'` + ); + } + return value >= compareValue; + } + } + + return false; + } + + private resolveComparisonValue( + value: string | number | boolean, + context: EvaluationContext + ): number | boolean { + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + try { + const variableContext = { + inputs: context.inputs, + constants: context.constants, + calculated: context.calculated, + }; + + return this.expressionEvaluator.evaluate( + value as string, + variableContext + ); + } catch (error) { + if (error instanceof ExpressionEvaluationError) { + throw new RuleEvaluationError(error.message); + } + throw error; + } + } + + private resolveVariableValue( + varName: string, + context: EvaluationContext + ): number | boolean { + // First check if variable exists directly in calculated + if (varName in context.calculated) { + return context.calculated[varName]; + } + + // Then check if it exists in inputs + if (varName in context.inputs) { + return context.inputs[varName]; + } + + // Then check if it exists in constants + if (varName in context.constants) { + return context.constants[varName]; + } + + // If not found directly, try to evaluate as expression with prefixes + try { + const variableContext = { + inputs: context.inputs, + constants: context.constants, + calculated: context.calculated, + }; + + return this.expressionEvaluator.evaluate(varName, variableContext); + } catch (error) { + if (error instanceof ExpressionEvaluationError) { + // Provide a based on variable prefix + if (varName.startsWith('$$')) { + throw new RuleEvaluationError( + `Constant '${varName.slice(2)}' not found` + ); + } else if (varName.startsWith('$')) { + throw new RuleEvaluationError( + `Input variable '${varName.slice(1)}' not found` + ); + } else { + throw new RuleEvaluationError(`Variable '${varName}' not found`); + } + } + throw error; + } + } +} + +// Export a default instance for convenience +// Create a default conditional evaluator instance +export const conditionalEvaluator = new ConditionalEvaluator( + new ExpressionEvaluator() +); diff --git a/src/evaluator/errors.ts b/src/evaluator/errors.ts new file mode 100644 index 0000000..826ad7b --- /dev/null +++ b/src/evaluator/errors.ts @@ -0,0 +1,45 @@ +import type { Operation, Rule, EvaluationContext } from '../types'; + +export class RuleEvaluationError extends Error { + constructor( + message: string, + public readonly rule?: Rule, + public readonly context?: EvaluationContext + ) { + super(message); + this.name = 'RuleEvaluationError'; + } +} + +export class OperationError extends Error { + constructor( + message: string, + public readonly operation?: Operation, + public readonly context?: EvaluationContext + ) { + super(message); + this.name = 'OperationError'; + } +} + +export class VariableError extends Error { + constructor( + message: string, + public readonly variableName?: string, + public readonly context?: EvaluationContext + ) { + super(message); + this.name = 'VariableError'; + } +} + +export class TableError extends Error { + constructor( + message: string, + public readonly tableName?: string, + public readonly context?: EvaluationContext + ) { + super(message); + this.name = 'TableError'; + } +} diff --git a/src/evaluator/evaluator.ts b/src/evaluator/evaluator.ts new file mode 100644 index 0000000..5c41a29 --- /dev/null +++ b/src/evaluator/evaluator.ts @@ -0,0 +1,172 @@ +import type { Rule, EvaluationContext, Operation, FlowStep } from '@/types'; +import { RuleEvaluationError, OperationError } from './errors'; +import { OPERATION_REGISTRY } from './operations'; +import { ConditionalEvaluator } from './conditional'; +import { ExpressionEvaluator } from '../expression'; +import { SymbolRegistry } from '../symbol'; + +export class RuleEvaluator { + private conditionalEvaluator: ConditionalEvaluator; + private expressionEvaluator: ExpressionEvaluator; + + constructor() { + // Create expression evaluator with default configuration + this.expressionEvaluator = new ExpressionEvaluator(); + + // Create conditional evaluator with shared expression evaluator + this.conditionalEvaluator = new ConditionalEvaluator( + this.expressionEvaluator + ); + } + + evaluate( + rule: Rule, + inputs: Record = {} + ): Record { + try { + // Initialize evaluation context + const context = this.createContext(rule, inputs); + + // Process flow steps + const finalContext = this.processFlow(rule.flow, context); + + // Return only the calculated variables that match rule outputs + const results: Record = {}; + for (const outputName of Object.keys(rule.outputs)) { + if (outputName in finalContext.calculated) { + results[outputName] = finalContext.calculated[outputName]; + } else { + // Initialize missing outputs to 0 or false based on their type + const outputType = rule.outputs[outputName].type; + results[outputName] = outputType === 'boolean' ? false : 0; + } + } + + return results; + } catch (error) { + if ( + error instanceof RuleEvaluationError || + error instanceof OperationError + ) { + throw error; + } + throw new RuleEvaluationError( + `Rule evaluation failed: ${error instanceof Error ? error.message : String(error)}`, + rule + ); + } + } + + private createContext( + rule: Rule, + inputs: Record + ): EvaluationContext { + // Validate required inputs + for (const [inputName, inputDecl] of Object.entries(rule.inputs)) { + if (!(inputName in inputs)) { + throw new RuleEvaluationError( + `Required input '${inputName}' not provided`, + rule + ); + } + + const value = inputs[inputName]; + const expectedType = inputDecl.type; + const actualType = typeof value; + + if (actualType !== expectedType) { + throw new RuleEvaluationError( + `Input '${inputName}' has wrong type. Expected ${expectedType}, got ${actualType}`, + rule + ); + } + + // Validate numeric ranges + if (expectedType === 'number' && typeof value === 'number') { + if (inputDecl.minimum !== undefined && value < inputDecl.minimum) { + throw new RuleEvaluationError( + `Input '${inputName}' value ${value} is below minimum ${inputDecl.minimum}`, + rule + ); + } + if (inputDecl.maximum !== undefined && value > inputDecl.maximum) { + throw new RuleEvaluationError( + `Input '${inputName}' value ${value} is above maximum ${inputDecl.maximum}`, + rule + ); + } + } + } + + // Create tables lookup + const tablesMap: Record = {}; + for (const table of rule.tables) { + tablesMap[table.name] = table; + } + + return { + inputs, + constants: rule.constants, + calculated: {}, + tables: tablesMap, + }; + } + + private processFlow( + flowSteps: FlowStep[], + context: EvaluationContext + ): EvaluationContext { + let currentContext = context; + + for (const step of flowSteps) { + currentContext = this.processFlowStep(step, currentContext); + } + + return currentContext; + } + + private processFlowStep( + step: FlowStep, + context: EvaluationContext + ): EvaluationContext { + let currentContext = context; + + // Process direct operations + if (step.operations) { + for (const operation of step.operations) { + currentContext = this.executeOperation(operation, currentContext); + } + } + + // Process conditional cases + if (step.cases) { + for (const case_ of step.cases) { + if (this.conditionalEvaluator.evaluate(case_.when, currentContext)) { + // Execute this case's operations + for (const operation of case_.operations) { + currentContext = this.executeOperation(operation, currentContext); + } + break; // Only execute the first matching case + } + } + } + + return currentContext; + } + + private executeOperation( + operation: Operation, + context: EvaluationContext + ): EvaluationContext { + const operationFunction = OPERATION_REGISTRY[operation.type]; + if (!operationFunction) { + throw new OperationError( + `Unknown operation type: ${operation.type}`, + operation, + context + ); + } + + return operationFunction(operation, context, this.expressionEvaluator); + } +} diff --git a/src/evaluator/index.ts b/src/evaluator/index.ts new file mode 100644 index 0000000..ad8f5e8 --- /dev/null +++ b/src/evaluator/index.ts @@ -0,0 +1,14 @@ +// Types are exported from the shared types module + +export { + RuleEvaluationError, + OperationError, + VariableError, + TableError, +} from './errors'; + +export { RuleEvaluator } from './evaluator'; +export { ConditionalEvaluator, conditionalEvaluator } from './conditional'; + +export type { OperationFunction } from './operations'; +export { OPERATION_REGISTRY } from './operations'; diff --git a/src/evaluator/operations.ts b/src/evaluator/operations.ts new file mode 100644 index 0000000..c55db92 --- /dev/null +++ b/src/evaluator/operations.ts @@ -0,0 +1,298 @@ +import type { + Operation, + EvaluationContext, + SetOperation, + ArithmeticOperation, + MinMaxOperation, + LookupOperation, +} from '../types'; +import { OperationError, TableError } from './errors'; +import { + ExpressionEvaluator, + ExpressionEvaluationError, + ExpressionParser, +} from '../expression'; + +export type OperationFunction = ( + operation: Operation, + context: EvaluationContext, + expressionEvaluator: ExpressionEvaluator +) => EvaluationContext; + +function resolveValue( + value: string | number | boolean, + expressionEvaluator: ExpressionEvaluator, + context: EvaluationContext +): number | boolean { + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + try { + return expressionEvaluator.evaluate(value as string, { + inputs: context.inputs, + constants: context.constants, + calculated: context.calculated, + }); + } catch (error) { + if (error instanceof ExpressionEvaluationError) { + throw new Error(error.message); + } + throw error; + } +} + +export const setOperation: OperationFunction = ( + operation: Operation, + context: EvaluationContext, + expressionEvaluator: ExpressionEvaluator +) => { + const setOp = operation as SetOperation; + const value = resolveValue(setOp.value, expressionEvaluator, context); + + return { + ...context, + calculated: { + ...context.calculated, + [setOp.target]: value, + }, + }; +}; + +const addOperation: OperationFunction = ( + operation: Operation, + context: EvaluationContext, + expressionEvaluator: ExpressionEvaluator +) => { + const addOp = operation as ArithmeticOperation; + const currentValue = context.calculated[addOp.target]; + const addValue = resolveValue(addOp.value, expressionEvaluator, context); + + if (typeof currentValue !== 'number' || typeof addValue !== 'number') { + throw new OperationError( + `Add operation requires numeric values. Target: ${typeof currentValue}, Value: ${typeof addValue}`, + operation, + context + ); + } + + return { + ...context, + calculated: { + ...context.calculated, + [addOp.target]: currentValue + addValue, + }, + }; +}; + +const subtractOp: OperationFunction = ( + operation: Operation, + context: EvaluationContext, + expressionEvaluator: ExpressionEvaluator +) => { + const subOp = operation as ArithmeticOperation; + const currentValue = context.calculated[subOp.target]; + const subValue = resolveValue(subOp.value, expressionEvaluator, context); + + if (typeof currentValue !== 'number' || typeof subValue !== 'number') { + throw new OperationError( + `Subtract operation requires numeric values. Target: ${typeof currentValue}, Value: ${typeof subValue}`, + operation, + context + ); + } + + return { + ...context, + calculated: { + ...context.calculated, + [subOp.target]: currentValue - subValue, + }, + }; +}; + +const multiplyOp: OperationFunction = ( + operation: Operation, + context: EvaluationContext, + expressionEvaluator: ExpressionEvaluator +) => { + const mulOp = operation as ArithmeticOperation; + const currentValue = context.calculated[mulOp.target]; + const mulValue = resolveValue(mulOp.value, expressionEvaluator, context); + + if (typeof currentValue !== 'number' || typeof mulValue !== 'number') { + throw new OperationError( + `Multiply operation requires numeric values. Target: ${typeof currentValue}, Value: ${typeof mulValue}`, + operation, + context + ); + } + + return { + ...context, + calculated: { + ...context.calculated, + [mulOp.target]: currentValue * mulValue, + }, + }; +}; + +const divideOp: OperationFunction = ( + operation: Operation, + context: EvaluationContext, + expressionEvaluator: ExpressionEvaluator +) => { + const divOp = operation as ArithmeticOperation; + const currentValue = context.calculated[divOp.target]; + const divValue = resolveValue(divOp.value, expressionEvaluator, context); + + if (typeof currentValue !== 'number' || typeof divValue !== 'number') { + throw new OperationError( + `Divide operation requires numeric values. Target: ${typeof currentValue}, Value: ${typeof divValue}`, + operation, + + context + ); + } + + if (divValue === 0) { + throw new OperationError('Division by zero', operation, context); + } + + return { + ...context, + calculated: { + ...context.calculated, + [divOp.target]: currentValue / divValue, + }, + }; +}; + +const minOp: OperationFunction = ( + operation: Operation, + context: EvaluationContext, + expressionEvaluator: ExpressionEvaluator +) => { + const minOp = operation as MinMaxOperation; + const currentValue = context.calculated[minOp.target]; + const minValue = resolveValue(minOp.value, expressionEvaluator, context); + + if (typeof currentValue !== 'number' || typeof minValue !== 'number') { + throw new OperationError( + `Min operation requires numeric values. Target: ${typeof currentValue}, Value: ${typeof minValue}`, + operation, + context + ); + } + + return { + ...context, + calculated: { + ...context.calculated, + [minOp.target]: Math.min(currentValue, minValue), + }, + }; +}; + +const maxOp: OperationFunction = ( + operation: Operation, + context: EvaluationContext, + expressionEvaluator: ExpressionEvaluator +) => { + const maxOp = operation as MinMaxOperation; + const currentValue = context.calculated[maxOp.target]; + const maxValue = resolveValue(maxOp.value, expressionEvaluator, context); + + if (typeof currentValue !== 'number' || typeof maxValue !== 'number') { + throw new OperationError( + `Max operation requires numeric values. Target: ${typeof currentValue}, Value: ${typeof maxValue}`, + operation, + context + ); + } + + return { + ...context, + calculated: { + ...context.calculated, + [maxOp.target]: Math.max(currentValue, maxValue), + }, + }; +}; + +const lookupOp: OperationFunction = ( + operation: Operation, + context: EvaluationContext, + expressionEvaluator: ExpressionEvaluator +) => { + const lookupOp = operation as LookupOperation; + const lookupValue = resolveValue( + lookupOp.value, + expressionEvaluator, + context + ); + + if (typeof lookupValue !== 'number') { + throw new OperationError( + `Lookup operation requires numeric lookup value, got ${typeof lookupValue}`, + operation, + context + ); + } + + const table = context.tables[lookupOp.table]; + if (!table) { + throw new TableError( + `Table '${lookupOp.table}' not found`, + lookupOp.table, + context + ); + } + + // Find the appropriate bracket + let bracket = null; + for (const b of table.brackets) { + if (lookupValue >= b.min && (b.max === null || lookupValue <= b.max)) { + bracket = b; + break; + } + } + + if (!bracket) { + throw new TableError( + `No bracket found for value ${lookupValue} in table '${lookupOp.table}'`, + lookupOp.table, + context + ); + } + + // Calculate progressive tax + // For the amount that falls within this bracket + const taxableInBracket = + bracket.max === null + ? lookupValue - bracket.min + : Math.min(lookupValue, bracket.max) - bracket.min; + + const taxInBracket = taxableInBracket * bracket.rate; + const totalTax = bracket.base_tax + taxInBracket; + + return { + ...context, + calculated: { + ...context.calculated, + [lookupOp.target]: totalTax, + }, + }; +}; + +export const OPERATION_REGISTRY: Record = { + set: setOperation, + add: addOperation, + subtract: subtractOp, + deduct: subtractOp, // Alias for subtract + multiply: multiplyOp, + divide: divideOp, + min: minOp, + max: maxOp, + lookup: lookupOp, +}; diff --git a/tests/evaluator/conditional.test.ts b/tests/evaluator/conditional.test.ts new file mode 100644 index 0000000..4ed16df --- /dev/null +++ b/tests/evaluator/conditional.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect } from 'vitest'; +import { ConditionalEvaluator } from '../../src/evaluator/conditional'; +import { ExpressionEvaluator } from '../../src/expression'; +import type { EvaluationContext, Condition } from '../../src/types'; + +describe('ConditionalEvaluator', () => { + const evaluator = new ConditionalEvaluator(new ExpressionEvaluator()); + + const createContext = ( + inputs: Record = {}, + constants: Record = {}, + calculated: Record = {} + ): EvaluationContext => ({ + inputs, + constants, + calculated, + tables: {} + }); + + describe('basic comparisons', () => { + it('should evaluate equality conditions', () => { + const context = createContext({ age: 25, is_senior: false }); + + expect(evaluator.evaluate({ age: { eq: 25 } }, context)).toBe(true); + expect(evaluator.evaluate({ age: { eq: 30 } }, context)).toBe(false); + expect(evaluator.evaluate({ is_senior: { eq: false } }, context)).toBe(true); + expect(evaluator.evaluate({ is_senior: { eq: true } }, context)).toBe(false); + }); + + it('should evaluate inequality conditions', () => { + const context = createContext({ age: 25, is_senior: false }); + + expect(evaluator.evaluate({ age: { ne: 30 } }, context)).toBe(true); + expect(evaluator.evaluate({ age: { ne: 25 } }, context)).toBe(false); + expect(evaluator.evaluate({ is_senior: { ne: true } }, context)).toBe(true); + }); + + it('should evaluate numeric comparison conditions', () => { + const context = createContext({ income: 50000, age: 25 }); + + expect(evaluator.evaluate({ income: { gt: 40000 } }, context)).toBe(true); + expect(evaluator.evaluate({ income: { gt: 60000 } }, context)).toBe(false); + expect(evaluator.evaluate({ income: { gte: 50000 } }, context)).toBe(true); + expect(evaluator.evaluate({ income: { gte: 50001 } }, context)).toBe(false); + + expect(evaluator.evaluate({ age: { lt: 30 } }, context)).toBe(true); + expect(evaluator.evaluate({ age: { lt: 20 } }, context)).toBe(false); + expect(evaluator.evaluate({ age: { lte: 25 } }, context)).toBe(true); + expect(evaluator.evaluate({ age: { lte: 24 } }, context)).toBe(false); + }); + }); + + describe('variable reference resolution', () => { + it('should resolve input variables with $ prefix', () => { + const context = createContext({ income: 50000 }); + + expect(evaluator.evaluate({ '$income': { eq: 50000 } }, context)).toBe(true); + }); + + it('should resolve constants with $$ prefix', () => { + const context = createContext({}, { tax_rate: 0.15 }); + + expect(evaluator.evaluate({ '$$tax_rate': { eq: 0.15 } }, context)).toBe(true); + }); + + it('should resolve calculated variables', () => { + const context = createContext({}, {}, { taxable_income: 45000 }); + + expect(evaluator.evaluate({ taxable_income: { gt: 40000 } }, context)).toBe(true); + }); + + it('should resolve variables in comparison values', () => { + const context = createContext({ income: 50000 }, { threshold: 45000 }); + + expect(evaluator.evaluate({ income: { gt: '$$threshold' } }, context)).toBe(true); + }); + }); + + describe('logical operations', () => { + it('should evaluate AND conditions', () => { + const context = createContext({ age: 25, income: 50000 }); + + const condition: Condition = { + and: [ + { age: { gte: 18 } }, + { income: { gt: 40000 } } + ] + }; + + expect(evaluator.evaluate(condition, context)).toBe(true); + + const falseCondition: Condition = { + and: [ + { age: { gte: 30 } }, + { income: { gt: 40000 } } + ] + }; + + expect(evaluator.evaluate(falseCondition, context)).toBe(false); + }); + + it('should evaluate OR conditions', () => { + const context = createContext({ age: 25, income: 30000 }); + + const condition: Condition = { + or: [ + { age: { gte: 65 } }, + { income: { lt: 35000 } } + ] + }; + + expect(evaluator.evaluate(condition, context)).toBe(true); + + const falseCondition: Condition = { + or: [ + { age: { gte: 65 } }, + { income: { gt: 35000 } } + ] + }; + + expect(evaluator.evaluate(falseCondition, context)).toBe(false); + }); + + it('should evaluate NOT conditions', () => { + const context = createContext({ is_senior: false }); + + const condition: Condition = { + not: { is_senior: { eq: true } } + }; + + expect(evaluator.evaluate(condition, context)).toBe(true); + + const falseCondition: Condition = { + not: { is_senior: { eq: false } } + }; + + expect(evaluator.evaluate(falseCondition, context)).toBe(false); + }); + + it('should evaluate nested logical conditions', () => { + const context = createContext({ age: 25, income: 50000, is_student: false }); + + const condition: Condition = { + and: [ + { + or: [ + { age: { gte: 65 } }, + { is_student: { eq: true } } + ] + }, + { income: { lt: 60000 } } + ] + }; + + expect(evaluator.evaluate(condition, context)).toBe(false); + + const trueCondition: Condition = { + and: [ + { + or: [ + { age: { gte: 18 } }, + { is_student: { eq: true } } + ] + }, + { income: { lt: 60000 } } + ] + }; + + expect(evaluator.evaluate(trueCondition, context)).toBe(true); + }); + }); + + describe('utility methods', () => { + it('should evaluate all conditions with AND logic', () => { + const context = createContext({ age: 25, income: 50000 }); + + const conditions: Condition[] = [ + { age: { gte: 18 } }, + { income: { gt: 40000 } }, + { age: { lt: 30 } } + ]; + + expect(evaluator.evaluateAll(conditions, context)).toBe(true); + + const conditionsWithExtra: Condition[] = [ + ...conditions, + { income: { gt: 60000 } } + ]; + expect(evaluator.evaluateAll(conditionsWithExtra, context)).toBe(false); + }); + + it('should evaluate any conditions with OR logic', () => { + const context = createContext({ age: 25, income: 30000 }); + + const conditions: Condition[] = [ + { age: { gte: 65 } }, + { income: { gt: 60000 } }, + { age: { eq: 25 } } + ]; + + expect(evaluator.evaluateAny(conditions, context)).toBe(true); + + const falseConditions: Condition[] = [ + { age: { gte: 65 } }, + { income: { gt: 60000 } } + ]; + + expect(evaluator.evaluateAny(falseConditions, context)).toBe(false); + }); + }); + + describe('error handling', () => { + it('should throw error for missing input variables', () => { + const context = createContext({}); + + expect(() => { + evaluator.evaluate({ '$income': { eq: 50000 } }, context); + }).toThrow("Input variable 'income' not found"); + }); + + it('should throw error for missing constants', () => { + const context = createContext({}); + + expect(() => { + evaluator.evaluate({ '$$tax_rate': { eq: 0.15 } }, context); + }).toThrow("Constant 'tax_rate' not found"); + }); + + it('should throw error for missing variables', () => { + const context = createContext({}); + + expect(() => { + evaluator.evaluate({ unknown_var: { eq: 100 } }, context); + }).toThrow("Variable 'unknown_var' not found"); + }); + + it('should throw error for invalid numeric comparisons', () => { + const context = createContext({ income: 50000 }, { is_active: false }); + + expect(() => { + evaluator.evaluate({ income: { gt: '$$is_active' } }, context); + }).toThrow("Cannot compare number with boolean using 'gt'"); + }); + }); + + describe('edge cases', () => { + it('should handle empty conditions gracefully', () => { + const context = createContext(); + + expect(evaluator.evaluateAll([], context)).toBe(true); + expect(evaluator.evaluateAny([], context)).toBe(false); + }); + + it('should handle zero and negative numbers', () => { + const context = createContext({ balance: -100, age: 0 }); + + expect(evaluator.evaluate({ balance: { lt: 0 } }, context)).toBe(true); + expect(evaluator.evaluate({ age: { eq: 0 } }, context)).toBe(true); + expect(evaluator.evaluate({ age: { gte: 0 } }, context)).toBe(true); + }); + + it('should handle floating point numbers', () => { + const context = createContext({ rate: 0.15, amount: 1234.56 }); + + expect(evaluator.evaluate({ rate: { gt: 0.1 } }, context)).toBe(true); + expect(evaluator.evaluate({ amount: { eq: 1234.56 } }, context)).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/tests/evaluator/evaluator.test.ts b/tests/evaluator/evaluator.test.ts new file mode 100644 index 0000000..5147697 --- /dev/null +++ b/tests/evaluator/evaluator.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect } from 'vitest'; +import { RuleEvaluator } from '../../src/evaluator'; +import type { Rule } from '../../src/types'; + +describe('RuleEvaluator', () => { + const evaluator = new RuleEvaluator(); + + describe('basic operations', () => { + it('should process a simple set operation', () => { + const rule: Rule = { + $version: '1.0', + name: 'Simple Set Test', + jurisdiction: 'test', + taxpayer_type: 'individual', + author: 'test', + constants: {}, + tables: [], + inputs: { + income: { + type: 'number', + description: 'Annual income' + } + }, + outputs: { + liability: { + type: 'number', + description: 'Tax liability' + } + }, + filing_schedules: [], + flow: [ + { + name: 'Calculate liability', + operations: [ + { + type: 'set', + target: 'liability', + value: '$income' + } + ] + } + ] + }; + + const result = evaluator.evaluate(rule, { income: 50000 }); + expect(result.liability).toBe(50000); + }); + + it('should process arithmetic operations', () => { + const rule: Rule = { + $version: '1.0', + name: 'Arithmetic Test', + jurisdiction: 'test', + taxpayer_type: 'individual', + author: 'test', + constants: { + tax_rate: 0.15 + }, + tables: [], + inputs: { + income: { + type: 'number', + description: 'Annual income' + }, + deductions: { + type: 'number', + description: 'Total deductions' + } + }, + outputs: { + taxable_income: { + type: 'number', + description: 'Taxable income' + }, + liability: { + type: 'number', + description: 'Tax liability' + } + }, + filing_schedules: [], + flow: [ + { + name: 'Calculate taxable income', + operations: [ + { + type: 'set', + target: 'taxable_income', + value: '$income' + }, + { + type: 'subtract', + target: 'taxable_income', + value: '$deductions' + }, + { + type: 'max', + target: 'taxable_income', + value: 0 + } + ] + }, + { + name: 'Calculate liability', + operations: [ + { + type: 'set', + target: 'liability', + value: 'taxable_income' + }, + { + type: 'multiply', + target: 'liability', + value: '$$tax_rate' + } + ] + } + ] + }; + + const result = evaluator.evaluate(rule, { + income: 60000, + deductions: 10000 + }); + + expect(result.taxable_income).toBe(50000); + expect(result.liability).toBe(7500); // 50000 * 0.15 + }); + + it('should handle conditional cases', () => { + const rule: Rule = { + $version: '1.0', + name: 'Conditional Test', + jurisdiction: 'test', + taxpayer_type: 'individual', + author: 'test', + constants: {}, + tables: [], + inputs: { + income: { + type: 'number', + description: 'Annual income' + }, + is_senior: { + type: 'boolean', + description: 'Is senior citizen' + } + }, + outputs: { + exemption: { + type: 'number', + description: 'Tax exemption' + } + }, + filing_schedules: [], + flow: [ + { + name: 'Apply exemption', + cases: [ + { + when: { + is_senior: { eq: true } + }, + operations: [ + { + type: 'set', + target: 'exemption', + value: 20000 + } + ] + }, + { + when: { + is_senior: { eq: false } + }, + operations: [ + { + type: 'set', + target: 'exemption', + value: 10000 + } + ] + } + ] + } + ] + }; + + const seniorResult = evaluator.evaluate(rule, { + income: 50000, + is_senior: true + }); + expect(seniorResult.exemption).toBe(20000); + + const regularResult = evaluator.evaluate(rule, { + income: 50000, + is_senior: false + }); + expect(regularResult.exemption).toBe(10000); + }); + + it('should handle lookup operations with tax brackets', () => { + const rule: Rule = { + $version: '1.0', + name: 'Tax Bracket Test', + jurisdiction: 'test', + taxpayer_type: 'individual', + author: 'test', + constants: {}, + tables: [ + { + name: 'tax_brackets', + brackets: [ + { min: 0, max: 10000, rate: 0.1, base_tax: 0 }, + { min: 10000, max: 50000, rate: 0.2, base_tax: 1000 }, + { min: 50000, max: null, rate: 0.3, base_tax: 9000 } + ] + } + ], + inputs: { + taxable_income: { + type: 'number', + description: 'Taxable income' + } + }, + outputs: { + liability: { + type: 'number', + description: 'Tax liability' + } + }, + filing_schedules: [], + flow: [ + { + name: 'Calculate tax', + operations: [ + { + type: 'lookup', + target: 'liability', + table: 'tax_brackets', + value: '$taxable_income' + } + ] + } + ] + }; + + // Test first bracket (0-10000) + const lowIncomeResult = evaluator.evaluate(rule, { taxable_income: 5000 }); + expect(lowIncomeResult.liability).toBe(500); // 5000 * 0.1 + 0 + + // Test second bracket (10000-50000) + const midIncomeResult = evaluator.evaluate(rule, { taxable_income: 30000 }); + expect(midIncomeResult.liability).toBe(5000); // (30000-10000) * 0.2 + 1000 = 20000 * 0.2 + 1000 = 4000 + 1000 + + // Test third bracket (50000+) + const highIncomeResult = evaluator.evaluate(rule, { taxable_income: 100000 }); + expect(highIncomeResult.liability).toBe(24000); // (100000-50000) * 0.3 + 9000 = 50000 * 0.3 + 9000 + }); + }); +}); \ No newline at end of file From aceeceee06c21b7e3da3bc154334f57b54b2cf47 Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 12:36:24 +0800 Subject: [PATCH 04/16] refactor: enhance ExpressionEvaluator API and encapsulation --- src/expression/evaluator.ts | 15 +++++++++++---- src/expression/index.ts | 24 ------------------------ 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/src/expression/evaluator.ts b/src/expression/evaluator.ts index 6ac7c02..0c591e8 100644 --- a/src/expression/evaluator.ts +++ b/src/expression/evaluator.ts @@ -7,6 +7,7 @@ import type { NumberLiteral, ParsedExpression, } from './parser'; +import { ExpressionParser } from './parser'; import { type FunctionDefinition, SymbolRegistry } from '@/symbol'; export interface VariableContext { @@ -51,21 +52,27 @@ export class ExpressionEvaluator { } evaluate( - expression: ParsedExpression, + expression: string | ParsedExpression, context: VariableContext = { inputs: {}, constants: {}, calculated: {} } ): number | boolean { try { + // Parse the expression if it's a string + const parsedExpression = + typeof expression === 'string' + ? ExpressionParser.parse(expression) + : expression; + // Clear any previous context symbols and build new ones this.symbolRegistry.clearDynamicSymbols(); this.buildDynamicSymbolRegistry(context); - return this.evaluateExpression(expression, context); + return this.evaluateExpression(parsedExpression, context); } catch (error) { if (error instanceof ExpressionEvaluationError) { throw error; } throw new ExpressionEvaluationError( `Evaluation failed: ${error instanceof Error ? error.message : String(error)}`, - expression, + typeof expression === 'string' ? undefined : expression, context ); } @@ -377,7 +384,7 @@ export class ExpressionEvaluator { } } - createContext( + private createContext( inputs: Record = {}, constants: Record = {}, calculated: Record = {} diff --git a/src/expression/index.ts b/src/expression/index.ts index e8513d6..451a4e1 100644 --- a/src/expression/index.ts +++ b/src/expression/index.ts @@ -19,27 +19,3 @@ export { isIdentifier, isRuleOnlyIdentifier, } from './identifiers'; - -import { - ExpressionEvaluator, - type ExpressionEvaluatorConfig, - type VariableContext, -} from './evaluator'; -import { ExpressionParser } from './parser'; - -export function evaluateExpression( - expression: string, - context: VariableContext = { inputs: {}, constants: {}, calculated: {} }, - evaluatorConfig?: ExpressionEvaluatorConfig -): number | boolean { - const evaluator = new ExpressionEvaluator(evaluatorConfig); - const parsed = ExpressionParser.parse(expression); - return evaluator.evaluate( - parsed, - evaluator.createContext( - context.inputs, - context.constants, - context.calculated - ) - ); -} From 350b896606fa27ddb645943d6536caeec55513ae Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 12:36:41 +0800 Subject: [PATCH 05/16] feat: export new evaluator and types modules --- src/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/index.ts b/src/index.ts index 86ac844..426f00e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,16 @@ export * from './expression'; export * from './validator'; +export * from './types'; + +export { + RuleEvaluationError, + OperationError, + VariableError, + TableError, + RuleEvaluator, + ConditionalEvaluator, + conditionalEvaluator, + OPERATION_REGISTRY, +} from './evaluator'; + +export type { OperationFunction } from './evaluator'; From 437e55c930d6bce3fa052f97e39c46678ff3133c Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 12:37:13 +0800 Subject: [PATCH 06/16] tests: update validator test to use Raw* prefixes --- tests/validator/validator.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/validator/validator.test.ts b/tests/validator/validator.test.ts index c22a30c..66d283f 100644 --- a/tests/validator/validator.test.ts +++ b/tests/validator/validator.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest'; import { validateRule } from '@/validator/validator'; import { RuleValidationError } from '@/validator/errors'; -import type { Rule } from '@/validator/types'; +import type { RawRule } from '../../src/validator/types'; -const validRule: Rule = { +const validRule: RawRule = { $version: '1.0.0', name: 'Test Income Tax', jurisdiction: 'PH', From 8f55f367419b7c9bd8ae518bdf08192ebb67b0fd Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 13:12:54 +0800 Subject: [PATCH 07/16] feat: add support for string literals --- RULES_SPEC.md | 52 ++++++++++++++++++++-- src/evaluator/conditional.ts | 20 ++++++++- src/evaluator/operations.ts | 8 ++++ src/expression/evaluator.ts | 34 ++++++++++---- src/expression/parser.ts | 86 +++++++++++++++++++++++++++++++++--- src/symbol/registry.ts | 4 +- 6 files changed, 184 insertions(+), 20 deletions(-) diff --git a/RULES_SPEC.md b/RULES_SPEC.md index 9bd2333..0ef9a1f 100644 --- a/RULES_SPEC.md +++ b/RULES_SPEC.md @@ -1068,9 +1068,10 @@ Function calls are used in expressions and conditional rules: ``` function_call = function_name "(" [parameter_list] ")" parameter_list = parameter ("," parameter)* -parameter = variable_reference | function_call | number | boolean +parameter = variable_reference | function_call | number | boolean | string_literal number = [0-9]+ ("." [0-9]+)? boolean = "true" | "false" +string_literal = "'" string_content "'" ``` **Examples:** @@ -1084,13 +1085,58 @@ min(tax_rate, 0.25) **Rules:** - Function names must conform to the `identifier` syntax defined in section 12.1.1 -- Parameters can be variable references, nested function calls, numbers, or booleans +- Parameters can be variable references, nested function calls, numbers, booleans, or string literals - Numbers can be integers or decimals - Booleans are the literals `true` or `false` +- String literals must be enclosed in single quotes (see section 12.1.4 for details) - Nested function calls are allowed - Whitespace around commas and parentheses is optional -#### 12.1.4. Validation Rules +#### 12.1.4. String Literals + +String literals are used in function parameters where string values are required (e.g., table names in lookup functions): + +**Syntax Pattern:** +``` +string_literal = "'" string_content "'" +string_content = (escaped_char | [^'\\])* +escaped_char = "\\" ("'" | "\\" | "n" | "t" | "r" | any_char) +``` + +**Rules:** +- Must be enclosed in single quotes (`'...'`) +- Single quotes are used instead of double quotes to avoid conflicts within JSON strings +- Escape sequences are supported: + - `\'` for literal single quote + - `\\` for literal backslash + - `\n` for newline + - `\t` for tab + - `\r` for carriage return + - Any other character following `\` is treated literally + +**Valid Examples:** +``` +'income_tax_brackets' +'tax_table_2024' +'don\'t include this' +'file\\path\\name' +'line1\nline2' +``` + +**Invalid Examples:** +- `"double_quotes"` (double quotes not allowed) +- `'unterminated` (missing closing quote) +- `missing_quotes` (unquoted strings not allowed) + +**Usage Context:** +- String literals are primarily used as parameters to functions that require string arguments +- They cannot be used in mathematical operations or conditional expressions +- Most commonly used with the `lookup(table_name, value)` function + +**Implementation Note:** +String literals should only be accepted where explicitly required by function signatures. They are not valid in mathematical operations, variable assignments, or conditional logic to maintain type safety. + +#### 12.1.5. Validation Rules Implementations must validate: diff --git a/src/evaluator/conditional.ts b/src/evaluator/conditional.ts index fd9c369..b74ac35 100644 --- a/src/evaluator/conditional.ts +++ b/src/evaluator/conditional.ts @@ -171,10 +171,18 @@ export class ConditionalEvaluator { calculated: context.calculated, }; - return this.expressionEvaluator.evaluate( + const result = this.expressionEvaluator.evaluate( value as string, variableContext ); + + if (typeof result === 'string') { + throw new RuleEvaluationError( + `String values are not allowed in conditional expressions: '${result}'` + ); + } + + return result; } catch (error) { if (error instanceof ExpressionEvaluationError) { throw new RuleEvaluationError(error.message); @@ -210,7 +218,15 @@ export class ConditionalEvaluator { calculated: context.calculated, }; - return this.expressionEvaluator.evaluate(varName, variableContext); + const result = this.expressionEvaluator.evaluate(varName, variableContext); + + if (typeof result === 'string') { + throw new RuleEvaluationError( + `String values are not allowed in conditional expressions: '${result}'` + ); + } + + return result; } catch (error) { if (error instanceof ExpressionEvaluationError) { // Provide a based on variable prefix diff --git a/src/evaluator/operations.ts b/src/evaluator/operations.ts index c55db92..e2f4cdd 100644 --- a/src/evaluator/operations.ts +++ b/src/evaluator/operations.ts @@ -34,6 +34,14 @@ function resolveValue( constants: context.constants, calculated: context.calculated, }); + + if (typeof result === 'string') { + throw new Error( + `String values are not allowed in operations: '${result}'` + ); + } + + return result; } catch (error) { if (error instanceof ExpressionEvaluationError) { throw new Error(error.message); diff --git a/src/expression/evaluator.ts b/src/expression/evaluator.ts index 0c591e8..4c24159 100644 --- a/src/expression/evaluator.ts +++ b/src/expression/evaluator.ts @@ -6,6 +6,7 @@ import type { InputVariableExpression, NumberLiteral, ParsedExpression, + StringLiteral, } from './parser'; import { ExpressionParser } from './parser'; import { type FunctionDefinition, SymbolRegistry } from '@/symbol'; @@ -54,7 +55,7 @@ export class ExpressionEvaluator { evaluate( expression: string | ParsedExpression, context: VariableContext = { inputs: {}, constants: {}, calculated: {} } - ): number | boolean { + ): number | boolean | string { try { // Parse the expression if it's a string const parsedExpression = @@ -140,7 +141,7 @@ export class ExpressionEvaluator { private evaluateExpression( expression: ParsedExpression, context: VariableContext - ): number | boolean { + ): number | boolean | string { switch (expression.type) { case 'number_literal': return this.evaluateNumberLiteral(expression); @@ -148,6 +149,9 @@ export class ExpressionEvaluator { case 'boolean_literal': return this.evaluateBooleanLiteral(expression); + case 'string_literal': + return this.evaluateStringLiteral(expression); + case 'input_variable': return this.evaluateInputVariable(expression, context); @@ -179,6 +183,10 @@ export class ExpressionEvaluator { return expression.value; } + private evaluateStringLiteral(expression: StringLiteral): string { + return expression.value; + } + private evaluateInputVariable( expression: InputVariableExpression, context: VariableContext @@ -253,7 +261,7 @@ export class ExpressionEvaluator { private evaluateCall( expression: CallExpression, context: VariableContext - ): number | boolean { + ): number | boolean | string { const { name, parameters } = expression; // Validate symbol usage - must be used as function @@ -267,7 +275,7 @@ export class ExpressionEvaluator { ); } - const evaluatedParams: (number | boolean)[] = []; + const evaluatedParams: (number | boolean | string)[] = []; for (const param of parameters) { try { evaluatedParams.push(this.evaluateExpression(param, context)); @@ -302,7 +310,7 @@ export class ExpressionEvaluator { const result = func.callback(...(evaluatedParams as unknown[])); if (typeof result !== 'number' && typeof result !== 'boolean') { throw new ExpressionEvaluationError( - `Function '${name}' must return a number or boolean, got ${typeof result}`, + `Function '${name}' must return a number, boolean, or string, got ${typeof result}`, expression, context ); @@ -314,7 +322,7 @@ export class ExpressionEvaluator { private validateFunctionParameters( funcName: string, func: FunctionDefinition, - params: (number | boolean)[], + params: (number | boolean | string)[], expression: CallExpression, context: VariableContext ): void { @@ -339,7 +347,12 @@ export class ExpressionEvaluator { const param = params[i]; const actualType = typeof param; if (actualType !== expectedType) { - const article = expectedType === 'number' ? 'a ' : ''; + const article = + expectedType === 'number' + ? 'a ' + : expectedType === 'string' + ? 'a ' + : ''; throw new ExpressionEvaluationError( `Function '${name}' parameter ${i + 1} must be ${article}${expectedType}, got ${actualType}`, expression, @@ -373,7 +386,12 @@ export class ExpressionEvaluator { const actualType = typeof param; if (actualType !== paramSchema.type) { - const article = paramSchema.type === 'number' ? 'a ' : ''; + const article = + paramSchema.type === 'number' + ? 'a ' + : paramSchema.type === 'string' + ? 'a ' + : ''; throw new ExpressionEvaluationError( `Function '${name}' parameter ${i + 1} must be ${article}${paramSchema.type}, got ${actualType}`, expression, diff --git a/src/expression/parser.ts b/src/expression/parser.ts index 766090f..82578a1 100644 --- a/src/expression/parser.ts +++ b/src/expression/parser.ts @@ -31,13 +31,18 @@ export interface BooleanLiteral { value: boolean; } +export interface StringLiteral { + type: 'string_literal'; + value: string; +} + // Union type for variables export type VariableExpression = | InputVariableExpression | ConstantVariableExpression | CalculatedVariableExpression; -export type LiteralExpression = NumberLiteral | BooleanLiteral; +export type LiteralExpression = NumberLiteral | BooleanLiteral | StringLiteral; export type ParsedExpression = | VariableExpression @@ -331,11 +336,16 @@ export class ExpressionParser { }; } - private parseLiteral(): NumberLiteral | BooleanLiteral { + private parseLiteral(): NumberLiteral | BooleanLiteral | StringLiteral { this.skipWhitespace(); const remaining = this.expression.slice(this.position); + // Check for string literals (single-quoted strings) + if (remaining.startsWith("'")) { + return this.parseStringLiteral(); + } + if (ExpressionParser.NUMBER_REGEX.test(remaining)) { const value = parseFloat(remaining); this.position = this.expression.length; @@ -355,15 +365,81 @@ export class ExpressionParser { } throw new ExpressionParseError( - `Invalid literal value '${remaining}'. Expected a number or boolean (true/false)`, + `Invalid literal value '${remaining}'. Expected a number, boolean (true/false), or single-quoted string`, this.expression, this.position ); } - private parseParameterLiteral(): NumberLiteral | BooleanLiteral { + private parseStringLiteral(): StringLiteral { + this.expectChar("'"); + + let value = ''; + while (this.position < this.expression.length && this.peek() !== "'") { + const char = this.peek(); + if (char === '\\') { + // Handle escape sequences + this.advance(); + if (this.position >= this.expression.length) { + throw new ExpressionParseError( + 'Unterminated string literal: missing closing quote', + this.expression, + this.position + ); + } + const escaped = this.peek(); + switch (escaped) { + case "'": + value += "'"; + break; + case '\\': + value += '\\'; + break; + case 'n': + value += '\n'; + break; + case 't': + value += '\t'; + break; + case 'r': + value += '\r'; + break; + default: + value += escaped; + } + this.advance(); + } else { + value += char; + this.advance(); + } + } + + if (this.position >= this.expression.length) { + throw new ExpressionParseError( + 'Unterminated string literal: missing closing quote', + this.expression, + this.position + ); + } + + this.expectChar("'"); + return { + type: 'string_literal', + value, + }; + } + + private parseParameterLiteral(): + | NumberLiteral + | BooleanLiteral + | StringLiteral { this.skipWhitespace(); + // Check for string literals first + if (this.peek() === "'") { + return this.parseStringLiteral(); + } + let end = this.position; while (end < this.expression.length) { const char = this.expression[end]; @@ -393,7 +469,7 @@ export class ExpressionParser { } throw new ExpressionParseError( - `Invalid literal value '${literalText}'. Expected a number or boolean (true/false)`, + `Invalid literal value '${literalText}'. Expected a number, boolean (true/false), or single-quoted string`, this.expression, this.position ); diff --git a/src/symbol/registry.ts b/src/symbol/registry.ts index e7ceae1..725e38d 100644 --- a/src/symbol/registry.ts +++ b/src/symbol/registry.ts @@ -22,9 +22,9 @@ export interface FunctionDefinition { export interface ParameterSchema { name?: string; - type: 'number' | 'boolean' | 'array'; + type: 'number' | 'boolean' | 'string' | 'array'; items?: { - type: 'number' | 'boolean'; + type: 'number' | 'boolean' | 'string'; }; required?: boolean; } From 8b22ad84aaa3d536ad098b1d8193132f50f9116e Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 13:30:28 +0800 Subject: [PATCH 08/16] feat: restructure FunctionDefinition callback signature to include context, update function --- src/expression/builtins.ts | 43 +++++-- src/expression/evaluator.ts | 185 ++++++++++++++++++++--------- src/symbol/index.ts | 1 + src/symbol/registry.ts | 7 +- tests/expression/evaluator.test.ts | 17 +-- 5 files changed, 178 insertions(+), 75 deletions(-) diff --git a/src/expression/builtins.ts b/src/expression/builtins.ts index ff511ee..51cd755 100644 --- a/src/expression/builtins.ts +++ b/src/expression/builtins.ts @@ -1,13 +1,18 @@ -import type { FunctionDefinition } from '@/symbol'; +import type { FunctionDefinition, FunctionContext } from '@/symbol'; +import type { Table } from '@/types'; export const BUILTIN_FUNCTIONS: Record = { diff: { parameters: [ - { type: 'number', required: true }, - { type: 'number', required: true }, + { name: 'a', type: 'number', required: true }, + { name: 'b', type: 'number', required: true }, ], - callback: (a: unknown, b: unknown): unknown => { - return Math.abs((a as number) - (b as number)); + callback: ( + args: Record, + context: FunctionContext + ): unknown => { + const { a, b } = args as { a: number; b: number }; + return Math.abs(a - b); }, }, @@ -20,8 +25,12 @@ export const BUILTIN_FUNCTIONS: Record = { required: false, }, ], - callback: (...args: unknown[]): unknown => { - return (args as number[]).reduce((acc: number, val) => acc + val, 0); + callback: ( + args: Record, + context: FunctionContext + ): unknown => { + const numbers = args.numbers as number[]; + return numbers.reduce((acc: number, val) => acc + val, 0); }, }, @@ -34,9 +43,13 @@ export const BUILTIN_FUNCTIONS: Record = { required: false, }, ], - callback: (...args: unknown[]): unknown => { - if (args.length === 0) return 0; - return Math.max(...(args as number[])); + callback: ( + args: Record, + context: FunctionContext + ): unknown => { + const numbers = args.numbers as number[]; + if (numbers.length === 0) return 0; + return Math.max(...numbers); }, }, @@ -49,9 +62,13 @@ export const BUILTIN_FUNCTIONS: Record = { required: false, }, ], - callback: (...args: unknown[]): unknown => { - if (args.length === 0) return 0; - return Math.min(...(args as number[])); + callback: ( + args: Record, + context: FunctionContext + ): unknown => { + const numbers = args.numbers as number[]; + if (numbers.length === 0) return 0; + return Math.min(...numbers); }, }, diff --git a/src/expression/evaluator.ts b/src/expression/evaluator.ts index 4c24159..0ab22f8 100644 --- a/src/expression/evaluator.ts +++ b/src/expression/evaluator.ts @@ -275,18 +275,10 @@ export class ExpressionEvaluator { ); } - const evaluatedParams: (number | boolean | string)[] = []; - for (const param of parameters) { - try { - evaluatedParams.push(this.evaluateExpression(param, context)); - } catch (error) { - throw new ExpressionEvaluationError( - `Failed to evaluate parameter in function '${name}': ${error instanceof Error ? error.message : String(error)}`, - expression, - context - ); - } - } + const evaluatedParams: Record< + string, + number | boolean | string | (number | boolean | string)[] + > = {}; const func = this.config.builtinFunctions[name]; if (!func) { @@ -297,6 +289,63 @@ export class ExpressionEvaluator { ); } + // Handle variadic parameters (array type) + const schema = func.parameters; + const isVariadic = + schema.length === 1 && + schema[0].name?.startsWith('...') && + schema[0].type === 'array' && + !schema[0].required; + + if (isVariadic) { + // For variadic functions, collect all parameters into an array + const paramName = schema[0].name || 'args'; + const paramValues: (number | boolean | string)[] = []; + + for (const param of parameters) { + try { + paramValues.push(this.evaluateExpression(param, context)); + } catch (error) { + throw new ExpressionEvaluationError( + `Failed to evaluate parameter in function '${name}': ${error instanceof Error ? error.message : String(error)}`, + expression, + context + ); + } + } + // Strip the '...' prefix when storing in args object for cleaner callback access + const cleanParamName = paramName.startsWith('...') + ? paramName.slice(3) + : paramName; + evaluatedParams[cleanParamName] = paramValues; + } else { + // For regular functions, map parameters by name + for (let i = 0; i < parameters.length; i++) { + const paramSchema = schema[i]; + if (!paramSchema) { + throw new ExpressionEvaluationError( + `Function '${name}' received too many parameters`, + expression, + context + ); + } + + const paramName = paramSchema.name || `param${i}`; + try { + evaluatedParams[paramName] = this.evaluateExpression( + parameters[i], + context + ); + } catch (error) { + throw new ExpressionEvaluationError( + `Failed to evaluate parameter '${paramName}' in function '${name}': ${error instanceof Error ? error.message : String(error)}`, + expression, + context + ); + } + } + } + if (typeof func === 'object' && 'parameters' in func) { this.validateFunctionParameters( name, @@ -307,8 +356,14 @@ export class ExpressionEvaluator { ); } - const result = func.callback(...(evaluatedParams as unknown[])); - if (typeof result !== 'number' && typeof result !== 'boolean') { + // Always pass context to the new callback signature + const result = func.callback(evaluatedParams, {}); + + if ( + typeof result !== 'number' && + typeof result !== 'boolean' && + typeof result !== 'string' + ) { throw new ExpressionEvaluationError( `Function '${name}' must return a number, boolean, or string, got ${typeof result}`, expression, @@ -322,7 +377,10 @@ export class ExpressionEvaluator { private validateFunctionParameters( funcName: string, func: FunctionDefinition, - params: (number | boolean | string)[], + params: Record< + string, + number | boolean | string | (number | boolean | string)[] + >, expression: CallExpression, context: VariableContext ): void { @@ -333,8 +391,24 @@ export class ExpressionEvaluator { schema.length === 1 && schema[0].type === 'array' && !schema[0].required; if (isVariadic) { - // For variadic functions, all parameters must match the array items type - const expectedType = schema[0].items?.type; + // For variadic functions, validate the array parameter + const paramSchema = schema[0]; + const paramName = paramSchema.name || 'args'; + // Use the stripped parameter name to access the value + const cleanParamName = paramName.startsWith('...') + ? paramName.slice(3) + : paramName; + const paramValue = params[cleanParamName]; + + if (!Array.isArray(paramValue)) { + throw new ExpressionEvaluationError( + `Function '${name}' parameter '${paramName}' must be an array`, + expression, + context + ); + } + + const expectedType = paramSchema.items?.type; if (!expectedType) { throw new ExpressionEvaluationError( `Function '${name}' array parameter missing items type definition`, @@ -343,57 +417,62 @@ export class ExpressionEvaluator { ); } - for (let i = 0; i < params.length; i++) { - const param = params[i]; - const actualType = typeof param; + for (let i = 0; i < paramValue.length; i++) { + const item = paramValue[i]; + const actualType = typeof item; if (actualType !== expectedType) { const article = - expectedType === 'number' - ? 'a ' - : expectedType === 'string' - ? 'a ' - : ''; + expectedType === 'number' || expectedType === 'string' ? 'a ' : ''; throw new ExpressionEvaluationError( - `Function '${name}' parameter ${i + 1} must be ${article}${expectedType}, got ${actualType}`, + `Function '${name}' parameter '${paramName}[${i}]' must be ${article}${expectedType}, got ${actualType}`, expression, context ); } } } else { - const requiredParamCount = schema.filter((p) => p.required).length; - - if (params.length < requiredParamCount) { - throw new ExpressionEvaluationError( - `Function '${name}' requires at least ${requiredParamCount} parameters, got ${params.length}`, - expression, - context - ); - } - - if (params.length > schema.length) { - throw new ExpressionEvaluationError( - `Function '${name}' accepts at most ${schema.length} parameters, got ${params.length}`, - expression, - context - ); + // Validate required parameters are present + const requiredParams = schema.filter((p) => p.required); + for (const paramSchema of requiredParams) { + const paramName = paramSchema.name || 'unknown'; + if (!(paramName in params)) { + throw new ExpressionEvaluationError( + `Function '${name}' missing required parameter '${paramName}'`, + expression, + context + ); + } } - // Validate each parameter type - for (let i = 0; i < params.length; i++) { - const param = params[i]; - const paramSchema = schema[i]; - const actualType = typeof param; + // Validate parameter types + for (const paramSchema of schema) { + const paramName = paramSchema.name || 'unknown'; + if (paramName in params) { + const paramValue = params[paramName]; + const actualType = typeof paramValue; - if (actualType !== paramSchema.type) { - const article = - paramSchema.type === 'number' - ? 'a ' - : paramSchema.type === 'string' + if (actualType !== paramSchema.type) { + const article = + paramSchema.type === 'number' || paramSchema.type === 'string' ? 'a ' : ''; + throw new ExpressionEvaluationError( + `Function '${name}' parameter '${paramName}' must be ${article}${paramSchema.type}, got ${actualType}`, + expression, + context + ); + } + } + } + + // Check for unexpected parameters + const expectedParamNames = new Set( + schema.map((p) => p.name).filter(Boolean) + ); + for (const paramName of Object.keys(params)) { + if (!expectedParamNames.has(paramName)) { throw new ExpressionEvaluationError( - `Function '${name}' parameter ${i + 1} must be ${article}${paramSchema.type}, got ${actualType}`, + `Function '${name}' received unexpected parameter '${paramName}'`, expression, context ); diff --git a/src/symbol/index.ts b/src/symbol/index.ts index c3d2fa0..51381fa 100644 --- a/src/symbol/index.ts +++ b/src/symbol/index.ts @@ -1,6 +1,7 @@ export type { BuiltinSymbols, FunctionDefinition, + FunctionContext, ParameterSchema, SymbolInfo, VariableContext, diff --git a/src/symbol/registry.ts b/src/symbol/registry.ts index 725e38d..b35f1e2 100644 --- a/src/symbol/registry.ts +++ b/src/symbol/registry.ts @@ -15,9 +15,14 @@ export interface BuiltinSymbols { variables?: Record; } +export interface FunctionContext {} + export interface FunctionDefinition { parameters: ParameterSchema[]; - callback: (...args: unknown[]) => unknown; + callback: ( + args: Record, + context: FunctionContext + ) => unknown; } export interface ParameterSchema { diff --git a/tests/expression/evaluator.test.ts b/tests/expression/evaluator.test.ts index 8aa5827..234306c 100644 --- a/tests/expression/evaluator.test.ts +++ b/tests/expression/evaluator.test.ts @@ -210,7 +210,7 @@ describe('ExpressionEvaluator', () => { expect(() => evaluator.evaluate(expr)).toThrow( expect.objectContaining({ message: expect.stringContaining( - 'parameter 1 must be a number, got boolean' + "parameter 'a' must be a number, got boolean" ), }) ); @@ -247,7 +247,7 @@ describe('ExpressionEvaluator', () => { expect(() => evaluator.evaluate(expr)).toThrow( expect.objectContaining({ message: expect.stringContaining( - 'parameter 1 must be a number, got boolean' + "parameter '...numbers[0]' must be a number, got boolean" ), }) ); @@ -292,7 +292,7 @@ describe('ExpressionEvaluator', () => { expect(() => evaluator.evaluate(expr)).toThrow( expect.objectContaining({ message: expect.stringContaining( - 'parameter 1 must be a number, got boolean' + "parameter '...numbers[0]' must be a number, got boolean" ), }) ); @@ -323,7 +323,7 @@ describe('ExpressionEvaluator', () => { expect(() => evaluator.evaluate(expr)).toThrow( expect.objectContaining({ message: expect.stringContaining( - 'parameter 1 must be a number, got boolean' + "parameter '...numbers[0]' must be a number, got boolean" ), }) ); @@ -348,7 +348,7 @@ describe('ExpressionEvaluator', () => { expect(() => evaluator.evaluate(expr)).toThrow( expect.objectContaining({ message: expect.stringContaining( - 'parameter 1 must be a number, got boolean' + "parameter 'value' must be a number, got boolean" ), }) ); @@ -457,9 +457,10 @@ describe('ExpressionEvaluator', () => { const customEvaluator = new ExpressionEvaluator({ builtinFunctions: { double: { - parameters: [{ name: 'x', type: 'number' }], - callback: (num: unknown) => { - return (num as number) * 2; + parameters: [{ name: 'x', type: 'number', required: true }], + callback: (args: Record) => { + const { x } = args as { x: number }; + return x * 2; }, }, }, From aeb5d979e2cf3af4f97afdd94ff92b2a6d48e896 Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 13:31:03 +0800 Subject: [PATCH 09/16] fix: update expression function list based on updated spec --- RULES_SPEC.md | 2 ++ src/expression/builtins.ts | 58 ++++++++++++++++++++++++++++++++----- src/expression/evaluator.ts | 3 +- src/symbol/registry.ts | 4 ++- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/RULES_SPEC.md b/RULES_SPEC.md index 0ef9a1f..adaf209 100644 --- a/RULES_SPEC.md +++ b/RULES_SPEC.md @@ -1081,6 +1081,8 @@ sum($income1, $income2, $income3) max(taxable_income, 0) round(liability, 2) min(tax_rate, 0.25) +lookup('income_tax_brackets', taxable_income) +lookup('tax_table_2024', $gross_income) ``` **Rules:** diff --git a/src/expression/builtins.ts b/src/expression/builtins.ts index 51cd755..b28e821 100644 --- a/src/expression/builtins.ts +++ b/src/expression/builtins.ts @@ -74,14 +74,58 @@ export const BUILTIN_FUNCTIONS: Record = { round: { parameters: [ - { type: 'number', required: true }, - { type: 'number', required: false }, // decimals parameter is optional + { name: 'value', type: 'number', required: true }, + { name: 'decimals', type: 'number', required: false }, // decimals parameter is optional ], - callback: (value: unknown, decimals: unknown = 0): unknown => { - const numValue = value as number; - const numDecimals = decimals as number; - const factor = 10 ** numDecimals; - return Math.round(numValue * factor) / factor; + callback: ( + args: Record, + context: FunctionContext + ): unknown => { + const { value, decimals = 0 } = args as { + value: number; + decimals?: number; + }; + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; + }, + }, + + lookup: { + parameters: [ + { name: 'tableName', type: 'string', required: true }, // table name + { name: 'value', type: 'number', required: true }, // value to lookup + ], + callback: ( + args: Record, + context: FunctionContext + ): unknown => { + const { tableName, value } = args as { tableName: string; value: number }; + + if (!context.tables) { + throw new Error('Tables context not available for lookup function'); + } + + const table = context.tables[tableName] as Table; + if (!table) { + throw new Error(`Table '${tableName}' not found`); + } + + // Find the bracket where value falls between min and max + for (const bracket of table.brackets) { + const min = bracket.min; + const max = + bracket.max === null ? Number.MAX_SAFE_INTEGER : bracket.max; + + if (value >= min && value <= max) { + // Calculate tax for the amount within this bracket + const taxableInBracket = value - min; + const taxInBracket = taxableInBracket * bracket.rate; + return bracket.base_tax + taxInBracket; + } + } + + // If no bracket found, return 0 + return 0; }, }, }; diff --git a/src/expression/evaluator.ts b/src/expression/evaluator.ts index 0ab22f8..281791d 100644 --- a/src/expression/evaluator.ts +++ b/src/expression/evaluator.ts @@ -15,6 +15,7 @@ export interface VariableContext { inputs: Record; constants: Record; calculated: Record; + tables?: Record; } export interface ExpressionEvaluatorConfig { @@ -357,7 +358,7 @@ export class ExpressionEvaluator { } // Always pass context to the new callback signature - const result = func.callback(evaluatedParams, {}); + const result = func.callback(evaluatedParams, { tables: context.tables }); if ( typeof result !== 'number' && diff --git a/src/symbol/registry.ts b/src/symbol/registry.ts index b35f1e2..35587ce 100644 --- a/src/symbol/registry.ts +++ b/src/symbol/registry.ts @@ -15,7 +15,9 @@ export interface BuiltinSymbols { variables?: Record; } -export interface FunctionContext {} +export interface FunctionContext { + tables?: Record; +} export interface FunctionDefinition { parameters: ParameterSchema[]; From ef7c662d7682d4c30ec3718d00cef030a182523c Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 13:34:15 +0800 Subject: [PATCH 10/16] fix: update import to use aliased imports --- src/evaluator/conditional.ts | 8 +-- src/evaluator/errors.ts | 2 +- src/evaluator/evaluator.ts | 3 +- src/evaluator/operations.ts | 8 +-- src/validator/schemas/conditions.ts | 2 +- src/validator/schemas/metadata.ts | 2 +- src/validator/schemas/operations.ts | 2 +- src/validator/schemas/schedules.ts | 2 +- src/validator/schemas/tables.ts | 2 +- src/validator/schemas/variables.ts | 2 +- tests/evaluator/conditional.test.ts | 84 ++++++++++++++--------------- tests/evaluator/evaluator.test.ts | 26 ++++----- tests/validator/validator.test.ts | 2 +- 13 files changed, 68 insertions(+), 77 deletions(-) diff --git a/src/evaluator/conditional.ts b/src/evaluator/conditional.ts index b74ac35..5132d8b 100644 --- a/src/evaluator/conditional.ts +++ b/src/evaluator/conditional.ts @@ -3,13 +3,9 @@ import type { ComparisonCondition, LogicalCondition, EvaluationContext, -} from '../types'; +} from '@/types'; import { RuleEvaluationError } from './errors'; -import { - ExpressionEvaluator, - ExpressionEvaluationError, - ExpressionParser, -} from '../expression'; +import { ExpressionEvaluator, ExpressionEvaluationError } from '@/expression'; export class ConditionalEvaluator { private expressionEvaluator: ExpressionEvaluator; diff --git a/src/evaluator/errors.ts b/src/evaluator/errors.ts index 826ad7b..8bc15e9 100644 --- a/src/evaluator/errors.ts +++ b/src/evaluator/errors.ts @@ -1,4 +1,4 @@ -import type { Operation, Rule, EvaluationContext } from '../types'; +import type { Operation, Rule, EvaluationContext } from '@/types'; export class RuleEvaluationError extends Error { constructor( diff --git a/src/evaluator/evaluator.ts b/src/evaluator/evaluator.ts index 5c41a29..cd78a34 100644 --- a/src/evaluator/evaluator.ts +++ b/src/evaluator/evaluator.ts @@ -2,8 +2,7 @@ import type { Rule, EvaluationContext, Operation, FlowStep } from '@/types'; import { RuleEvaluationError, OperationError } from './errors'; import { OPERATION_REGISTRY } from './operations'; import { ConditionalEvaluator } from './conditional'; -import { ExpressionEvaluator } from '../expression'; -import { SymbolRegistry } from '../symbol'; +import { ExpressionEvaluator } from '@/expression'; export class RuleEvaluator { private conditionalEvaluator: ConditionalEvaluator; diff --git a/src/evaluator/operations.ts b/src/evaluator/operations.ts index e2f4cdd..f2b34f8 100644 --- a/src/evaluator/operations.ts +++ b/src/evaluator/operations.ts @@ -5,13 +5,9 @@ import type { ArithmeticOperation, MinMaxOperation, LookupOperation, -} from '../types'; +} from '@/types'; import { OperationError, TableError } from './errors'; -import { - ExpressionEvaluator, - ExpressionEvaluationError, - ExpressionParser, -} from '../expression'; +import { ExpressionEvaluator, ExpressionEvaluationError } from '@/expression'; export type OperationFunction = ( operation: Operation, diff --git a/src/validator/schemas/conditions.ts b/src/validator/schemas/conditions.ts index c3ee255..0aa1255 100644 --- a/src/validator/schemas/conditions.ts +++ b/src/validator/schemas/conditions.ts @@ -5,7 +5,7 @@ import type { LogicalExpression, } from '../types'; import type { ValidationIssue } from '../errors'; -import { COMPARISON_OPERATORS, LOGICAL_OPERATORS } from '../../types'; +import { COMPARISON_OPERATORS, LOGICAL_OPERATORS } from '@/types'; function validateComparisonOperator( operator: ComparisonOperator, diff --git a/src/validator/schemas/metadata.ts b/src/validator/schemas/metadata.ts index 906ae92..889c743 100644 --- a/src/validator/schemas/metadata.ts +++ b/src/validator/schemas/metadata.ts @@ -1,5 +1,5 @@ import type { RawRule, ValidatorConfig } from '../types'; -import { VALID_TAXPAYER_TYPES } from '../../types'; +import { VALID_TAXPAYER_TYPES } from '@/types'; import type { ValidationIssue } from '../errors'; const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; diff --git a/src/validator/schemas/operations.ts b/src/validator/schemas/operations.ts index dead13b..2e9fdc2 100644 --- a/src/validator/schemas/operations.ts +++ b/src/validator/schemas/operations.ts @@ -5,7 +5,7 @@ import type { RawConditionalCase, } from '../types'; import type { ValidationIssue } from '../errors'; -import { VALID_OPERATION_TYPES } from '../../types'; +import { VALID_OPERATION_TYPES } from '@/types'; import { isRuleOnlyIdentifier } from '../../expression/identifiers'; function validateValueReference( diff --git a/src/validator/schemas/schedules.ts b/src/validator/schemas/schedules.ts index c7bc43b..a7ef1ef 100644 --- a/src/validator/schemas/schedules.ts +++ b/src/validator/schemas/schedules.ts @@ -1,6 +1,6 @@ import type { RawRule } from '../types'; import type { ValidationIssue } from '../errors'; -import { VALID_FREQUENCIES } from '../../types'; +import { VALID_FREQUENCIES } from '@/types'; export function validateFilingSchedules(rule: RawRule): ValidationIssue[] { const issues: ValidationIssue[] = []; diff --git a/src/validator/schemas/tables.ts b/src/validator/schemas/tables.ts index 556d912..6d60d53 100644 --- a/src/validator/schemas/tables.ts +++ b/src/validator/schemas/tables.ts @@ -1,6 +1,6 @@ import type { RawRule, RawTableBracket } from '../types'; import type { ValidationIssue } from '../errors'; -import { isRuleOnlyIdentifier } from '../../expression'; +import { isRuleOnlyIdentifier } from '@/expression'; const MAX_TAXABLE_INCOME_REFERENCE = '$$MAX_TAXABLE_INCOME'; diff --git a/src/validator/schemas/variables.ts b/src/validator/schemas/variables.ts index 6c0c368..c3f0843 100644 --- a/src/validator/schemas/variables.ts +++ b/src/validator/schemas/variables.ts @@ -1,6 +1,6 @@ import type { RawRule, RawVariableSchema } from '../types'; import type { ValidationIssue } from '../errors'; -import { isRuleOnlyIdentifier } from '../../expression/identifiers'; +import { isRuleOnlyIdentifier } from '@/expression/identifiers'; const VALID_SCHEMA_TYPES = ['number', 'string', 'boolean', 'array', 'object']; diff --git a/tests/evaluator/conditional.test.ts b/tests/evaluator/conditional.test.ts index 4ed16df..73d4971 100644 --- a/tests/evaluator/conditional.test.ts +++ b/tests/evaluator/conditional.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { ConditionalEvaluator } from '../../src/evaluator/conditional'; -import { ExpressionEvaluator } from '../../src/expression'; -import type { EvaluationContext, Condition } from '../../src/types'; +import { ConditionalEvaluator } from '@/evaluator/conditional'; +import { ExpressionEvaluator } from '@/expression'; +import type { EvaluationContext, Condition } from '@/types'; describe('ConditionalEvaluator', () => { const evaluator = new ConditionalEvaluator(new ExpressionEvaluator()); @@ -20,7 +20,7 @@ describe('ConditionalEvaluator', () => { describe('basic comparisons', () => { it('should evaluate equality conditions', () => { const context = createContext({ age: 25, is_senior: false }); - + expect(evaluator.evaluate({ age: { eq: 25 } }, context)).toBe(true); expect(evaluator.evaluate({ age: { eq: 30 } }, context)).toBe(false); expect(evaluator.evaluate({ is_senior: { eq: false } }, context)).toBe(true); @@ -29,7 +29,7 @@ describe('ConditionalEvaluator', () => { it('should evaluate inequality conditions', () => { const context = createContext({ age: 25, is_senior: false }); - + expect(evaluator.evaluate({ age: { ne: 30 } }, context)).toBe(true); expect(evaluator.evaluate({ age: { ne: 25 } }, context)).toBe(false); expect(evaluator.evaluate({ is_senior: { ne: true } }, context)).toBe(true); @@ -37,12 +37,12 @@ describe('ConditionalEvaluator', () => { it('should evaluate numeric comparison conditions', () => { const context = createContext({ income: 50000, age: 25 }); - + expect(evaluator.evaluate({ income: { gt: 40000 } }, context)).toBe(true); expect(evaluator.evaluate({ income: { gt: 60000 } }, context)).toBe(false); expect(evaluator.evaluate({ income: { gte: 50000 } }, context)).toBe(true); expect(evaluator.evaluate({ income: { gte: 50001 } }, context)).toBe(false); - + expect(evaluator.evaluate({ age: { lt: 30 } }, context)).toBe(true); expect(evaluator.evaluate({ age: { lt: 20 } }, context)).toBe(false); expect(evaluator.evaluate({ age: { lte: 25 } }, context)).toBe(true); @@ -53,25 +53,25 @@ describe('ConditionalEvaluator', () => { describe('variable reference resolution', () => { it('should resolve input variables with $ prefix', () => { const context = createContext({ income: 50000 }); - + expect(evaluator.evaluate({ '$income': { eq: 50000 } }, context)).toBe(true); }); it('should resolve constants with $$ prefix', () => { const context = createContext({}, { tax_rate: 0.15 }); - + expect(evaluator.evaluate({ '$$tax_rate': { eq: 0.15 } }, context)).toBe(true); }); it('should resolve calculated variables', () => { const context = createContext({}, {}, { taxable_income: 45000 }); - + expect(evaluator.evaluate({ taxable_income: { gt: 40000 } }, context)).toBe(true); }); it('should resolve variables in comparison values', () => { const context = createContext({ income: 50000 }, { threshold: 45000 }); - + expect(evaluator.evaluate({ income: { gt: '$$threshold' } }, context)).toBe(true); }); }); @@ -79,67 +79,67 @@ describe('ConditionalEvaluator', () => { describe('logical operations', () => { it('should evaluate AND conditions', () => { const context = createContext({ age: 25, income: 50000 }); - + const condition: Condition = { and: [ { age: { gte: 18 } }, { income: { gt: 40000 } } ] }; - + expect(evaluator.evaluate(condition, context)).toBe(true); - + const falseCondition: Condition = { and: [ { age: { gte: 30 } }, { income: { gt: 40000 } } ] }; - + expect(evaluator.evaluate(falseCondition, context)).toBe(false); }); it('should evaluate OR conditions', () => { const context = createContext({ age: 25, income: 30000 }); - + const condition: Condition = { or: [ { age: { gte: 65 } }, { income: { lt: 35000 } } ] }; - + expect(evaluator.evaluate(condition, context)).toBe(true); - + const falseCondition: Condition = { or: [ { age: { gte: 65 } }, { income: { gt: 35000 } } ] }; - + expect(evaluator.evaluate(falseCondition, context)).toBe(false); }); it('should evaluate NOT conditions', () => { const context = createContext({ is_senior: false }); - + const condition: Condition = { not: { is_senior: { eq: true } } }; - + expect(evaluator.evaluate(condition, context)).toBe(true); - + const falseCondition: Condition = { not: { is_senior: { eq: false } } }; - + expect(evaluator.evaluate(falseCondition, context)).toBe(false); }); it('should evaluate nested logical conditions', () => { const context = createContext({ age: 25, income: 50000, is_student: false }); - + const condition: Condition = { and: [ { @@ -151,9 +151,9 @@ describe('ConditionalEvaluator', () => { { income: { lt: 60000 } } ] }; - + expect(evaluator.evaluate(condition, context)).toBe(false); - + const trueCondition: Condition = { and: [ { @@ -165,7 +165,7 @@ describe('ConditionalEvaluator', () => { { income: { lt: 60000 } } ] }; - + expect(evaluator.evaluate(trueCondition, context)).toBe(true); }); }); @@ -173,15 +173,15 @@ describe('ConditionalEvaluator', () => { describe('utility methods', () => { it('should evaluate all conditions with AND logic', () => { const context = createContext({ age: 25, income: 50000 }); - + const conditions: Condition[] = [ { age: { gte: 18 } }, { income: { gt: 40000 } }, { age: { lt: 30 } } ]; - + expect(evaluator.evaluateAll(conditions, context)).toBe(true); - + const conditionsWithExtra: Condition[] = [ ...conditions, { income: { gt: 60000 } } @@ -191,20 +191,20 @@ describe('ConditionalEvaluator', () => { it('should evaluate any conditions with OR logic', () => { const context = createContext({ age: 25, income: 30000 }); - + const conditions: Condition[] = [ { age: { gte: 65 } }, { income: { gt: 60000 } }, { age: { eq: 25 } } ]; - + expect(evaluator.evaluateAny(conditions, context)).toBe(true); - + const falseConditions: Condition[] = [ { age: { gte: 65 } }, { income: { gt: 60000 } } ]; - + expect(evaluator.evaluateAny(falseConditions, context)).toBe(false); }); }); @@ -212,7 +212,7 @@ describe('ConditionalEvaluator', () => { describe('error handling', () => { it('should throw error for missing input variables', () => { const context = createContext({}); - + expect(() => { evaluator.evaluate({ '$income': { eq: 50000 } }, context); }).toThrow("Input variable 'income' not found"); @@ -220,7 +220,7 @@ describe('ConditionalEvaluator', () => { it('should throw error for missing constants', () => { const context = createContext({}); - + expect(() => { evaluator.evaluate({ '$$tax_rate': { eq: 0.15 } }, context); }).toThrow("Constant 'tax_rate' not found"); @@ -228,7 +228,7 @@ describe('ConditionalEvaluator', () => { it('should throw error for missing variables', () => { const context = createContext({}); - + expect(() => { evaluator.evaluate({ unknown_var: { eq: 100 } }, context); }).toThrow("Variable 'unknown_var' not found"); @@ -236,7 +236,7 @@ describe('ConditionalEvaluator', () => { it('should throw error for invalid numeric comparisons', () => { const context = createContext({ income: 50000 }, { is_active: false }); - + expect(() => { evaluator.evaluate({ income: { gt: '$$is_active' } }, context); }).toThrow("Cannot compare number with boolean using 'gt'"); @@ -246,14 +246,14 @@ describe('ConditionalEvaluator', () => { describe('edge cases', () => { it('should handle empty conditions gracefully', () => { const context = createContext(); - + expect(evaluator.evaluateAll([], context)).toBe(true); expect(evaluator.evaluateAny([], context)).toBe(false); }); it('should handle zero and negative numbers', () => { const context = createContext({ balance: -100, age: 0 }); - + expect(evaluator.evaluate({ balance: { lt: 0 } }, context)).toBe(true); expect(evaluator.evaluate({ age: { eq: 0 } }, context)).toBe(true); expect(evaluator.evaluate({ age: { gte: 0 } }, context)).toBe(true); @@ -261,9 +261,9 @@ describe('ConditionalEvaluator', () => { it('should handle floating point numbers', () => { const context = createContext({ rate: 0.15, amount: 1234.56 }); - + expect(evaluator.evaluate({ rate: { gt: 0.1 } }, context)).toBe(true); expect(evaluator.evaluate({ amount: { eq: 1234.56 } }, context)).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/tests/evaluator/evaluator.test.ts b/tests/evaluator/evaluator.test.ts index 5147697..d06351e 100644 --- a/tests/evaluator/evaluator.test.ts +++ b/tests/evaluator/evaluator.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { RuleEvaluator } from '../../src/evaluator'; -import type { Rule } from '../../src/types'; +import { RuleEvaluator } from '@/evaluator'; +import type { Rule } from '@/types'; describe('RuleEvaluator', () => { const evaluator = new RuleEvaluator(); @@ -117,11 +117,11 @@ describe('RuleEvaluator', () => { ] }; - const result = evaluator.evaluate(rule, { - income: 60000, - deductions: 10000 + const result = evaluator.evaluate(rule, { + income: 60000, + deductions: 10000 }); - + expect(result.taxable_income).toBe(50000); expect(result.liability).toBe(7500); // 50000 * 0.15 }); @@ -185,15 +185,15 @@ describe('RuleEvaluator', () => { ] }; - const seniorResult = evaluator.evaluate(rule, { - income: 50000, - is_senior: true + const seniorResult = evaluator.evaluate(rule, { + income: 50000, + is_senior: true }); expect(seniorResult.exemption).toBe(20000); - const regularResult = evaluator.evaluate(rule, { - income: 50000, - is_senior: false + const regularResult = evaluator.evaluate(rule, { + income: 50000, + is_senior: false }); expect(regularResult.exemption).toBe(10000); }); @@ -257,4 +257,4 @@ describe('RuleEvaluator', () => { expect(highIncomeResult.liability).toBe(24000); // (100000-50000) * 0.3 + 9000 = 50000 * 0.3 + 9000 }); }); -}); \ No newline at end of file +}); diff --git a/tests/validator/validator.test.ts b/tests/validator/validator.test.ts index 66d283f..d065c1d 100644 --- a/tests/validator/validator.test.ts +++ b/tests/validator/validator.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { validateRule } from '@/validator/validator'; import { RuleValidationError } from '@/validator/errors'; -import type { RawRule } from '../../src/validator/types'; +import type { RawRule } from '@/validator/types'; const validRule: RawRule = { $version: '1.0.0', From af0cb23e8a498fddd278df669ba61efdab8011ef Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 13:47:34 +0800 Subject: [PATCH 11/16] feat: add tables, fmt --- src/evaluator/conditional.ts | 9 ++++++--- src/evaluator/evaluator.ts | 16 ++++------------ src/evaluator/operations.ts | 5 +++-- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/evaluator/conditional.ts b/src/evaluator/conditional.ts index 5132d8b..a0fee38 100644 --- a/src/evaluator/conditional.ts +++ b/src/evaluator/conditional.ts @@ -214,14 +214,17 @@ export class ConditionalEvaluator { calculated: context.calculated, }; - const result = this.expressionEvaluator.evaluate(varName, variableContext); - + const result = this.expressionEvaluator.evaluate( + varName, + variableContext + ); + if (typeof result === 'string') { throw new RuleEvaluationError( `String values are not allowed in conditional expressions: '${result}'` ); } - + return result; } catch (error) { if (error instanceof ExpressionEvaluationError) { diff --git a/src/evaluator/evaluator.ts b/src/evaluator/evaluator.ts index cd78a34..340069f 100644 --- a/src/evaluator/evaluator.ts +++ b/src/evaluator/evaluator.ts @@ -2,17 +2,14 @@ import type { Rule, EvaluationContext, Operation, FlowStep } from '@/types'; import { RuleEvaluationError, OperationError } from './errors'; import { OPERATION_REGISTRY } from './operations'; import { ConditionalEvaluator } from './conditional'; -import { ExpressionEvaluator } from '@/expression'; +import { ExpressionEvaluator, ExpressionEvaluatorConfig } from '@/expression'; export class RuleEvaluator { private conditionalEvaluator: ConditionalEvaluator; private expressionEvaluator: ExpressionEvaluator; - constructor() { - // Create expression evaluator with default configuration - this.expressionEvaluator = new ExpressionEvaluator(); - - // Create conditional evaluator with shared expression evaluator + constructor(exprEvalConfig?: ExpressionEvaluatorConfig) { + this.expressionEvaluator = new ExpressionEvaluator(exprEvalConfig); this.conditionalEvaluator = new ConditionalEvaluator( this.expressionEvaluator ); @@ -23,19 +20,14 @@ export class RuleEvaluator { inputs: Record = {} ): Record { try { - // Initialize evaluation context const context = this.createContext(rule, inputs); - - // Process flow steps const finalContext = this.processFlow(rule.flow, context); - - // Return only the calculated variables that match rule outputs const results: Record = {}; + for (const outputName of Object.keys(rule.outputs)) { if (outputName in finalContext.calculated) { results[outputName] = finalContext.calculated[outputName]; } else { - // Initialize missing outputs to 0 or false based on their type const outputType = rule.outputs[outputName].type; results[outputName] = outputType === 'boolean' ? false : 0; } diff --git a/src/evaluator/operations.ts b/src/evaluator/operations.ts index f2b34f8..b769500 100644 --- a/src/evaluator/operations.ts +++ b/src/evaluator/operations.ts @@ -25,15 +25,16 @@ function resolveValue( } try { - return expressionEvaluator.evaluate(value as string, { + const result = expressionEvaluator.evaluate(value as string, { inputs: context.inputs, constants: context.constants, calculated: context.calculated, + tables: context.tables, }); if (typeof result === 'string') { throw new Error( - `String values are not allowed in operations: '${result}'` + `String values are not allowed in operations: '${result}'. This should not happen. Please report a bug.` ); } From 6d97cd41f3d56986300573cfe3e07d0b2d12802f Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 13:55:51 +0800 Subject: [PATCH 12/16] refactor: use ExpressionEvaluator directly for ConditionalEvaluator. fix tests --- src/evaluator/conditional.ts | 86 +++++++---------------------- src/evaluator/index.ts | 4 +- tests/evaluator/conditional.test.ts | 28 +++++----- tests/evaluator/evaluator.test.ts | 4 +- 4 files changed, 37 insertions(+), 85 deletions(-) diff --git a/src/evaluator/conditional.ts b/src/evaluator/conditional.ts index a0fee38..6c95ef2 100644 --- a/src/evaluator/conditional.ts +++ b/src/evaluator/conditional.ts @@ -47,9 +47,27 @@ export class ConditionalEvaluator { const varCondition = condition as { [variable: string]: ComparisonCondition; }; + + const variableContext = { + inputs: context.inputs, + constants: context.constants, + calculated: context.calculated, + tables: context.tables, + }; + for (const [varName, comparison] of Object.entries(varCondition)) { - const varValue = this.resolveVariableValue(varName, context); - return this.evaluateComparison(varValue, comparison, context); + const result = this.expressionEvaluator.evaluate( + varName, + variableContext + ); + + if (typeof result === 'string') { + throw new RuleEvaluationError( + `String values are not allowed in conditional expressions: '${result}'` + ); + } + + return this.evaluateComparison(result, comparison, context); } return false; @@ -186,68 +204,4 @@ export class ConditionalEvaluator { throw error; } } - - private resolveVariableValue( - varName: string, - context: EvaluationContext - ): number | boolean { - // First check if variable exists directly in calculated - if (varName in context.calculated) { - return context.calculated[varName]; - } - - // Then check if it exists in inputs - if (varName in context.inputs) { - return context.inputs[varName]; - } - - // Then check if it exists in constants - if (varName in context.constants) { - return context.constants[varName]; - } - - // If not found directly, try to evaluate as expression with prefixes - try { - const variableContext = { - inputs: context.inputs, - constants: context.constants, - calculated: context.calculated, - }; - - const result = this.expressionEvaluator.evaluate( - varName, - variableContext - ); - - if (typeof result === 'string') { - throw new RuleEvaluationError( - `String values are not allowed in conditional expressions: '${result}'` - ); - } - - return result; - } catch (error) { - if (error instanceof ExpressionEvaluationError) { - // Provide a based on variable prefix - if (varName.startsWith('$$')) { - throw new RuleEvaluationError( - `Constant '${varName.slice(2)}' not found` - ); - } else if (varName.startsWith('$')) { - throw new RuleEvaluationError( - `Input variable '${varName.slice(1)}' not found` - ); - } else { - throw new RuleEvaluationError(`Variable '${varName}' not found`); - } - } - throw error; - } - } } - -// Export a default instance for convenience -// Create a default conditional evaluator instance -export const conditionalEvaluator = new ConditionalEvaluator( - new ExpressionEvaluator() -); diff --git a/src/evaluator/index.ts b/src/evaluator/index.ts index ad8f5e8..1515db8 100644 --- a/src/evaluator/index.ts +++ b/src/evaluator/index.ts @@ -1,5 +1,3 @@ -// Types are exported from the shared types module - export { RuleEvaluationError, OperationError, @@ -8,7 +6,7 @@ export { } from './errors'; export { RuleEvaluator } from './evaluator'; -export { ConditionalEvaluator, conditionalEvaluator } from './conditional'; +export { ConditionalEvaluator } from './conditional'; export type { OperationFunction } from './operations'; export { OPERATION_REGISTRY } from './operations'; diff --git a/tests/evaluator/conditional.test.ts b/tests/evaluator/conditional.test.ts index 73d4971..6728b05 100644 --- a/tests/evaluator/conditional.test.ts +++ b/tests/evaluator/conditional.test.ts @@ -19,7 +19,7 @@ describe('ConditionalEvaluator', () => { describe('basic comparisons', () => { it('should evaluate equality conditions', () => { - const context = createContext({ age: 25, is_senior: false }); + const context = createContext({}, {}, { age: 25, is_senior: false }); expect(evaluator.evaluate({ age: { eq: 25 } }, context)).toBe(true); expect(evaluator.evaluate({ age: { eq: 30 } }, context)).toBe(false); @@ -28,7 +28,7 @@ describe('ConditionalEvaluator', () => { }); it('should evaluate inequality conditions', () => { - const context = createContext({ age: 25, is_senior: false }); + const context = createContext({}, {}, { age: 25, is_senior: false }); expect(evaluator.evaluate({ age: { ne: 30 } }, context)).toBe(true); expect(evaluator.evaluate({ age: { ne: 25 } }, context)).toBe(false); @@ -36,7 +36,7 @@ describe('ConditionalEvaluator', () => { }); it('should evaluate numeric comparison conditions', () => { - const context = createContext({ income: 50000, age: 25 }); + const context = createContext({}, {}, { income: 50000, age: 25 }); expect(evaluator.evaluate({ income: { gt: 40000 } }, context)).toBe(true); expect(evaluator.evaluate({ income: { gt: 60000 } }, context)).toBe(false); @@ -70,7 +70,7 @@ describe('ConditionalEvaluator', () => { }); it('should resolve variables in comparison values', () => { - const context = createContext({ income: 50000 }, { threshold: 45000 }); + const context = createContext({}, { threshold: 45000 }, { income: 50000 }); expect(evaluator.evaluate({ income: { gt: '$$threshold' } }, context)).toBe(true); }); @@ -78,7 +78,7 @@ describe('ConditionalEvaluator', () => { describe('logical operations', () => { it('should evaluate AND conditions', () => { - const context = createContext({ age: 25, income: 50000 }); + const context = createContext({}, {}, { age: 25, income: 50000 }); const condition: Condition = { and: [ @@ -100,7 +100,7 @@ describe('ConditionalEvaluator', () => { }); it('should evaluate OR conditions', () => { - const context = createContext({ age: 25, income: 30000 }); + const context = createContext({}, {}, { age: 25, income: 30000 }); const condition: Condition = { or: [ @@ -122,7 +122,7 @@ describe('ConditionalEvaluator', () => { }); it('should evaluate NOT conditions', () => { - const context = createContext({ is_senior: false }); + const context = createContext({}, {}, { is_senior: false }); const condition: Condition = { not: { is_senior: { eq: true } } @@ -138,7 +138,7 @@ describe('ConditionalEvaluator', () => { }); it('should evaluate nested logical conditions', () => { - const context = createContext({ age: 25, income: 50000, is_student: false }); + const context = createContext({}, {}, { age: 25, income: 50000, is_student: false }); const condition: Condition = { and: [ @@ -172,7 +172,7 @@ describe('ConditionalEvaluator', () => { describe('utility methods', () => { it('should evaluate all conditions with AND logic', () => { - const context = createContext({ age: 25, income: 50000 }); + const context = createContext({}, {}, { age: 25, income: 50000 }); const conditions: Condition[] = [ { age: { gte: 18 } }, @@ -190,7 +190,7 @@ describe('ConditionalEvaluator', () => { }); it('should evaluate any conditions with OR logic', () => { - const context = createContext({ age: 25, income: 30000 }); + const context = createContext({}, {}, { age: 25, income: 30000 }); const conditions: Condition[] = [ { age: { gte: 65 } }, @@ -231,11 +231,11 @@ describe('ConditionalEvaluator', () => { expect(() => { evaluator.evaluate({ unknown_var: { eq: 100 } }, context); - }).toThrow("Variable 'unknown_var' not found"); + }).toThrow("Calculated variable 'unknown_var' not found"); }); it('should throw error for invalid numeric comparisons', () => { - const context = createContext({ income: 50000 }, { is_active: false }); + const context = createContext({}, { is_active: false }, { income: 50000 }); expect(() => { evaluator.evaluate({ income: { gt: '$$is_active' } }, context); @@ -252,7 +252,7 @@ describe('ConditionalEvaluator', () => { }); it('should handle zero and negative numbers', () => { - const context = createContext({ balance: -100, age: 0 }); + const context = createContext({}, {}, { balance: -100, age: 0 }); expect(evaluator.evaluate({ balance: { lt: 0 } }, context)).toBe(true); expect(evaluator.evaluate({ age: { eq: 0 } }, context)).toBe(true); @@ -260,7 +260,7 @@ describe('ConditionalEvaluator', () => { }); it('should handle floating point numbers', () => { - const context = createContext({ rate: 0.15, amount: 1234.56 }); + const context = createContext({}, {}, { rate: 0.15, amount: 1234.56 }); expect(evaluator.evaluate({ rate: { gt: 0.1 } }, context)).toBe(true); expect(evaluator.evaluate({ amount: { eq: 1234.56 } }, context)).toBe(true); diff --git a/tests/evaluator/evaluator.test.ts b/tests/evaluator/evaluator.test.ts index d06351e..8f660c7 100644 --- a/tests/evaluator/evaluator.test.ts +++ b/tests/evaluator/evaluator.test.ts @@ -158,7 +158,7 @@ describe('RuleEvaluator', () => { cases: [ { when: { - is_senior: { eq: true } + '$is_senior': { eq: true } }, operations: [ { @@ -170,7 +170,7 @@ describe('RuleEvaluator', () => { }, { when: { - is_senior: { eq: false } + '$is_senior': { eq: false } }, operations: [ { From 61e221b773a1c8d074557907c5efec2b14232344 Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 14:00:38 +0800 Subject: [PATCH 13/16] fix: use default exports of evaluator --- src/index.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 426f00e..276e723 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,4 @@ export * from './expression'; export * from './validator'; export * from './types'; - -export { - RuleEvaluationError, - OperationError, - VariableError, - TableError, - RuleEvaluator, - ConditionalEvaluator, - conditionalEvaluator, - OPERATION_REGISTRY, -} from './evaluator'; - -export type { OperationFunction } from './evaluator'; +export * from './evaluator'; From 6d9756679466fb1e7a6ed053ef2ff2f7604f0023 Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 14:05:23 +0800 Subject: [PATCH 14/16] fix: fix and adjust eslint errors --- eslint.config.js | 7 +++++-- src/evaluator/evaluator.ts | 2 +- src/expression/builtins.ts | 10 +++++----- src/expression/evaluator.ts | 2 +- src/symbol/registry.ts | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 8719a3c..cb4078f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,7 +18,10 @@ export default [ }, rules: { ...tseslint.configs.recommended.rules, - '@typescript-eslint/no-unused-vars': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], '@typescript-eslint/explicit-function-return-type': 'warn', '@typescript-eslint/no-explicit-any': 'warn', }, @@ -41,4 +44,4 @@ export default [ '@typescript-eslint/explicit-function-return-type': 'off', }, }, -]; \ No newline at end of file +]; diff --git a/src/evaluator/evaluator.ts b/src/evaluator/evaluator.ts index 340069f..cb2836e 100644 --- a/src/evaluator/evaluator.ts +++ b/src/evaluator/evaluator.ts @@ -90,7 +90,7 @@ export class RuleEvaluator { } // Create tables lookup - const tablesMap: Record = {}; + const tablesMap: Record = {}; for (const table of rule.tables) { tablesMap[table.name] = table; } diff --git a/src/expression/builtins.ts b/src/expression/builtins.ts index b28e821..1199068 100644 --- a/src/expression/builtins.ts +++ b/src/expression/builtins.ts @@ -9,7 +9,7 @@ export const BUILTIN_FUNCTIONS: Record = { ], callback: ( args: Record, - context: FunctionContext + _context: FunctionContext ): unknown => { const { a, b } = args as { a: number; b: number }; return Math.abs(a - b); @@ -27,7 +27,7 @@ export const BUILTIN_FUNCTIONS: Record = { ], callback: ( args: Record, - context: FunctionContext + _context: FunctionContext ): unknown => { const numbers = args.numbers as number[]; return numbers.reduce((acc: number, val) => acc + val, 0); @@ -45,7 +45,7 @@ export const BUILTIN_FUNCTIONS: Record = { ], callback: ( args: Record, - context: FunctionContext + _context: FunctionContext ): unknown => { const numbers = args.numbers as number[]; if (numbers.length === 0) return 0; @@ -64,7 +64,7 @@ export const BUILTIN_FUNCTIONS: Record = { ], callback: ( args: Record, - context: FunctionContext + _context: FunctionContext ): unknown => { const numbers = args.numbers as number[]; if (numbers.length === 0) return 0; @@ -79,7 +79,7 @@ export const BUILTIN_FUNCTIONS: Record = { ], callback: ( args: Record, - context: FunctionContext + _context: FunctionContext ): unknown => { const { value, decimals = 0 } = args as { value: number; diff --git a/src/expression/evaluator.ts b/src/expression/evaluator.ts index 281791d..50cdc41 100644 --- a/src/expression/evaluator.ts +++ b/src/expression/evaluator.ts @@ -15,7 +15,7 @@ export interface VariableContext { inputs: Record; constants: Record; calculated: Record; - tables?: Record; + tables?: Record; } export interface ExpressionEvaluatorConfig { diff --git a/src/symbol/registry.ts b/src/symbol/registry.ts index 35587ce..b8059ea 100644 --- a/src/symbol/registry.ts +++ b/src/symbol/registry.ts @@ -16,7 +16,7 @@ export interface BuiltinSymbols { } export interface FunctionContext { - tables?: Record; + tables?: Record; } export interface FunctionDefinition { From 56ddd3d1314720d87c2ddfffa636f9a107a1d1d6 Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 14:06:55 +0800 Subject: [PATCH 15/16] fix: type error --- src/evaluator/evaluator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/evaluator/evaluator.ts b/src/evaluator/evaluator.ts index cb2836e..eac8133 100644 --- a/src/evaluator/evaluator.ts +++ b/src/evaluator/evaluator.ts @@ -1,4 +1,4 @@ -import type { Rule, EvaluationContext, Operation, FlowStep } from '@/types'; +import type { Rule, EvaluationContext, Operation, FlowStep, Table } from '@/types'; import { RuleEvaluationError, OperationError } from './errors'; import { OPERATION_REGISTRY } from './operations'; import { ConditionalEvaluator } from './conditional'; @@ -90,7 +90,7 @@ export class RuleEvaluator { } // Create tables lookup - const tablesMap: Record = {}; + const tablesMap: Record = {}; for (const table of rule.tables) { tablesMap[table.name] = table; } From e5533ddb38e82dac1c5a3b85d30d22d65c3b43d2 Mon Sep 17 00:00:00 2001 From: Ned Palacios Date: Sat, 27 Sep 2025 14:07:46 +0800 Subject: [PATCH 16/16] fix: fmt --- src/evaluator/evaluator.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/evaluator/evaluator.ts b/src/evaluator/evaluator.ts index eac8133..2d5a4c3 100644 --- a/src/evaluator/evaluator.ts +++ b/src/evaluator/evaluator.ts @@ -1,4 +1,10 @@ -import type { Rule, EvaluationContext, Operation, FlowStep, Table } from '@/types'; +import type { + Rule, + EvaluationContext, + Operation, + FlowStep, + Table, +} from '@/types'; import { RuleEvaluationError, OperationError } from './errors'; import { OPERATION_REGISTRY } from './operations'; import { ConditionalEvaluator } from './conditional';