diff --git a/RULES_SPEC.md b/RULES_SPEC.md index 9bd2333..adaf209 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:** @@ -1080,17 +1081,64 @@ 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:** - 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/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/conditional.ts b/src/evaluator/conditional.ts new file mode 100644 index 0000000..6c95ef2 --- /dev/null +++ b/src/evaluator/conditional.ts @@ -0,0 +1,207 @@ +import type { + Condition, + ComparisonCondition, + LogicalCondition, + EvaluationContext, +} from '@/types'; +import { RuleEvaluationError } from './errors'; +import { ExpressionEvaluator, ExpressionEvaluationError } 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; + }; + + const variableContext = { + inputs: context.inputs, + constants: context.constants, + calculated: context.calculated, + tables: context.tables, + }; + + for (const [varName, comparison] of Object.entries(varCondition)) { + 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; + } + + 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, + }; + + 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); + } + throw error; + } + } +} diff --git a/src/evaluator/errors.ts b/src/evaluator/errors.ts new file mode 100644 index 0000000..8bc15e9 --- /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..2d5a4c3 --- /dev/null +++ b/src/evaluator/evaluator.ts @@ -0,0 +1,169 @@ +import type { + Rule, + EvaluationContext, + Operation, + FlowStep, + Table, +} from '@/types'; +import { RuleEvaluationError, OperationError } from './errors'; +import { OPERATION_REGISTRY } from './operations'; +import { ConditionalEvaluator } from './conditional'; +import { ExpressionEvaluator, ExpressionEvaluatorConfig } from '@/expression'; + +export class RuleEvaluator { + private conditionalEvaluator: ConditionalEvaluator; + private expressionEvaluator: ExpressionEvaluator; + + constructor(exprEvalConfig?: ExpressionEvaluatorConfig) { + this.expressionEvaluator = new ExpressionEvaluator(exprEvalConfig); + this.conditionalEvaluator = new ConditionalEvaluator( + this.expressionEvaluator + ); + } + + evaluate( + rule: Rule, + inputs: Record = {} + ): Record { + try { + const context = this.createContext(rule, inputs); + const finalContext = this.processFlow(rule.flow, context); + const results: Record = {}; + + for (const outputName of Object.keys(rule.outputs)) { + if (outputName in finalContext.calculated) { + results[outputName] = finalContext.calculated[outputName]; + } else { + 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..1515db8 --- /dev/null +++ b/src/evaluator/index.ts @@ -0,0 +1,12 @@ +export { + RuleEvaluationError, + OperationError, + VariableError, + TableError, +} from './errors'; + +export { RuleEvaluator } from './evaluator'; +export { 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..b769500 --- /dev/null +++ b/src/evaluator/operations.ts @@ -0,0 +1,303 @@ +import type { + Operation, + EvaluationContext, + SetOperation, + ArithmeticOperation, + MinMaxOperation, + LookupOperation, +} from '@/types'; +import { OperationError, TableError } from './errors'; +import { ExpressionEvaluator, ExpressionEvaluationError } 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 { + 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}'. This should not happen. Please report a bug.` + ); + } + + return result; + } 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/src/expression/builtins.ts b/src/expression/builtins.ts index ff511ee..1199068 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,22 +62,70 @@ 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); }, }, 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 6ac7c02..50cdc41 100644 --- a/src/expression/evaluator.ts +++ b/src/expression/evaluator.ts @@ -6,13 +6,16 @@ import type { InputVariableExpression, NumberLiteral, ParsedExpression, + StringLiteral, } from './parser'; +import { ExpressionParser } from './parser'; import { type FunctionDefinition, SymbolRegistry } from '@/symbol'; export interface VariableContext { inputs: Record; constants: Record; calculated: Record; + tables?: Record; } export interface ExpressionEvaluatorConfig { @@ -51,21 +54,27 @@ export class ExpressionEvaluator { } evaluate( - expression: ParsedExpression, + expression: string | ParsedExpression, context: VariableContext = { inputs: {}, constants: {}, calculated: {} } - ): number | boolean { + ): number | boolean | string { 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 ); } @@ -133,7 +142,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); @@ -141,6 +150,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); @@ -172,6 +184,10 @@ export class ExpressionEvaluator { return expression.value; } + private evaluateStringLiteral(expression: StringLiteral): string { + return expression.value; + } + private evaluateInputVariable( expression: InputVariableExpression, context: VariableContext @@ -246,7 +262,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 @@ -260,18 +276,10 @@ export class ExpressionEvaluator { ); } - const evaluatedParams: (number | boolean)[] = []; - 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) { @@ -282,6 +290,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, @@ -292,10 +357,16 @@ 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, { tables: context.tables }); + + if ( + typeof result !== 'number' && + typeof result !== 'boolean' && + typeof result !== 'string' + ) { 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 ); @@ -307,7 +378,10 @@ export class ExpressionEvaluator { private validateFunctionParameters( funcName: string, func: FunctionDefinition, - params: (number | boolean)[], + params: Record< + string, + number | boolean | string | (number | boolean | string)[] + >, expression: CallExpression, context: VariableContext ): void { @@ -318,8 +392,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`, @@ -328,47 +418,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 ' : ''; + const article = + 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 - ); + // 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 + ); + } } - if (params.length > schema.length) { - throw new ExpressionEvaluationError( - `Function '${name}' accepts at most ${schema.length} parameters, got ${params.length}`, - expression, - context - ); + // 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' || paramSchema.type === 'string' + ? 'a ' + : ''; + throw new ExpressionEvaluationError( + `Function '${name}' parameter '${paramName}' must be ${article}${paramSchema.type}, got ${actualType}`, + 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; - - if (actualType !== paramSchema.type) { - const article = paramSchema.type === 'number' ? 'a ' : ''; + // 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 ); @@ -377,7 +482,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 - ) - ); -} 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/index.ts b/src/index.ts index 86ac844..276e723 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,4 @@ export * from './expression'; export * from './validator'; +export * from './types'; +export * from './evaluator'; diff --git a/src/symbol/index.ts b/src/symbol/index.ts index 3ff819d..51381fa 100644 --- a/src/symbol/index.ts +++ b/src/symbol/index.ts @@ -1,8 +1,10 @@ export type { BuiltinSymbols, FunctionDefinition, + FunctionContext, 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..b8059ea 100644 --- a/src/symbol/registry.ts +++ b/src/symbol/registry.ts @@ -15,20 +15,43 @@ export interface BuiltinSymbols { variables?: Record; } +export interface FunctionContext { + tables?: Record; +} + export interface FunctionDefinition { parameters: ParameterSchema[]; - callback: (...args: unknown[]) => unknown; + callback: ( + args: Record, + context: FunctionContext + ) => unknown; } export interface ParameterSchema { name?: string; - type: 'number' | 'boolean' | 'array'; + type: 'number' | 'boolean' | 'string' | 'array'; items?: { - type: 'number' | 'boolean'; + type: 'number' | 'boolean' | 'string'; }; 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 +135,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; +} 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..0aa1255 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..889c743 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..2e9fdc2 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..a7ef1ef 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..6d60d53 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'; +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..c3f0843 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'; +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), diff --git a/tests/evaluator/conditional.test.ts b/tests/evaluator/conditional.test.ts new file mode 100644 index 0000000..6728b05 --- /dev/null +++ b/tests/evaluator/conditional.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect } from 'vitest'; +import { ConditionalEvaluator } from '@/evaluator/conditional'; +import { ExpressionEvaluator } from '@/expression'; +import type { EvaluationContext, Condition } from '@/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({}, { threshold: 45000 }, { income: 50000 }); + + 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("Calculated variable 'unknown_var' not found"); + }); + + it('should throw error for invalid numeric comparisons', () => { + const context = createContext({}, { is_active: false }, { income: 50000 }); + + 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); + }); + }); +}); diff --git a/tests/evaluator/evaluator.test.ts b/tests/evaluator/evaluator.test.ts new file mode 100644 index 0000000..8f660c7 --- /dev/null +++ b/tests/evaluator/evaluator.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect } from 'vitest'; +import { RuleEvaluator } from '@/evaluator'; +import type { Rule } from '@/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 + }); + }); +}); 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; }, }, }, diff --git a/tests/validator/validator.test.ts b/tests/validator/validator.test.ts index c22a30c..d065c1d 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 '@/validator/types'; -const validRule: Rule = { +const validRule: RawRule = { $version: '1.0.0', name: 'Test Income Tax', jurisdiction: 'PH',