PR T: consolidate obligations/evaluator into rulesEngine #24

Merged
nsatoshi merged 1 commits from devin/1776890754-pr-t-evaluator-consolidation into main 2026-04-22 20:48:10 +00:00
2 changed files with 37 additions and 141 deletions

View File

@@ -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);
}

View File

@@ -91,6 +91,10 @@ export interface EvaluationResult {
/* -----------------------------------------------------------------
* Dotted-path resolver. Supports a.b.c and a.b[0].c.
* --------------------------------------------------------------- */
export function resolvePath(ctx: unknown, path: string): unknown {
return getPath(ctx, path);
}
function getPath(ctx: unknown, path: string): unknown {
if (!path) return ctx;
const parts = path