Skip to content

Commit 56c74c0

Browse files
authored
feat: add evaluateWithReason function to return minimal expression that led to true result (#48)
1 parent 5efe82a commit 56c74c0

10 files changed

Lines changed: 842 additions & 152 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
strategy:
2121
matrix:
2222
os: [ ubuntu-latest ]
23-
node-version: [ 18.x, 20.x, 22.x, 24.x ]
23+
node-version: [ 20.x, 22.x, 24.x ]
2424

2525
steps:
2626
- uses: actions/checkout@v5

CLAUDE.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Build & Test Commands
6+
7+
- `yarn install` - Install dependencies
8+
- `yarn build` - Lint and compile TypeScript
9+
- `yarn compile` - Compile TypeScript only
10+
- `yarn test` - Run linting, type tests, and coverage tests
11+
- `yarn test:unit` - Run unit tests only
12+
- `yarn test:cover` - Run unit tests with coverage
13+
- `yarn test:tsd` - Run TypeScript definition tests
14+
- `yarn lint` - Run TSLint
15+
- `yarn ci` - Full CI pipeline (lint, compile, type tests, coverage)
16+
17+
Run a single test:
18+
```sh
19+
yarn test:unit --grep "test name pattern"
20+
```
21+
22+
## Architecture
23+
24+
### Two Evaluation Systems
25+
26+
1. **Expression Evaluator** (`src/lib/evaluator.ts`): Evaluates boolean expressions against a context
27+
- `evaluate()` - Returns boolean result
28+
- `evaluateWithReason()` - Returns `{result, reason}` where reason is the minimal expression that caused the result. For `and`/`or`, reason is always wrapped in array form (e.g., `{or: [reason]}`)
29+
- `validate()` - Validates expression structure against a full context
30+
31+
2. **Rule Engine** (`src/lib/engine.ts`): Evaluates rules with conditions and consequences
32+
- Rules have a condition (expression) and consequence (message + custom payload)
33+
- Can also use rule functions that combine condition checking and consequence in one
34+
35+
### Type System (Critical)
36+
37+
The type system uses `ts-toolbelt` for advanced type manipulation. Generic parameters follow this pattern:
38+
```typescript
39+
<Context, FunctionsTable, Ignore, CustomRunOptions>
40+
```
41+
42+
- **Context**: The object being evaluated against
43+
- **FunctionsTable**: Custom functions available in expressions
44+
- **Ignore**: Types to exclude from path extraction (e.g., `Moment`) to avoid TypeScript exhaustion
45+
- **CustomRunOptions**: User-defined options passed to functions
46+
47+
Key types in `src/types/`:
48+
- `Expression` - Union type of all valid expression forms (and/or/not/property comparisons/functions)
49+
- `ValidationContext` - Context with all optional properties required (for validation)
50+
- `EvaluationResult` - Result of `evaluateWithReason()` with boolean and minimal expression
51+
52+
### Expression Operators
53+
54+
Logical: `and`, `or`, `not`
55+
Comparison: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `between`, `inq`, `nin`, `regexp`, `regexpi`, `exists`
56+
57+
Right-hand side can be: literal value, `{ref: "path"}` to reference context, or math operation `{op, lhs, rhs}`.
58+
59+
## Code Style
60+
61+
- 4-space indentation, single quotes, semicolons required
62+
- Lines ~140 characters max
63+
- 100% code coverage required
64+
- Type tests use TSD in `src/test/types/`
65+
66+
## Node.js Support
67+
68+
Supports Node.js ^20, ^22, or ^24

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ yarn add json-expression-eval
2727
*Please see tests and examples dir for more usages and examples (under /src)*
2828

2929
```typescript
30-
import {evaluate, Expression, ExpressionHandler, validate, ValidationContext, EvaluatorFuncRunOptions} from 'json-expression-eval';
30+
import {evaluate, evaluateWithReason, Expression, ExpressionHandler, validate, ValidationContext, EvaluatorFuncRunOptions, EvaluationResult} from 'json-expression-eval';
3131
import {Moment} from 'moment';
3232
import moment = require('moment');
3333

@@ -139,9 +139,37 @@ const expression: IExampleExpression = {
139139
await validate<IExampleContext, IExampleFunctionTable, IExampleContextIgnore,
140140
IExampleCustomEvaluatorFuncRunOptions>(expression, validationContext, functionsTable, {dryRun: true}); // Should not throw
141141
console.log(await evaluate<IExampleContext, IExampleFunctionTable, IExampleContextIgnore, IExampleCustomEvaluatorFuncRunOptions>(expression, context, functionsTable, {dryRun: true})); // true
142+
143+
// Example usage 3 - evaluateWithReason returns the minimal expression that led to the result
144+
const resultWithReason = await evaluateWithReason<IExampleContext, IExampleFunctionTable, IExampleContextIgnore, IExampleCustomEvaluatorFuncRunOptions>(expression, context, functionsTable, {dryRun: true});
145+
console.log(resultWithReason.result); // true
146+
console.log(JSON.stringify(resultWithReason.reason)); // {"or":[{"userId":"a@b.com"}]} - the first matching condition in the OR
147+
148+
// Using ExpressionHandler
149+
const handlerResult = await handler.evaluateWithReason(context, {dryRun: true});
150+
console.log(handlerResult.result); // true
151+
console.log(JSON.stringify(handlerResult.reason)); // {"or":[{"userId":"a@b.com"}]}
142152
})()
143153
```
144154

155+
### evaluateWithReason
156+
157+
The `evaluateWithReason` function evaluates an expression and returns not only the boolean result, but also the minimal sub-expression that led to that result. This is useful for debugging and understanding why an expression evaluated to a specific value.
158+
159+
```typescript
160+
const result: EvaluationResult<...> = await evaluateWithReason(expression, context, functionsTable, runOptions);
161+
// result.result - boolean: the evaluation result
162+
// result.reason - Expression: the minimal expression that caused the result
163+
```
164+
165+
**Behavior:**
166+
- For `or` expressions that evaluate to `true`: returns `{or: [reason]}` with the first sub-expression that evaluated to `true`
167+
- For `or` expressions that evaluate to `false`: returns `{or: [reasons...]}` with all sub-expressions (all failed)
168+
- For `and` expressions that evaluate to `false`: returns `{and: [reason]}` with the first sub-expression that evaluated to `false`
169+
- For `and` expressions that evaluate to `true`: returns `{and: [reasons...]}` with all sub-expressions (all passed)
170+
- For `not` expressions: returns `{not: reason}` wrapping the reason from the inner expression
171+
- For simple property comparisons and function calls: returns the expression itself
172+
145173
### Expression
146174

147175
There are 4 types of operators you can use (evaluated in that order of precedence):

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
22
"name": "json-expression-eval",
3-
"version": "8.2.1",
3+
"version": "9.0.0",
44
"description": "json serializable rule engine / boolean expression evaluator",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
77
"engines": {
8-
"node": "^18 || ^20 || ^22 || ^24"
8+
"node": "^20 || ^22 || ^24"
99
},
1010
"scripts": {
1111
"test": "yarn lint && yarn test:tsd && yarn test:cover",
@@ -52,7 +52,7 @@
5252
"@types/chai": "4.3.20",
5353
"@types/chai-as-promised": "7.1.8",
5454
"@types/mocha": "^10.0.10",
55-
"@types/node": "^24.10.0",
55+
"@types/node": "^24.10.9",
5656
"@types/underscore": "^1.13.0",
5757
"chai": "4.5.0",
5858
"chai-as-promised": "7.1.2",

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export * from './types';
2-
export {evaluate, validate} from './lib/evaluator';
2+
export {evaluate, validate, evaluateWithReason} from './lib/evaluator';
33
export {validateRules, evaluateRules} from './lib/engine';
44
export {ExpressionHandler} from './lib/expressionHandler';
55
export {RulesEngine} from './lib/rulesEngine';

src/lib/evaluator.ts

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {
2525
FunctionsTable,
2626
ExtendedCompareOp,
2727
ValidationContext,
28-
PropertyCompareOps, Primitive, MathOp
28+
PropertyCompareOps, Primitive, MathOp,
29+
EvaluationResult
2930
} from '../types';
3031
import {
3132
assertUnreachable,
@@ -155,42 +156,59 @@ async function evaluateCompareOp<C extends Context, Ignore>(expressionValue: Ext
155156
async function handleAndOp<C extends Context, F extends FunctionsTable<C, CustomEvaluatorFuncRunOptions>,
156157
Ignore, CustomEvaluatorFuncRunOptions>
157158
(andExpression: Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>[], context: C, functionsTable: F,
158-
validation: boolean, runOptions: CustomEvaluatorFuncRunOptions): Promise<boolean> {
159+
validation: boolean, runOptions: CustomEvaluatorFuncRunOptions)
160+
: Promise<EvaluationResult<C, F, Ignore, CustomEvaluatorFuncRunOptions>> {
159161
if (andExpression.length === 0) {
160162
throw new Error('Invalid expression - and operator must have at least one expression');
161163
}
164+
const reasons: Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>[] = [];
162165
for (const currExpression of andExpression) {
163-
const result = await run<C, F, Ignore, CustomEvaluatorFuncRunOptions>(currExpression,
164-
context, functionsTable, validation, runOptions);
165-
if (!validation && !result) {
166-
return false;
166+
const runResult = await run<C, F, Ignore, CustomEvaluatorFuncRunOptions>(
167+
currExpression, context, functionsTable, validation, runOptions);
168+
if (!runResult.result && !validation) {
169+
// AND fails on first false
170+
const reason = {and: [runResult.reason] as const} as
171+
Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>;
172+
return {result: false, reason};
167173
}
174+
reasons.push(runResult.reason);
168175
}
169-
return true;
176+
return {
177+
result: true,
178+
reason: {and: reasons} as Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>,
179+
};
170180
}
171181

172182
async function handleOrOp<C extends Context, F extends FunctionsTable<C, CustomEvaluatorFuncRunOptions>,
173183
Ignore, CustomEvaluatorFuncRunOptions>(
174184
orExpression: Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>[],
175185
context: C, functionsTable: F, validation: boolean, runOptions: CustomEvaluatorFuncRunOptions)
176-
: Promise<boolean> {
186+
: Promise<EvaluationResult<C, F, Ignore, CustomEvaluatorFuncRunOptions>> {
177187
if (orExpression.length === 0) {
178188
throw new Error('Invalid expression - or operator must have at least one expression');
179189
}
190+
const reasons: Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>[] = [];
180191
for (const currExpression of orExpression) {
181-
const result = await run<C, F, Ignore, CustomEvaluatorFuncRunOptions>(
192+
const runResult = await run<C, F, Ignore, CustomEvaluatorFuncRunOptions>(
182193
currExpression, context, functionsTable, validation, runOptions);
183-
if (!validation && result) {
184-
return true;
194+
if (runResult.result && !validation) {
195+
// OR succeeds on first true
196+
const reason = {or: [runResult.reason] as const} as Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>;
197+
return {result: true, reason};
185198
}
199+
reasons.push(runResult.reason);
186200
}
187-
return false;
201+
return {
202+
result: false,
203+
reason: {or: reasons} as Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>,
204+
};
188205
}
189206

190207
async function run<C extends Context, F extends FunctionsTable<C, CustomEvaluatorFuncRunOptions>,
191208
Ignore, CustomEvaluatorFuncRunOptions>(
192209
expression: Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>, context: C,
193-
functionsTable: F, validation: boolean, runOptions: CustomEvaluatorFuncRunOptions): Promise<boolean> {
210+
functionsTable: F, validation: boolean, runOptions: CustomEvaluatorFuncRunOptions)
211+
: Promise<EvaluationResult<C, F, Ignore, CustomEvaluatorFuncRunOptions>> {
194212
const expressionKeys = objectKeys(expression);
195213
if (expressionKeys.length !== 1) {
196214
throw new Error('Invalid expression - too may keys');
@@ -203,24 +221,30 @@ async function run<C extends Context, F extends FunctionsTable<C, CustomEvaluato
203221
return handleOrOp<C, F, Ignore, CustomEvaluatorFuncRunOptions>(expression.or, context,
204222
functionsTable, validation, runOptions);
205223
} else if (isNotCompareOp<C, F, Ignore, CustomEvaluatorFuncRunOptions>(expression)) {
206-
return !(await run<C, F, Ignore, CustomEvaluatorFuncRunOptions>(expression.not, context,
207-
functionsTable, validation, runOptions));
224+
const innerResult = await run<C, F, Ignore, CustomEvaluatorFuncRunOptions>(
225+
expression.not, context, functionsTable, validation, runOptions);
226+
return {
227+
result: !innerResult.result,
228+
reason: {not: innerResult.reason} as Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>,
229+
};
208230
} else if (isFunctionCompareOp<C, F, Ignore, CustomEvaluatorFuncRunOptions>(expression,
209231
functionsTable, expressionKey)) {
210-
return functionsTable[expressionKey](expression[expressionKey], context, {
232+
const result = await functionsTable[expressionKey](expression[expressionKey], context, {
211233
custom: runOptions,
212234
validation,
213235
});
236+
return {result, reason: expression};
214237
} else {
215238
const {value: contextValue, exists} = getFromPath(context, expressionKey);
216239
if (validation && !exists) {
217240
throw new Error(`Invalid expression - unknown context key ${expressionKey}`);
218241
}
219-
return evaluateCompareOp<C, Ignore>(
242+
const result = await evaluateCompareOp<C, Ignore>(
220243
(expression as PropertyCompareOps<C, Ignore>)
221244
[expressionKey as any as keyof PropertyCompareOps<C, Ignore>] as
222245
unknown as ExtendedCompareOp<any, any, any>,
223246
expressionKey, contextValue, context, validation);
247+
return {result, reason: expression};
224248
}
225249
}
226250

@@ -229,8 +253,9 @@ export const evaluate = async <C extends Context, F extends FunctionsTable<C, Cu
229253
expression: Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>, context: C, functionsTable: F
230254
, runOptions: CustomEvaluatorFuncRunOptions)
231255
: Promise<boolean> => {
232-
return run<C, F, Ignore, CustomEvaluatorFuncRunOptions>(expression, context,
256+
const runResult = await run<C, F, Ignore, CustomEvaluatorFuncRunOptions>(expression, context,
233257
functionsTable, false, runOptions);
258+
return runResult.result;
234259
};
235260

236261
// Throws in case of validation error. Does not run functions or compare fields
@@ -242,3 +267,12 @@ export const validate = async <C extends Context, F extends FunctionsTable<C, Cu
242267
await run<C, F, Ignore, CustomEvaluatorFuncRunOptions>(expression,
243268
validationContext as C, functionsTable, true, runOptions);
244269
};
270+
271+
export const evaluateWithReason = async <C extends Context, F extends FunctionsTable<C, CustomEvaluatorFuncRunOptions>,
272+
Ignore = never, CustomEvaluatorFuncRunOptions = undefined>(
273+
expression: Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>, context: C, functionsTable: F
274+
, runOptions: CustomEvaluatorFuncRunOptions)
275+
: Promise<EvaluationResult<C, F, Ignore, CustomEvaluatorFuncRunOptions>> => {
276+
return run<C, F, Ignore, CustomEvaluatorFuncRunOptions>(
277+
expression, context, functionsTable, false, runOptions);
278+
};

src/lib/expressionHandler.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import {evaluate, validate} from './evaluator';
1+
import {evaluate, validate, evaluateWithReason} from './evaluator';
22
import {
33
Context,
44
Expression,
55
FunctionsTable,
6-
ValidationContext
6+
ValidationContext,
7+
EvaluationResult
78
} from '../types';
89

910
export class ExpressionHandler<C extends Context, F extends FunctionsTable<C, CustomEvaluatorFuncRunOptions>,
@@ -24,4 +25,10 @@ export class ExpressionHandler<C extends Context, F extends FunctionsTable<C, Cu
2425
this.expression, validationContext, this.functionsTable, runOptions);
2526
}
2627

28+
public async evaluateWithReason(context: C, runOptions: CustomEvaluatorFuncRunOptions)
29+
: Promise<EvaluationResult<C, F, Ignore, CustomEvaluatorFuncRunOptions>> {
30+
return evaluateWithReason<C, F, Ignore, CustomEvaluatorFuncRunOptions>(
31+
this.expression, context, this.functionsTable, runOptions);
32+
}
33+
2734
}

0 commit comments

Comments
 (0)