|
|
|
|
@@ -1,153 +1,45 @@
|
|
|
|
|
/**
|
|
|
|
|
* Minimal, self-contained condition evaluator for the obligation
|
|
|
|
|
* layer.
|
|
|
|
|
* Obligation-layer condition evaluator.
|
|
|
|
|
*
|
|
|
|
|
* A sibling Rules Engine (PR P) ships a more general DSL that this
|
|
|
|
|
* evaluator is a subset of; keeping a local copy lets the obligation
|
|
|
|
|
* layer be merged independently. The two surfaces share the same
|
|
|
|
|
* Condition shape so clauses authored for one run on the other.
|
|
|
|
|
* Originally shipped as a self-contained subset of the PR P Rules
|
|
|
|
|
* Engine so the obligation layer could be merged independently. Now
|
|
|
|
|
* consolidated: this file re-exports the shared types and
|
|
|
|
|
* `evaluateCondition` from `services/rulesEngine.ts` and provides a
|
|
|
|
|
* thin compatibility wrapper for `resolvePath(path, context)` which
|
|
|
|
|
* historically took its arguments in the opposite order.
|
|
|
|
|
*
|
|
|
|
|
* Key guarantees:
|
|
|
|
|
* - Closed operator set (no eval, no code execution).
|
|
|
|
|
* - Deterministic: given the same (condition, context), always
|
|
|
|
|
* returns the same boolean.
|
|
|
|
|
* - Dotted + indexed path resolution so clauses can assert on
|
|
|
|
|
* deeply nested context objects (e.g. "plan.steps[0].amount").
|
|
|
|
|
* Keeping this module as a named surface preserves existing imports
|
|
|
|
|
* under `services/obligations/evaluator` throughout the codebase and
|
|
|
|
|
* the test suite.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export type Operator =
|
|
|
|
|
| "eq"
|
|
|
|
|
| "neq"
|
|
|
|
|
| "gt"
|
|
|
|
|
| "gte"
|
|
|
|
|
| "lt"
|
|
|
|
|
| "lte"
|
|
|
|
|
| "in"
|
|
|
|
|
| "not_in"
|
|
|
|
|
| "exists"
|
|
|
|
|
| "matches"
|
|
|
|
|
| "length_gte"
|
|
|
|
|
| "length_lte";
|
|
|
|
|
export type {
|
|
|
|
|
Operator,
|
|
|
|
|
LeafCondition,
|
|
|
|
|
AndCondition,
|
|
|
|
|
OrCondition,
|
|
|
|
|
NotCondition,
|
|
|
|
|
Condition,
|
|
|
|
|
} from "../rulesEngine";
|
|
|
|
|
|
|
|
|
|
export interface LeafCondition {
|
|
|
|
|
path: string;
|
|
|
|
|
op: Operator;
|
|
|
|
|
value?: unknown;
|
|
|
|
|
message?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AndCondition {
|
|
|
|
|
all: Condition[];
|
|
|
|
|
message?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface OrCondition {
|
|
|
|
|
any: Condition[];
|
|
|
|
|
message?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface NotCondition {
|
|
|
|
|
not: Condition;
|
|
|
|
|
message?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type Condition = LeafCondition | AndCondition | OrCondition | NotCondition;
|
|
|
|
|
|
|
|
|
|
export function resolvePath(
|
|
|
|
|
path: string,
|
|
|
|
|
context: Record<string, unknown>,
|
|
|
|
|
): unknown {
|
|
|
|
|
// Handles both "a.b.c" and "a.b[0].c"
|
|
|
|
|
const tokens = path
|
|
|
|
|
.replace(/\[(\d+)\]/g, ".$1")
|
|
|
|
|
.split(".")
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
let cur: unknown = context;
|
|
|
|
|
for (const t of tokens) {
|
|
|
|
|
if (cur === null || cur === undefined) return undefined;
|
|
|
|
|
if (Array.isArray(cur)) {
|
|
|
|
|
const idx = Number(t);
|
|
|
|
|
cur = Number.isNaN(idx) ? undefined : cur[idx];
|
|
|
|
|
} else if (typeof cur === "object") {
|
|
|
|
|
cur = (cur as Record<string, unknown>)[t];
|
|
|
|
|
} else {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return cur;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isLeaf(c: Condition): c is LeafCondition {
|
|
|
|
|
return (c as LeafCondition).path !== undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAnd(c: Condition): c is AndCondition {
|
|
|
|
|
return Array.isArray((c as AndCondition).all);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isOr(c: Condition): c is OrCondition {
|
|
|
|
|
return Array.isArray((c as OrCondition).any);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isNot(c: Condition): c is NotCondition {
|
|
|
|
|
return (c as NotCondition).not !== undefined;
|
|
|
|
|
}
|
|
|
|
|
import { evaluateCondition as ruleEngineEvaluate, resolvePath as ruleEnginePath } from "../rulesEngine";
|
|
|
|
|
import type { Condition } from "../rulesEngine";
|
|
|
|
|
|
|
|
|
|
export function evaluateCondition(
|
|
|
|
|
condition: Condition,
|
|
|
|
|
context: Record<string, unknown>,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (isAnd(condition)) {
|
|
|
|
|
return condition.all.every((c) => evaluateCondition(c, context));
|
|
|
|
|
}
|
|
|
|
|
if (isOr(condition)) {
|
|
|
|
|
return condition.any.some((c) => evaluateCondition(c, context));
|
|
|
|
|
}
|
|
|
|
|
if (isNot(condition)) {
|
|
|
|
|
return !evaluateCondition(condition.not, context);
|
|
|
|
|
}
|
|
|
|
|
if (!isLeaf(condition)) return false;
|
|
|
|
|
|
|
|
|
|
const actual = resolvePath(condition.path, context);
|
|
|
|
|
const expected = condition.value;
|
|
|
|
|
switch (condition.op) {
|
|
|
|
|
case "eq":
|
|
|
|
|
return actual === expected;
|
|
|
|
|
case "neq":
|
|
|
|
|
return actual !== expected;
|
|
|
|
|
case "gt":
|
|
|
|
|
return typeof actual === "number" && typeof expected === "number" && actual > expected;
|
|
|
|
|
case "gte":
|
|
|
|
|
return typeof actual === "number" && typeof expected === "number" && actual >= expected;
|
|
|
|
|
case "lt":
|
|
|
|
|
return typeof actual === "number" && typeof expected === "number" && actual < expected;
|
|
|
|
|
case "lte":
|
|
|
|
|
return typeof actual === "number" && typeof expected === "number" && actual <= expected;
|
|
|
|
|
case "in":
|
|
|
|
|
return Array.isArray(expected) && expected.includes(actual);
|
|
|
|
|
case "not_in":
|
|
|
|
|
return Array.isArray(expected) && !expected.includes(actual);
|
|
|
|
|
case "exists":
|
|
|
|
|
return actual !== undefined && actual !== null;
|
|
|
|
|
case "matches":
|
|
|
|
|
return (
|
|
|
|
|
typeof actual === "string" &&
|
|
|
|
|
typeof expected === "string" &&
|
|
|
|
|
new RegExp(expected).test(actual)
|
|
|
|
|
);
|
|
|
|
|
case "length_gte":
|
|
|
|
|
return (
|
|
|
|
|
typeof expected === "number" &&
|
|
|
|
|
(Array.isArray(actual) || typeof actual === "string") &&
|
|
|
|
|
(actual as unknown[] | string).length >= expected
|
|
|
|
|
);
|
|
|
|
|
case "length_lte":
|
|
|
|
|
return (
|
|
|
|
|
typeof expected === "number" &&
|
|
|
|
|
(Array.isArray(actual) || typeof actual === "string") &&
|
|
|
|
|
(actual as unknown[] | string).length <= expected
|
|
|
|
|
);
|
|
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return ruleEngineEvaluate(condition, context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Historical (path, context) signature retained for backward
|
|
|
|
|
* compatibility with call sites written before the evaluator was
|
|
|
|
|
* consolidated into the Rules Engine.
|
|
|
|
|
*/
|
|
|
|
|
export function resolvePath(
|
|
|
|
|
path: string,
|
|
|
|
|
context: Record<string, unknown>,
|
|
|
|
|
): unknown {
|
|
|
|
|
return ruleEnginePath(context, path);
|
|
|
|
|
}
|
|
|
|
|
|