UNPKG

verdict-rules

Version:

A small, zero-dependency, async-native rule-evaluation engine. Compose conditions into one explainable pass/fail verdict, with sequential evaluation and real short-circuiting as a guarantee.

8 lines (7 loc) • 12.7 kB
{ "version": 3, "sources": ["../src/index.ts", "../src/errors.ts", "../src/engine.ts", "../src/rule.ts"], "sourcesContent": ["/**\n * A small, zero-dependency, async-native rule-evaluation engine.\n *\n * `Rule<TContext>`/`RulesEngine<TContext>` are generic over the context they\n * read from, with no default type parameter \u2014 dict-context is\n * `FunctionRule<Context>`/`RulesEngine<Context>`.\n *\n * ```ts\n * import { AndRule, FunctionRule, type Context } from \"verdict-rules\";\n *\n * const overEighteen = new FunctionRule<Context>(\"over_18\", async (ctx) => ({\n * ruleName: \"over_18\",\n * passed: (ctx.age as number) >= 18,\n * }));\n *\n * const verdict = await new AndRule<Context>(\"eligible\", [overEighteen]).evaluate({ age: 21 });\n * console.log(verdict.passed); // true\n * ```\n */\n\nexport { RulesEngine } from \"./engine.js\";\nexport { UnknownLookupError } from \"./errors.js\";\nexport type { Context, RuleResult, RunResult } from \"./result.js\";\nexport { AndRule, FunctionRule, OrRule } from \"./rule.js\";\nexport type { Rule, RulePredicate } from \"./rule.js\";\n", "/**\n * Thrown when a lookup names a rule or group that does not exist.\n *\n * ```ts\n * try {\n * await engine.runGroup(\"cor\", ctx);\n * } catch (err) {\n * if (err instanceof UnknownLookupError && err.kind === \"group\") {\n * // a typo, not a failed evaluation\n * }\n * }\n * ```\n */\nexport class UnknownLookupError extends Error {\n /** Whether the missing thing was a rule name or a group label. */\n readonly kind: \"rule\" | \"group\";\n\n /** The name or label that matched nothing. */\n readonly key: string;\n\n constructor(kind: \"rule\" | \"group\", key: string) {\n super(\n kind === \"rule\"\n ? `No rule named '${key}' in this engine`\n : `No rules in group '${key}' in this engine`,\n );\n this.name = \"UnknownLookupError\";\n this.kind = kind;\n this.key = key;\n }\n}\n", "import { UnknownLookupError } from \"./errors.js\";\nimport type { RuleResult, RunResult } from \"./result.js\";\nimport type { Rule } from \"./rule.js\";\n\n/**\n * Holds a set of rules and answers questions about them.\n *\n * The engine's run modes never short-circuit; a composite reaches one\n * verdict and stops.\n *\n * Generic over `TContext`, the same way {@link Rule} is, with no default\n * type parameter \u2014 dict-context is `RulesEngine<Context>`.\n */\nexport class RulesEngine<TContext> {\n readonly #rules: readonly Rule<TContext>[];\n readonly #byName: Map<string, Rule<TContext>>;\n readonly #byGroup: Map<string, Rule<TContext>[]>;\n\n constructor(rules: readonly Rule<TContext>[]) {\n this.#rules = [...rules];\n this.#byName = new Map(rules.map((r) => [r.name, r]));\n this.#byGroup = new Map();\n for (const rule of rules) {\n if (rule.group === undefined || rule.group === \"\") continue;\n const existing = this.#byGroup.get(rule.group);\n if (existing === undefined) this.#byGroup.set(rule.group, [rule]);\n else existing.push(rule);\n }\n }\n\n /** Every rule name registered here, in registration order. */\n get ruleNames(): readonly string[] {\n return [...this.#byName.keys()];\n }\n\n /** Every group label carried by at least one rule, in first-seen order. */\n get groupNames(): readonly string[] {\n return [...this.#byGroup.keys()];\n }\n\n /** Evaluate every registered rule. Never short-circuits. */\n async runAll(context: TContext): Promise<RunResult> {\n const results: RuleResult[] = [];\n for (const rule of this.#rules) {\n results.push(await rule.evaluate(context));\n }\n return { passed: results.every((r) => r.passed), results };\n }\n\n /**\n * Evaluate one rule by name, or return `undefined` if no such rule exists.\n *\n * `undefined` means absent, never failed.\n */\n async tryRunNamed(\n name: string,\n context: TContext,\n ): Promise<RuleResult | undefined> {\n const rule = this.#byName.get(name);\n if (rule === undefined) return undefined;\n return rule.evaluate(context);\n }\n\n /**\n * Evaluate exactly one rule, looked up by name.\n *\n * @throws {UnknownLookupError} if no rule has this name.\n */\n async runNamed(name: string, context: TContext): Promise<RuleResult> {\n const result = await this.tryRunNamed(name, context);\n if (result === undefined) {\n throw new UnknownLookupError(\"rule\", name);\n }\n return result;\n }\n\n /**\n * Evaluate a group, or return `undefined` if no such group exists.\n *\n * `undefined` means absent, never vacuously passed.\n */\n async tryRunGroup(\n group: string,\n context: TContext,\n ): Promise<RunResult | undefined> {\n const rules = this.#byGroup.get(group);\n if (rules === undefined || rules.length === 0) return undefined;\n const results: RuleResult[] = [];\n for (const rule of rules) {\n results.push(await rule.evaluate(context));\n }\n return { passed: results.every((r) => r.passed), results };\n }\n\n /**\n * Evaluate every rule sharing a group label. Never short-circuits.\n *\n * @throws {UnknownLookupError} if no rule carries this label.\n */\n async runGroup(group: string, context: TContext): Promise<RunResult> {\n const result = await this.tryRunGroup(group, context);\n if (result === undefined) {\n throw new UnknownLookupError(\"group\", group);\n }\n return result;\n }\n}\n", "import type { RuleResult } from \"./result.js\";\n\n/**\n * The contract every rule satisfies.\n *\n * A plain `interface` \u2014 structural typing: any object of the right shape\n * is a `Rule`. No `implements` clause, no base class, no registration.\n *\n * `Rule<TContext>` is generic over the context it reads from, with no\n * default type parameter. Dict-context is `Rule<Context>`, written out\n * every time.\n *\n * ```ts\n * const overEighteen: Rule<Context> = {\n * name: \"over_18\",\n * async evaluate(ctx) {\n * return { ruleName: \"over_18\", passed: (ctx.age as number) >= 18 };\n * },\n * };\n * ```\n */\nexport interface Rule<TContext> {\n /**\n * Unique identifier for this rule, used for engine lookups and to attribute\n * a result back to its source.\n */\n readonly name: string;\n\n /** Optional group label. Rules sharing one can be run together. */\n readonly group?: string | undefined;\n\n /** Evaluate this rule against `context`. */\n evaluate(context: TContext): Promise<RuleResult>;\n}\n\n/** Signature of the predicate {@link FunctionRule} wraps. */\nexport type RulePredicate<TContext> = (context: TContext) => Promise<RuleResult>;\n\n/**\n * Wraps a plain async predicate as a {@link Rule}.\n *\n * `TContext` is inferred from the wrapped predicate's own parameter type.\n */\nexport class FunctionRule<TContext> implements Rule<TContext> {\n readonly name: string;\n readonly group: string | undefined;\n readonly #predicate: RulePredicate<TContext>;\n\n constructor(name: string, predicate: RulePredicate<TContext>, group?: string) {\n this.name = name;\n this.group = group;\n this.#predicate = predicate;\n }\n\n /** Runs the wrapped predicate and returns whatever it returns, unchanged. */\n evaluate(context: TContext): Promise<RuleResult> {\n return this.#predicate(context);\n }\n}\n\n/**\n * Composite that passes only if every sub-rule passes.\n *\n * Short-circuits on the first failing sub-rule.\n *\n * An empty list passes vacuously.\n *\n * Every sub-rule must share the exact same `TContext`.\n */\nexport class AndRule<TContext> implements Rule<TContext> {\n readonly name: string;\n readonly group: string | undefined;\n readonly #rules: readonly Rule<TContext>[];\n\n constructor(name: string, rules: readonly Rule<TContext>[], group?: string) {\n this.name = name;\n this.group = group;\n this.#rules = rules;\n }\n\n async evaluate(context: TContext): Promise<RuleResult> {\n const subResults: RuleResult[] = [];\n // Sequential, not Promise.all.\n for (const rule of this.#rules) {\n const result = await rule.evaluate(context);\n subResults.push(result);\n if (!result.passed) {\n const detail = result.detail\n ? `'${rule.name}' failed: ${result.detail}`\n : `'${rule.name}' failed`;\n return { ruleName: this.name, passed: false, detail, data: subResults };\n }\n }\n return { ruleName: this.name, passed: true, data: subResults };\n }\n}\n\n/**\n * Composite that passes as soon as any sub-rule passes.\n *\n * Short-circuits on the first passing sub-rule.\n *\n * An empty list fails vacuously. The same same-`TContext` requirement\n * across sub-rules applies here too.\n */\nexport class OrRule<TContext> implements Rule<TContext> {\n readonly name: string;\n readonly group: string | undefined;\n readonly #rules: readonly Rule<TContext>[];\n\n constructor(name: string, rules: readonly Rule<TContext>[], group?: string) {\n this.name = name;\n this.group = group;\n this.#rules = rules;\n }\n\n async evaluate(context: TContext): Promise<RuleResult> {\n const subResults: RuleResult[] = [];\n // Sequential, not Promise.all.\n for (const rule of this.#rules) {\n const result = await rule.evaluate(context);\n subResults.push(result);\n if (result.passed) {\n return { ruleName: this.name, passed: true, data: subResults };\n }\n }\n return {\n ruleName: this.name,\n passed: false,\n detail: \"no sub-rule passed\",\n data: subResults,\n };\n }\n}\n"], "mappings": "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", "names": [] }