@ctserv/rule-evaluator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # @ctserv/rule-evaluator
2
+
3
+ Evaluate FormBuilder logic rule **conditions** from JSON. Returns a boolean — whether the expression is satisfied at runtime.
4
+
5
+ Pure JavaScript. No React. No DOM. Runs in Node and browser bundlers.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @ctserv/rule-evaluator
11
+ ```
12
+
13
+ Local development from FormBuilder:
14
+
15
+ ```bash
16
+ npm install ../Rules\ Evaluator
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```js
22
+ import {
23
+ evaluateConditions,
24
+ normalizeRules,
25
+ hasCompleteConditions,
26
+ } from "@ctserv/rule-evaluator";
27
+
28
+ const rules = {
29
+ enableWhen: {
30
+ type: "group",
31
+ op: "or",
32
+ children: [
33
+ {
34
+ type: "group",
35
+ op: "and",
36
+ children: [
37
+ { type: "condition", field: "age", op: ">", value: "18" },
38
+ { type: "condition", field: "country", op: "==", value: "US" },
39
+ ],
40
+ },
41
+ { type: "condition", field: "vip", op: "==", value: "true" },
42
+ ],
43
+ },
44
+ action: "disable",
45
+ };
46
+
47
+ const values = { age: "25", country: { value: "US" }, vip: false };
48
+
49
+ const met = evaluateConditions(rules, values, {
50
+ fieldLookup: {
51
+ age: { type: "inputNumeric" },
52
+ country: { type: "select" },
53
+ vip: { type: "checkbox" },
54
+ },
55
+ });
56
+ ```
57
+
58
+ Flat and legacy shapes are normalized internally:
59
+
60
+ ```js
61
+ // flat
62
+ { match: "all", conditions: [...] }
63
+
64
+ // legacy
65
+ { all: [...] } // or { any: [...] }
66
+ ```
67
+
68
+ ## API
69
+
70
+ | Function | Description |
71
+ |----------|-------------|
72
+ | `evaluateConditions(rules, values, options?)` | Returns `boolean` |
73
+ | `normalizeRules(rules)` | Canonical tree-shaped rules object |
74
+ | `hasCompleteConditions(rules)` | At least one complete `field` + `op` condition |
75
+ | `sanitizeRules(rules)` | Strip incomplete nodes; `null` if nothing valid |
76
+
77
+ ### Options
78
+
79
+ - `fieldLookup` — map of field name → metadata (`type`, etc.)
80
+ - `getComparableValue(fieldMeta, rawValue)` — override for type-aware comparisons (dateTime, richEdit, fileUpload)
81
+
82
+ ### Default comparable values
83
+
84
+ | Field type | Comparable |
85
+ |------------|------------|
86
+ | `inputText`, `inputNumeric`, `radioGroup` | raw scalar / string |
87
+ | `select` | `rawValue.value` |
88
+ | `checkbox` | `"true"` or `"false"` |
89
+ | `dateTime`, `richEdit`, `fileUpload` | use injected `getComparableValue` |
90
+
91
+ ## Debugging
92
+
93
+ Example scripts live in `debug/`. Open any file, set breakpoints, then run **Debug current file** from `.vscode/launch.json`.
94
+
95
+ | File | What it exercises |
96
+ |------|-------------------|
97
+ | `debug/evaluate-flat-and.js` | Flat AND rules |
98
+ | `debug/evaluate-flat-or.js` | Flat OR rules |
99
+ | `debug/evaluate-nested-or-and.js` | Nested OR with inner AND |
100
+ | `debug/evaluate-legacy-all.js` | Legacy `all[]` shape |
101
+ | `debug/evaluate-field-types.js` | Select, checkbox, numeric, case sensitivity |
102
+ | `debug/normalize-rules.js` | Normalization and sanitize |
103
+
104
+ Shared helpers: `debug/_shared.js`.
105
+
106
+ Or run from terminal:
107
+
108
+ ```bash
109
+ node debug/evaluate-nested-or-and.js
110
+ ```
111
+
112
+ ## Operators
113
+
114
+ `==`, `!=`, `>`, `>=`, `<`, `<=`
115
+
116
+ Ordering ops use numeric comparison when both sides are finite numbers; otherwise lexicographic string comparison.
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@ctserv/rule-evaluator",
3
+ "files": [
4
+ "src"
5
+ ],
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "version": "1.0.0",
10
+ "description": "Evaluate FormBuilder logic rule conditions from JSON (nested AND/OR groups)",
11
+ "main": "src/index.js",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.js"
15
+ },
16
+ "keywords": [
17
+ "formbuilder",
18
+ "rules",
19
+ "evaluator"
20
+ ],
21
+ "license": "UNLICENSED"
22
+ }
@@ -0,0 +1,15 @@
1
+ export const OPS = ["==", "!=", ">", ">=", "<", "<="];
2
+
3
+ export const MATCH_MODES = ["all", "any"];
4
+
5
+ export const GROUP_OPS = ["and", "or"];
6
+
7
+ export const NODE_TYPES = ["group", "condition"];
8
+
9
+ export const VALID_ACTIONS = new Set([
10
+ "disable",
11
+ "enable",
12
+ "hide",
13
+ "show",
14
+ "resetDefault",
15
+ ]);
@@ -0,0 +1,110 @@
1
+ import {
2
+ hasCompleteConditionsInTree,
3
+ normalizeEnableWhen,
4
+ normalizeRules,
5
+ } from "./normalize.js";
6
+
7
+ const NUMERIC_OPS = new Set([">", ">=", "<", "<="]);
8
+
9
+ export function defaultGetComparableValue(fieldMeta, rawValue) {
10
+ if (fieldMeta?.type === "select") return rawValue?.value ?? "";
11
+ if (fieldMeta?.type === "checkbox") {
12
+ return rawValue === true ? "true" : "false";
13
+ }
14
+
15
+ return rawValue ?? "";
16
+ }
17
+
18
+ export function compareOp(op, left, right) {
19
+ const lNum = Number(left);
20
+ const rNum = Number(right);
21
+ const canNum = Number.isFinite(lNum) && Number.isFinite(rNum);
22
+
23
+ if (NUMERIC_OPS.has(op) && canNum) {
24
+ if (op === ">") return lNum > rNum;
25
+ if (op === ">=") return lNum >= rNum;
26
+ if (op === "<") return lNum < rNum;
27
+ if (op === "<=") return lNum <= rNum;
28
+ }
29
+
30
+ const lStr = String(left ?? "");
31
+ const rStr = String(right ?? "");
32
+
33
+ if (op === "==") return lStr === rStr;
34
+ if (op === "!=") return lStr !== rStr;
35
+ if (op === ">") return lStr > rStr;
36
+ if (op === ">=") return lStr >= rStr;
37
+ if (op === "<") return lStr < rStr;
38
+ if (op === "<=") return lStr <= rStr;
39
+
40
+ return false;
41
+ }
42
+
43
+ function evaluateCondition(condition, values, fieldLookup, getComparableValue) {
44
+ const fieldName = condition.field;
45
+ const fieldMeta = fieldLookup?.[fieldName];
46
+ const rawValue = values?.[fieldName];
47
+ const actual = getComparableValue(fieldMeta, rawValue);
48
+
49
+ return compareOp(condition.op, actual, condition.value);
50
+ }
51
+
52
+ function evaluateTreeNode(node, values, fieldLookup, getComparableValue) {
53
+ if (node.type === "condition") {
54
+ return evaluateCondition(node, values, fieldLookup, getComparableValue);
55
+ }
56
+
57
+ if (node.type === "group") {
58
+ const children = node.children || [];
59
+ if (children.length === 0) return false;
60
+
61
+ if (node.op === "or") {
62
+ return children.some((child) =>
63
+ evaluateTreeNode(child, values, fieldLookup, getComparableValue),
64
+ );
65
+ }
66
+
67
+ return children.every((child) =>
68
+ evaluateTreeNode(child, values, fieldLookup, getComparableValue),
69
+ );
70
+ }
71
+
72
+ return false;
73
+ }
74
+
75
+ function resolveEnableWhenTree(rules) {
76
+ if (!rules || typeof rules !== "object") return null;
77
+
78
+ if (rules.enableWhen !== undefined) {
79
+ return normalizeEnableWhen(rules.enableWhen);
80
+ }
81
+
82
+ return normalizeEnableWhen(rules);
83
+ }
84
+
85
+ /**
86
+ * @param {object|null} rules - Full rules object or enableWhen tree/flat JSON
87
+ * @param {object} values - Runtime values keyed by field name
88
+ * @param {object} [options]
89
+ * @param {Record<string, object>} [options.fieldLookup]
90
+ * @param {(fieldMeta: object|undefined, rawValue: unknown) => any} [options.getComparableValue]
91
+ * @returns {boolean}
92
+ */
93
+ export function evaluateConditions(rules, values, options = {}) {
94
+ if (rules == null) return false;
95
+
96
+ const tree = resolveEnableWhenTree(rules);
97
+ if (!tree || !hasCompleteConditionsInTree(tree)) return false;
98
+
99
+ const fieldLookup = options.fieldLookup ?? {};
100
+ const getComparableValue =
101
+ options.getComparableValue ?? defaultGetComparableValue;
102
+
103
+ return evaluateTreeNode(tree, values, fieldLookup, getComparableValue);
104
+ }
105
+
106
+ export function hasCompleteConditions(rules) {
107
+ const normalized = normalizeRules(rules);
108
+ if (!normalized?.enableWhen) return false;
109
+ return hasCompleteConditionsInTree(normalized.enableWhen);
110
+ }
package/src/format.js ADDED
@@ -0,0 +1,100 @@
1
+ import { normalizeEnableWhen, normalizeRules } from "./normalize.js";
2
+
3
+ function resolveEnableWhen(rules) {
4
+ if (!rules || typeof rules !== "object") return null;
5
+
6
+ if (rules.enableWhen !== undefined) {
7
+ return normalizeEnableWhen(rules.enableWhen);
8
+ }
9
+
10
+ return normalizeEnableWhen(rules);
11
+ }
12
+
13
+ function formatConditionValue(value, quoteValues) {
14
+ const text = value ?? "";
15
+ if (!quoteValues) return String(text);
16
+ return `"${String(text)}"`;
17
+ }
18
+
19
+ function formatCondition(condition, options) {
20
+ const { quoteValues = true } = options;
21
+ const value = formatConditionValue(condition.value, quoteValues);
22
+ return `${condition.field} ${condition.op} ${value}`;
23
+ }
24
+
25
+ function formatGroupJoiner(op, options) {
26
+ const { joinerAnd = " AND ", joinerOr = " OR " } = options;
27
+ return op === "or" ? joinerOr : joinerAnd;
28
+ }
29
+
30
+ function formatTreeNode(node, options) {
31
+ if (!node) return null;
32
+
33
+ if (node.type === "condition") {
34
+ return formatCondition(node, options);
35
+ }
36
+
37
+ if (node.type !== "group") return null;
38
+
39
+ const joiner = formatGroupJoiner(node.op, options);
40
+ const { wrapNestedGroups = true } = options;
41
+ const parts = (node.children || [])
42
+ .map((child) => {
43
+ const text = formatTreeNode(child, options);
44
+ if (!text) return null;
45
+ if (wrapNestedGroups && child.type === "group") return `(${text})`;
46
+ return text;
47
+ })
48
+ .filter(Boolean);
49
+
50
+ if (parts.length === 0) return null;
51
+ if (parts.length === 1) return parts[0];
52
+ return parts.join(joiner);
53
+ }
54
+
55
+ /**
56
+ * Format rules (or enableWhen only) as a textual AND/OR expression.
57
+ *
58
+ * @param {object|null} rules - Full rules object, enableWhen tree/flat JSON, or normalized rules
59
+ * @param {object} [options]
60
+ * @param {boolean} [options.quoteValues=true] - Wrap condition values in double quotes
61
+ * @param {string} [options.joinerAnd=" AND "] - Joiner for and groups
62
+ * @param {string} [options.joinerOr=" OR "] - Joiner for or groups
63
+ * @param {boolean} [options.wrapNestedGroups=true] - Wrap nested groups in parentheses
64
+ * @returns {string|null} Expression such as `(age > "18" AND country == "US") OR vip == "true"`
65
+ */
66
+ export function formatConditionsExpression(rules, options = {}) {
67
+ const tree = resolveEnableWhen(rules);
68
+ if (!tree) return null;
69
+ return formatTreeNode(tree, options);
70
+ }
71
+
72
+ /**
73
+ * Format the full rules object including action when present.
74
+ *
75
+ * Example: `Disable this field when (age > "18" AND country == "US") OR vip == "true"`
76
+ *
77
+ * @param {object|null} rules
78
+ * @param {object} [options]
79
+ * @param {string} [options.targetLabel="this field"] - Label used in action prefix
80
+ * @returns {string|null}
81
+ */
82
+ export function formatRulesExpression(rules, options = {}) {
83
+ const normalized = normalizeRules(rules);
84
+ if (!normalized) return null;
85
+
86
+ const expression = formatConditionsExpression(normalized.enableWhen, options);
87
+ if (!expression) return null;
88
+
89
+ const { targetLabel = "this field" } = options;
90
+ const action = normalized.action ?? "disable";
91
+
92
+ if (action === "hide") return `Hide ${targetLabel} when ${expression}`;
93
+ if (action === "show") return `Show ${targetLabel} when ${expression}`;
94
+ if (action === "enable") return `Enable ${targetLabel} when ${expression}`;
95
+ if (action === "resetDefault") {
96
+ return `Reset ${targetLabel} to default when ${expression}`;
97
+ }
98
+
99
+ return `Disable ${targetLabel} when ${expression}`;
100
+ }
package/src/index.js ADDED
@@ -0,0 +1,31 @@
1
+ export {
2
+ OPS,
3
+ MATCH_MODES,
4
+ GROUP_OPS,
5
+ NODE_TYPES,
6
+ VALID_ACTIONS,
7
+ } from "./constants.js";
8
+
9
+ export {
10
+ normalizeRules,
11
+ normalizeEnableWhen,
12
+ normalizeTreeNode,
13
+ sanitizeRules,
14
+ isCompleteCondition,
15
+ countCompleteConditions,
16
+ hasCompleteConditionsInTree,
17
+ } from "./normalize.js";
18
+
19
+ export {
20
+ evaluateConditions,
21
+ hasCompleteConditions,
22
+ compareOp,
23
+ defaultGetComparableValue,
24
+ } from "./evaluate.js";
25
+
26
+ export { validateEnableWhenTree } from "./validate.js";
27
+
28
+ export {
29
+ formatConditionsExpression,
30
+ formatRulesExpression,
31
+ } from "./format.js";
@@ -0,0 +1,191 @@
1
+ import { GROUP_OPS, VALID_ACTIONS } from "./constants.js";
2
+
3
+ function matchToGroupOp(match) {
4
+ return match === "any" ? "or" : "and";
5
+ }
6
+
7
+ function legacyGroupOp(enableWhen) {
8
+ const legacyAll = Array.isArray(enableWhen.all) ? enableWhen.all : [];
9
+ const legacyAny = Array.isArray(enableWhen.any) ? enableWhen.any : [];
10
+
11
+ if (legacyAny.length > 0 && legacyAll.length === 0) {
12
+ return { op: "or", items: legacyAny };
13
+ }
14
+
15
+ return { op: "and", items: legacyAll };
16
+ }
17
+
18
+ export function isCompleteCondition(node) {
19
+ return Boolean(node?.field && node?.op);
20
+ }
21
+
22
+ function normalizeCondition(node) {
23
+ if (!node || typeof node !== "object") return null;
24
+ if (!isCompleteCondition(node)) return null;
25
+
26
+ return {
27
+ type: "condition",
28
+ field: node.field,
29
+ op: node.op,
30
+ value: node.value ?? "",
31
+ };
32
+ }
33
+
34
+ function normalizeGroupNode(node) {
35
+ if (!node || typeof node !== "object" || node.type !== "group") return null;
36
+
37
+ const op = node.op === "or" ? "or" : "and";
38
+ const children = (Array.isArray(node.children) ? node.children : [])
39
+ .map(normalizeTreeNode)
40
+ .filter(Boolean);
41
+
42
+ if (children.length === 0) return null;
43
+
44
+ return { type: "group", op, children };
45
+ }
46
+
47
+ export function normalizeTreeNode(node) {
48
+ if (!node || typeof node !== "object") return null;
49
+
50
+ if (node.type === "group") return normalizeGroupNode(node);
51
+ if (node.type === "condition") return normalizeCondition(node);
52
+
53
+ if (node.field !== undefined) {
54
+ return normalizeCondition({ type: "condition", ...node });
55
+ }
56
+
57
+ return null;
58
+ }
59
+
60
+ /** Normalize enableWhen JSON (tree, flat, or legacy) to an internal group tree. */
61
+ export function normalizeEnableWhen(enableWhen) {
62
+ if (!enableWhen || typeof enableWhen !== "object") return null;
63
+
64
+ if (enableWhen.type === "group") {
65
+ return normalizeGroupNode(enableWhen);
66
+ }
67
+
68
+ if (Array.isArray(enableWhen.conditions)) {
69
+ const op = matchToGroupOp(enableWhen.match === "any" ? "any" : "all");
70
+ const children = enableWhen.conditions
71
+ .map((condition) => normalizeCondition({ type: "condition", ...condition }))
72
+ .filter(Boolean);
73
+
74
+ if (children.length === 0) return null;
75
+
76
+ return { type: "group", op, children };
77
+ }
78
+
79
+ const { op, items } = legacyGroupOp(enableWhen);
80
+ const children = items
81
+ .map((condition) => normalizeCondition({ type: "condition", ...condition }))
82
+ .filter(Boolean);
83
+
84
+ if (children.length === 0) return null;
85
+
86
+ return { type: "group", op, children };
87
+ }
88
+
89
+ function migrateLegacyAction(rules) {
90
+ if (rules.action && VALID_ACTIONS.has(rules.action)) {
91
+ return rules.action;
92
+ }
93
+ if (rules.whenMet === "disable") return "disable";
94
+ return "disable";
95
+ }
96
+
97
+ function extractRulesObject(rules) {
98
+ if (!rules || typeof rules !== "object") return null;
99
+
100
+ if (rules.enableWhen !== undefined) {
101
+ return rules;
102
+ }
103
+
104
+ if (
105
+ rules.type === "group" ||
106
+ rules.match !== undefined ||
107
+ rules.conditions !== undefined ||
108
+ rules.all !== undefined ||
109
+ rules.any !== undefined
110
+ ) {
111
+ return { enableWhen: rules };
112
+ }
113
+
114
+ return null;
115
+ }
116
+
117
+ /** Normalize legacy, flat, and nested shapes to a canonical rules object. */
118
+ export function normalizeRules(rules) {
119
+ const source = extractRulesObject(rules);
120
+ if (!source) return null;
121
+
122
+ const enableWhen = normalizeEnableWhen(source.enableWhen);
123
+ if (!enableWhen) return null;
124
+
125
+ const normalized = { enableWhen };
126
+
127
+ if (source.action !== undefined || source.whenMet !== undefined) {
128
+ normalized.action = migrateLegacyAction(source);
129
+ }
130
+
131
+ return normalized;
132
+ }
133
+
134
+ export function countCompleteConditions(node) {
135
+ if (!node) return 0;
136
+
137
+ if (node.type === "condition") {
138
+ return isCompleteCondition(node) ? 1 : 0;
139
+ }
140
+
141
+ if (node.type === "group") {
142
+ return (node.children || []).reduce(
143
+ (total, child) => total + countCompleteConditions(child),
144
+ 0,
145
+ );
146
+ }
147
+
148
+ return 0;
149
+ }
150
+
151
+ export function hasCompleteConditionsInTree(node) {
152
+ return countCompleteConditions(node) > 0;
153
+ }
154
+
155
+ export function sanitizeTreeNode(node) {
156
+ if (!node) return null;
157
+
158
+ if (node.type === "condition") {
159
+ return normalizeCondition(node);
160
+ }
161
+
162
+ if (node.type === "group") {
163
+ const op = GROUP_OPS.includes(node.op) ? node.op : "and";
164
+ const children = (node.children || [])
165
+ .map(sanitizeTreeNode)
166
+ .filter(Boolean);
167
+
168
+ if (children.length === 0) return null;
169
+
170
+ return { type: "group", op, children };
171
+ }
172
+
173
+ return null;
174
+ }
175
+
176
+ /** Strip incomplete nodes; return null if nothing valid remains. */
177
+ export function sanitizeRules(rules) {
178
+ const normalized = normalizeRules(rules);
179
+ if (!normalized) return null;
180
+
181
+ const enableWhen = sanitizeTreeNode(normalized.enableWhen);
182
+ if (!enableWhen) return null;
183
+
184
+ const sanitized = { enableWhen };
185
+
186
+ if (normalized.action !== undefined) {
187
+ sanitized.action = normalized.action;
188
+ }
189
+
190
+ return sanitized;
191
+ }
@@ -0,0 +1,84 @@
1
+ import { GROUP_OPS, NODE_TYPES, OPS } from "./constants.js";
2
+ import { isCompleteCondition } from "./normalize.js";
3
+
4
+ function validateConditionNode(node, errors, path) {
5
+ if (node.type !== "condition") {
6
+ errors.push(`${path}: expected type "condition"`);
7
+ return;
8
+ }
9
+
10
+ if (!node.field) {
11
+ errors.push(`${path}: condition missing field`);
12
+ }
13
+
14
+ if (!node.op) {
15
+ errors.push(`${path}: condition missing op`);
16
+ } else if (!OPS.includes(node.op)) {
17
+ errors.push(`${path}: unsupported op "${node.op}"`);
18
+ }
19
+ }
20
+
21
+ function validateGroupNode(node, errors, path) {
22
+ if (node.type !== "group") {
23
+ errors.push(`${path}: expected type "group"`);
24
+ return;
25
+ }
26
+
27
+ if (!GROUP_OPS.includes(node.op)) {
28
+ errors.push(`${path}: group op must be "and" or "or"`);
29
+ }
30
+
31
+ if (!Array.isArray(node.children) || node.children.length === 0) {
32
+ errors.push(`${path}: group must have at least one child`);
33
+ return;
34
+ }
35
+
36
+ node.children.forEach((child, index) => {
37
+ validateTreeNode(child, errors, `${path}.children[${index}]`);
38
+ });
39
+ }
40
+
41
+ function validateTreeNode(node, errors, path = "enableWhen") {
42
+ if (!node || typeof node !== "object") {
43
+ errors.push(`${path}: expected object`);
44
+ return;
45
+ }
46
+
47
+ if (!NODE_TYPES.includes(node.type)) {
48
+ errors.push(`${path}: unknown node type "${node.type}"`);
49
+ return;
50
+ }
51
+
52
+ if (node.type === "condition") {
53
+ validateConditionNode(node, errors, path);
54
+ return;
55
+ }
56
+
57
+ validateGroupNode(node, errors, path);
58
+ }
59
+
60
+ /**
61
+ * Validate a normalized tree-shaped enableWhen node.
62
+ * Returns { valid: boolean, errors: string[] }.
63
+ */
64
+ export function validateEnableWhenTree(enableWhen) {
65
+ const errors = [];
66
+
67
+ if (!enableWhen || typeof enableWhen !== "object") {
68
+ return { valid: false, errors: ["enableWhen must be an object"] };
69
+ }
70
+
71
+ if (enableWhen.type === "group") {
72
+ validateTreeNode(enableWhen, errors);
73
+ } else if (Array.isArray(enableWhen.conditions)) {
74
+ enableWhen.conditions.forEach((condition, index) => {
75
+ if (!isCompleteCondition(condition)) {
76
+ errors.push(`enableWhen.conditions[${index}]: incomplete condition`);
77
+ }
78
+ });
79
+ } else {
80
+ errors.push('enableWhen must be a group tree or flat "conditions" list');
81
+ }
82
+
83
+ return { valid: errors.length === 0, errors };
84
+ }