first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
# @suaveplan/rules - Rule engine and validation system — async evaluator, fluent builder, 30 operators, schema/field/custom validators, serializer, optimizer; Zod boundaries, OTel surface, AbortSignal throughout
## Summary
Universal rule engine + validation system. Comprehensive `RuleEngine` with async evaluation, depth limit, AbortSignal, plugin registration; fluent `RuleBuilder` + factory functions; 33-operator `OperatorRegistry`; three validator families (`SchemaValidator`, `FieldValidator`, `CustomValidator`); JSON serializer with function-placeholder strategy; AST optimiser (flatten / dedupe / simplify / sort); action subsystem with timeout + abort; typed event bus with priority hooks + bounded history. Zod boundaries at every public surface; OTel spans + histograms + counters from production paths; `RulesError` extends `SuaveplanError` with a 24-code registry.
<!-- begin:auto-docs -->
## Documentation tree
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - operators-internal — `compareValues(actual, operato…
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - operators-internal — `compareValues(actual, operato…
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
<!-- end:auto-docs --> - [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_ - [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_ - [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_ - [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ - [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
## Install
```bash
bun add @suaveplan/rules
```
## Quick start
```ts
import { context } from "@suaveplan/rules/context";
import { rule } from "@suaveplan/rules/builder";
import { RuleEngine } from "@suaveplan/rules/engine";
const policy = rule()
.field("age").greaterThanOrEqual(18)
.and()
.field("region").in(["US", "CA"])
.build();
const engine = new RuleEngine();
const result = await engine.evaluate(policy, context({ age: 21, region: "US" }));
// result.passed === true
```
## Modules
- [`context`](src/context/context.md) — `RuleContext` builder, dot-path / array-index field resolver, request-context derivation
- [`builder`](src/builder/builder.md) — `RuleBuilder` + `FieldBuilder` fluent DSL + factory functions
- [`engine`](src/engine/engine.md) — `RuleEngine` with evaluate / evaluateAll / evaluateRuleset / check + plugin registration
- [`operators`](src/operators/operators.md) — `OperatorRegistry` + 33 built-in operator definitions
- [`actions`](src/actions/actions.md) — `Action` / `ActionRegistry` / `ActionExecutor` + 4 built-in handlers
- [`events`](src/events/event-bus.md) — `EventBus` + `HookManager` with priority hooks and `waitFor`
- [`validators/schema`](src/validators/schema/schema.md) — recursive JSON-schema-like validator
- [`validators/field`](src/validators/field/field.md) — chainable field validator with 12 presets
- [`validators/custom`](src/validators/custom/custom.md) — named validators + registry + 11 built-ins + 5 parameterised factories
- [`serializer`](src/serializer/serializer.md) — round-trip JSON with function-placeholder strategy
- [`optimizer`](src/optimizer/optimizer.md) — AST optimiser (flatten / dedupe / simplify / sort) + analytics
- [`errors`](src/errors/rules-error.md) — `RulesError` + 24-code registry
- [`otel`](src/otel/otel.md) — lazy OTel instrument surface
- Constants — [`constants.md`](src/constants.md)
## Internal references
- [Engine internal compare dispatcher](src/engine/operators-internal.md) — bridges into the operator surface
- [Type guards](src/types/) — `isConditionRule`, `isLogicalRule`, `isGroupRule`, `isCustomRule`, `isComputedRule`
## Conventions
- Every public method validates input via Zod and raises `RulesError` with a typed code on rejection.
- `AbortSignal` is threaded through every async path; pre-aborted signals short-circuit before work begins.
- `Clock` / `Rng` / `LoggerLike` are injectable per CLAUDE.md "no module-scope `Date.now`/`Math.random`" rule.
- 100% test coverage; sibling `.md` per implementation file ≥ 200 words.
- Tests import runner primitives from `@suaveplan/testing/runner` exclusively (no direct `vitest` / `bun:test`).
- No `export *` in feature barrels — every public symbol is named explicitly.
## See also
- [`@suaveplan/rules-hono`](../rules-hono/README.md) — Hono middleware wrapping this package.

View File

@@ -0,0 +1,133 @@
# Actions module — `@suaveplan/rules/actions`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The actions module is the post-evaluation effect surface — named, timeout-bounded, abort-aware handlers that fire after a rule passes (or fails). Callers register handler functions on an `ActionRegistry`, wrap them in `Action` instances with `ActionConfig` (handler name, params, timeout, continue-on-error), and execute them sequentially or in parallel via `ActionExecutor`. The 4 built-in handlers (`log`, `set`, `delay`, `noop`) cover the common cases; the `log` handler takes an injectable `LoggerLike` rather than calling `console.log` directly (CLAUDE.md §2 bans `console.log` in `src/`).
Every async path threads `AbortSignal` so callers can cancel long-running effects; every failure mode maps to a typed `RulesError` code (`RULES.ACTION_TIMEOUT`, `RULES.ACTION_ABORTED`, `RULES.ACTION_HANDLER_FAILED`, `RULES.ACTION_NOT_FOUND`). The module emits OTel histogram (`rules.action.execute.duration_ms`) and counter (`rules.action.executions_total{status}`) for every action invocation.
## Features
- `ActionRegistry` — `register`, `get`, `has`, `names`, `unregister`. Empty names rejected with `RULES.ACTION_HANDLER_FAILED`.
- `Action` — wraps an `ActionConfig` + `ActionRegistry`; constructor Zod-validates the config; `execute(context, options?)` returns an `ActionResult` rather than throwing.
- `ActionExecutor` — `executeSequential` (respects `continueOnError`, reports later actions as `skipped` on hard stop); `executeParallel` (bounded concurrency via `parallelism`, defaults from constants module).
- `builtInHandlers({ logger? })` — factory returning the `{ log, set, delay, noop }` handler bundle bound to a caller-supplied `LoggerLike`.
- `validateActionConfig`, `createAction`, `createActionRegistry`, `createActionExecutor` — function factories matching the convention used by every other package.
## Usage: Basic Example
```ts
import { context } from "@suaveplan/rules/context";
import {
builtInHandlers,
createAction,
createActionExecutor,
createActionRegistry,
} from "@suaveplan/rules/actions";
const registry = createActionRegistry();
for (const [name, handler] of Object.entries(builtInHandlers())) {
registry.register(name, handler);
}
const executor = createActionExecutor();
const result = await executor.executeSequential(
[createAction({ handler: "log", params: { message: "ran" } }, registry)],
context({}),
);
result[0]?.status; // "success"
```
## Usage: Advanced Example
```ts
import { context } from "@suaveplan/rules/context";
import {
Action,
ActionExecutor,
ActionRegistry,
builtInHandlers,
} from "@suaveplan/rules/actions";
import type { LoggerLike } from "@suaveplan/types";
const logger: LoggerLike = {
debug: (m, ctx) => process.stderr.write(`debug ${m} ${JSON.stringify(ctx)}\n`),
info: (m, ctx) => process.stderr.write(`info ${m} ${JSON.stringify(ctx)}\n`),
warn: (m, ctx) => process.stderr.write(`warn ${m} ${JSON.stringify(ctx)}\n`),
error: (m, ctx) => process.stderr.write(`error ${m} ${JSON.stringify(ctx)}\n`),
};
const registry = new ActionRegistry();
for (const [name, handler] of Object.entries(builtInHandlers({ logger }))) {
registry.register(name, handler);
}
registry.register("fetch", async (params, _ctx, { signal }) => {
const response = await fetch(params.url as string, { signal });
return response.json();
});
const executor = new ActionExecutor({ parallelism: 4 });
const controller = new AbortController();
setTimeout(() => controller.abort(), 500); // hard deadline
const results = await executor.executeParallel(
[
new Action({ handler: "fetch", params: { url: "/api/a" }, timeoutMs: 200 }, registry),
new Action({ handler: "fetch", params: { url: "/api/b" }, timeoutMs: 200 }, registry),
new Action({ handler: "log", params: { message: "done" } }, registry),
],
context({}),
{ signal: controller.signal, parallelism: 2 },
);
for (const r of results) {
if (r.status === "failed") console.warn(r.handler, r.errorCode, r.errorMessage);
}
```
## API Reference
### `class ActionRegistry`
Standard register/get/has/names/unregister surface. Empty handler name raises `RULES.ACTION_HANDLER_FAILED`.
### `class Action`
- `new Action(config, registry)` — Zod-validates `config`; raises `RULES.ACTION_HANDLER_FAILED` on rejection.
- `execute(context, options?)` → `Promise<ActionResult>`. Never throws — every failure becomes an `ActionResult { status: "failed", errorCode, errorMessage }`.
- Pre-aborted signal → `errorCode: "RULES.ACTION_ABORTED"`.
- Timeout (config.timeoutMs or `DEFAULT_ACTION_TIMEOUT_MS`) → `errorCode: "RULES.ACTION_TIMEOUT"`.
- Caller aborts mid-flight → `errorCode: "RULES.ACTION_ABORTED"`.
- Handler throws → `errorCode: "RULES.ACTION_HANDLER_FAILED"`, original message preserved.
### `class ActionExecutor`
- `new ActionExecutor(defaults?)` — `defaults.clock` is used for duration measurement; `defaults.parallelism` (default `DEFAULT_ACTION_PARALLELISM = 8`) is the executeParallel ceiling when not overridden.
- `executeSequential(actions, context, options?)` → `Promise<readonly ActionResult[]>`. Stops on first failure unless `action.config.continueOnError === true`; remaining actions return `status: "skipped"` with `durationMs: 0`.
- `executeParallel(actions, context, options?)` → `Promise<readonly ActionResult[]>`. Bounded by `options.parallelism ?? defaults.parallelism ?? DEFAULT_ACTION_PARALLELISM`. `parallelism <= 0` raises `RULES.ACTION_HANDLER_FAILED`.
### `function builtInHandlers(deps?)`
Returns the `{ log, set, delay, noop }` handler bundle.
- `log(params, context, { signal })` — `params.level` (one of `debug` / `info` / `warn` / `error`, default `info`), `params.message` (default `"log"`). Calls `deps.logger[level](message)`; no-op fallback when `deps.logger` is omitted.
- `set(params, context, { signal })` — writes `params.value` onto `context.data[params.target]`. Missing or empty `params.target` raises `RULES.ACTION_HANDLER_FAILED`.
- `delay(params, context, { signal })` — `params.ms` milliseconds (default 0). Aborts cleanly with `RULES.ACTION_ABORTED` when the signal fires.
- `noop` — always returns `undefined`.
### Factory aliases
`createActionRegistry()`, `createAction(config, registry)`, `createActionExecutor(defaults?)`, `validateActionConfig(config)`.
## Implementation Notes
- `Action.execute` uses a local `AbortController` linked to the caller signal AND a `setTimeout`-driven timeout abort. The race is implemented as `Promise.race([handlerPromise, abortPromise])`; the abort promise discriminates timeout vs caller abort by inspecting the caller's signal state at fire time.
- The signal listener is registered with `{ once: true }` and the `finally` clears the timeout and removes the listener, so a long-lived caller signal does not accumulate listeners across many actions.
- `executeSequential` reports skipped actions deterministically: every action after a hard-stop failure returns `{ status: "skipped", handler, durationMs: 0 }` in the result array, preserving 1:1 correspondence with the input.
- `executeParallel` uses a shared cursor with `Math.min(limit, actions.length)` worker promises; each worker pulls the next index until the cursor exhausts. This is the simplest bounded-concurrency pattern that works under `AbortSignal` without an external semaphore library.
- OTel emission is best-effort: a global no-op meter is used when no SDK is installed, so handlers in `vitest run` without a `MeterProvider` still execute correctly.
- The `delay` handler is intentionally simple — it does not return a `Promise.race` with a `signal.reason` because callers can read `signal.reason` themselves; surfacing it inside the handler would couple `RulesError` shape to `DOMException` shape.

View File

@@ -0,0 +1,108 @@
# Builder module — `@suaveplan/rules/builder`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The builder module is the ergonomic, type-safe entry point for constructing rules without hand-authoring the discriminated `Rule` union. It exposes the `RuleBuilder` fluent class, the field-scoped `FieldBuilder` returned by `RuleBuilder.field(path)`, and a set of factory functions (`rule`, `condition`, `and`, `or`, `not`, `group`) for callers who prefer a function-style API to the chainable builder. Every builder output round-trips through the serializer and is consumed by the engine without further translation.
The builder is intentionally a thin layer over the `Rule` types — it does not validate that field paths exist, operators are registered, or values type-check against the operator. Those checks land in the engine at evaluation time so that the builder remains a pure constructor and tests for the builder are exclusively about shape correctness.
## Features
- `RuleBuilder` — fluent class with `field()`, `and()`, `or()`, `not()`, `group()`, `where()`, `any()`, `all()`, `custom()`, `computed()`, `rule()`, `addRules()`, `withMetadata()`, `build()`, `buildRuleset()`, `getRules()`. Pending-operator pattern: `.and()` / `.or()` flag the next `addRule` to combine with the previous rule into a `LogicalRule`.
- `FieldBuilder` — yields a `ConditionRule` via any of the 30+ operator methods. Returns the parent `RuleBuilder` so the fluent chain continues.
- Factory helpers — `rule()` (alias for `RuleBuilder.create()`), `condition()`, `and(...)`, `or(...)`, `not(...)`, `group(name, ...)` for callers who want plain functions.
- Structured errors at every misuse — `.not()` without a preceding rule, `and()`/`or()` with zero args, empty group / ruleset names — all surface `RulesError` with the canonical code (`RULES.RULE_INVALID` / `RULES.RULESET_INVALID`).
- Optional `message` / `metadata` on `FieldBuilder` propagate into the emitted `ConditionRule`.
## Usage: Basic Example
```ts
import { rule } from "@suaveplan/rules/builder";
const adultAndVerified = rule()
.field("age").greaterThanOrEqual(18)
.and()
.field("verified").equals(true)
.build();
```
## Usage: Advanced Example
```ts
import { and, condition, group, not, or, rule } from "@suaveplan/rules/builder";
// Function-style composition
const policy = and(
condition("age", "greaterThanOrEqual", 18),
or(
condition("region", "in", ["US", "CA"]),
condition("hasExplicitConsent", "equals", true),
),
not(condition("blocked", "equals", true)),
);
// Fluent composition with nested groups + ruleset metadata
const ruleset = rule()
.withMetadata({ priority: "high", tags: ["compliance", "kyc"] })
.group("identity", (rb) => rb
.field("email").matches(/@example\.com$/)
.and()
.field("verified").equals(true))
.all((rb) => rb
.field("country").in(["US", "CA"])
.field("age").greaterThanOrEqual(18))
.custom("not-on-watchlist", async (data: { id: string }) => {
return !(await isOnWatchlist(data.id));
})
.buildRuleset("rs-onboarding", "Onboarding policy", {
operator: "and",
failFast: false,
version: "2.1.0",
});
declare function isOnWatchlist(id: string): Promise<boolean>;
declare const policy: unknown;
declare const ruleset: unknown;
```
## API Reference
### `class RuleBuilder`
- `static create()` — entry point.
- `field<TValue>(path)` → `FieldBuilder<TValue>`.
- `and()` / `or()` — set pending operator; next `addRule` combines into a binary `LogicalRule`.
- `not()` — wrap the most recent rule in `LogicalRule { operator: "not" }`. Raises `RULES.RULE_INVALID` if the stack is empty.
- `group(name, build)` — build an inner `RuleBuilder` and wrap its rules in a `GroupRule` (default operator `and`).
- `where(build)` — single inner rule passes through; multiple inner rules wrap in `LogicalRule { operator: "and" }`; empty body is a no-op.
- `any(build)` / `all(build)` — explicit OR / AND grouping.
- `custom(name, validate, message?)` — append a `CustomRule`.
- `computed(field, operator, compute, message?)` — append a `ComputedRule`.
- `rule(r)` / `addRules(rules)` — append an existing rule or array of rules.
- `withMetadata(meta)` — set metadata applied to subsequent `custom`, `computed`, and `buildRuleset` outputs.
- `build()` → `Rule` — single rule returned directly; multiple rules wrap in `LogicalRule { operator: "and" }`. Empty stack raises `RULES.RULE_INVALID`.
- `buildRuleset(id, name, options?)` → `Ruleset` — empty `id` / `name` raises `RULES.RULESET_INVALID`. Inherits builder metadata when `options.metadata` is omitted.
- `getRules()` — read-only view of the internal stack.
### `class FieldBuilder<TValue>`
- `withMessage(s)` / `withMetadata(m)` — both return `this` and propagate into the emitted `ConditionRule`.
- 30 operator methods: `equals`, `notEquals`, `strictEquals`, `strictNotEquals`, `greaterThan`, `greaterThanOrEqual`, `lessThan`, `lessThanOrEqual`, `between(min, max)`, `notBetween(min, max)`, `in(values)`, `notIn(values)`, `contains`, `notContains`, `containsAll(values)`, `containsAny(values)`, `exists`, `notExists`, `isNull`, `isNotNull`, `isEmpty`, `isNotEmpty`, `isType(type)`, `isNotType(type)`, `startsWith`, `endsWith`, `matches(pattern)` (string or RegExp), `notMatches(pattern)` (string or RegExp). Each commits a `ConditionRule` to the parent builder and returns the parent.
### Function factories
- `rule()` — alias for `RuleBuilder.create()`.
- `condition(field, operator, value, message?)` — produce a `ConditionRule` directly.
- `and(...rules)` / `or(...rules)` — empty args raise `RULES.RULE_INVALID`.
- `not(rule)` — single-target wrapper.
- `group(name, ...rules)` — empty name raises `RULES.RULE_INVALID`.
## Implementation Notes
- The pending-operator pattern matches the upstream behaviour exactly: `.and()` / `.or()` flag a transition, the next `addRule` consumes the flag, builds a binary `LogicalRule`, and clears the flag. Subsequent rules without an explicit operator simply append to the stack (and are wrapped in an outer AND at `build()` time).
- All optional fields (`message`, `metadata`) are omitted from the emitted rule when undefined, so output equality checks in tests can use plain `toEqual` without redundant `undefined` keys.
- `FieldBuilder.matches(/regex/)` extracts the source string via `pattern.source` — the engine reconstructs the `RegExp` per evaluation (cheap, but means flags are NOT carried through; pass flags inside the pattern: `(?i)abc`).
- The `addRule` method is intentionally part of the public class surface (not private) because `FieldBuilder.commit()` calls it from outside the class. Marking it `@internal` would still leave it on the public typing; the trade-off is accepted to keep the friendliness of the public API.

View File

@@ -0,0 +1,51 @@
# Package constants — `@suaveplan/rules`
## Purpose
This module exposes the package-level constants consumed across the rules engine, validators, action executor, and event bus. Constants are deliberately separated from feature code so that one canonical source defines the conservative defaults — depth limits, history capacities, timeout deadlines — for the entire package. Every default is overridable through the options object on the relevant constructor or factory function; the defaults exist for the "I just want it to work" call path, not as a behavioural contract.
`PACKAGE_NAME` and `VERSION` are surfaced for diagnostics (error wire format, OTel resource attributes, telemetry headers). They are `const` literals so that downstream tree-shaking can fold them into the bundle as inline strings.
## Features
- `PACKAGE_NAME` — the canonical `@suaveplan/rules` identifier; matches the `name` field in `package.json`.
- `VERSION` — synced to `package.json`'s `version` field at publish time.
- `DEFAULT_MAX_DEPTH` — the recursion depth at which the engine throws `RULES.ENGINE_DEPTH_EXCEEDED`. Set conservatively to 32; well above any realistic business-rule tree, but low enough to detect cyclic or pathological inputs quickly.
- `DEFAULT_EVENT_HISTORY` — the size of the bounded ring buffer maintained by `EventBus`. At 1000 entries the bus keeps recent context for debugging without growing without bound under sustained emission.
- `DEFAULT_ACTION_TIMEOUT_MS` — the default per-action timeout used by `ActionExecutor` when the caller does not supply one. 30s matches the convention used by `@suaveplan/retry` for "long-running but bounded" operations.
- `DEFAULT_ACTION_PARALLELISM` — the concurrency cap applied by `ActionExecutor.executeParallel` when the caller does not supply one. 8 is the sweet spot for IO-bound handlers without saturating an event loop.
## Usage: Basic Example
```ts
import { DEFAULT_MAX_DEPTH, PACKAGE_NAME, VERSION } from "@suaveplan/rules";
console.log(`${PACKAGE_NAME}@${VERSION}, max-depth=${DEFAULT_MAX_DEPTH}`);
```
## Usage: Advanced Example
```ts
import { RuleEngine } from "@suaveplan/rules/engine";
import { DEFAULT_MAX_DEPTH } from "@suaveplan/rules";
// Halve the default depth limit for a constrained edge function.
const engine = new RuleEngine({ maxDepth: DEFAULT_MAX_DEPTH / 2 });
```
## API Reference
| Export | Type | Description |
|---|---|---|
| `PACKAGE_NAME` | `"@suaveplan/rules"` (literal `const`) | Package identifier. |
| `VERSION` | `"0.1.0"` (literal `const`) | Current package version; bumped to `1.0.0` at graduation. |
| `DEFAULT_MAX_DEPTH` | `number` | Engine recursion ceiling. |
| `DEFAULT_EVENT_HISTORY` | `number` | EventBus ring-buffer capacity. |
| `DEFAULT_ACTION_TIMEOUT_MS` | `number` | Per-action timeout in milliseconds. |
| `DEFAULT_ACTION_PARALLELISM` | `number` | Maximum concurrent actions in `executeParallel`. |
## Implementation Notes
- The constants file ships zero runtime initialisation and zero `import`s of other workspace packages. It is intentionally side-effect free so that tree-shakers can prune it cleanly when unused.
- Numbers are declared with `_` digit separators (`30_000`) for readability. The TypeScript target is ES2022, where numeric separators are supported by every published runtime.
- Changes to default values are behaviour changes per AGENTS.md §13a and require a `## Migration` note in the OpenSpec proposal.

View File

@@ -0,0 +1,107 @@
# Context module — `@suaveplan/rules/context`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The context module supplies the evaluation environment threaded through every rule, action, and validator in the engine. It owns the `RuleContext` shape, the canonical `FieldResolver` implementation (dot-path + `prop[index]` array indexing), a fluent `ContextBuilder` whose `with*` calls each return a fresh builder for safe sharing across requests, and a `DefaultContextProvider` that composes registered enhancers and stamps a deterministic `timestamp` from an injectable `Clock`. The module also exports HTTP-shaped helpers (`createRequestContext`, `createScopedContext`) and shorthand factories (`context`, `contextWith`, `getField`, `hasField`) that cover the common call sites.
The module is intentionally side-effect free: no module-level state, no `new Date()` at import time, no global registries. Every default that touches wall-clock time runs through the injectable `Clock` parameter so deterministic tests pin behaviour with `fakeClock(...)` and production code uses `Date.now`.
## Features
- `DefaultFieldResolver` — dot-path + `prop[index]` traversal with optional strict mode raising `RULES.FIELD_RESOLUTION_FAILED` on missing intermediates.
- `ContextBuilder` — immutable-per-call fluent API for `RuleContext`; `static create` / `static from` entry points; `withData<TNew>` re-types the builder while preserving optional fields.
- `DefaultContextProvider` — `ContextProvider` impl with injectable `Clock` and async enhancer pipeline; `create` runs enhancers against an empty base, `enhance` runs them against an existing context.
- `createRequestContext` — accepts plain header records or a Web `Headers` instance; derives `path` from `url` when explicit `path` is absent; survives malformed URLs.
- `createScopedContext` — adds a `scope` label to a parent context; rejects empty scopes with `RULES.CONTEXT_INVALID`.
- `context` / `contextWith` / `getField` / `hasField` — shorthand factories. `contextWith` Zod-validates its options at the boundary.
## Usage: Basic Example
```ts
import { context, getField } from "@suaveplan/rules/context";
const ctx = context({ user: { age: 21 } });
const age = getField(ctx, "user.age"); // 21
```
## Usage: Advanced Example
```ts
import {
ContextBuilder,
createRequestContext,
DefaultContextProvider,
DefaultFieldResolver,
} from "@suaveplan/rules/context";
const resolver = new DefaultFieldResolver({ strict: true });
const sku = resolver.resolve(
{ items: [{ sku: "X" }, { sku: "Y" }] },
"items[1].sku",
); // "Y"
const provider = new DefaultContextProvider<{ user: { id: string } }>({
clock: () => 1700000000000,
enhancers: [
async (ctx) => ({ ...ctx, tenant: "acme" }),
async (ctx) => ({
...ctx,
user: { ...ctx.data.user, id: ctx.data.user.id.toUpperCase() },
}),
],
});
const baseCtx = await provider.create({ user: { id: "ada" } });
const requestCtx = ContextBuilder.from(baseCtx)
.withRequest(createRequestContext({ method: "GET", url: "/api/users/42" }))
.build();
```
## API Reference
### `class DefaultFieldResolver implements FieldResolver`
- `new DefaultFieldResolver(options?)` — `options.strict` enables strict mode.
- `resolve(data, path) -> unknown` — traverses dot-path segments and `prop[N]` array indices. Returns `data` when `path === ""`. In strict mode, missing intermediates raise `RulesError(RULES.FIELD_RESOLUTION_FAILED)`.
- `has(data, path) -> boolean` — wraps `resolve` and swallows `RulesError` to return `false`; rethrows other errors.
### `class ContextBuilder<TData extends Record<string, unknown>>`
- `static create<T>(data)` / `static from<T>(context)` — entry points.
- `withData<TNew>(data)` — re-types and forks the builder.
- `withTenant`, `withUser`, `withRequest`, `withTimestamp`, `withMetadata` — each returns a fresh builder with the field set.
- `build()` — produces an immutable `RuleContext<TData>` with only the supplied optional fields populated (undefined keys are omitted from the output object).
### `class DefaultContextProvider<TData> implements ContextProvider<TData>`
- `new DefaultContextProvider(options?)` — `options.clock` defaults to `() => Date.now()`; `options.enhancers` is copied into an internal array.
- `create(input) -> Promise<RuleContext<TData>>` — wraps non-object input as `{}`, stamps `timestamp = new Date(clock())`, runs every enhancer in order.
- `enhance(context, input) -> Promise<RuleContext<TData>>` — runs every enhancer in order against the supplied context.
### `function context(data)` / `function contextWith(data, options?)`
Shorthand factories. `contextWith` validates `options` via Zod and raises `RulesError(RULES.CONTEXT_INVALID)` on rejection.
### `function getField(context, path)` / `function hasField(context, path)`
Convenience wrappers around a module-local non-strict `DefaultFieldResolver`.
### `function createRequestContext(input)`
Derives a `RequestContext` from an HTTP-shaped input. Validates input via Zod; preserves an explicit `path` over a URL-derived one; falls back to the raw `url` string when the URL parser rejects the value.
### `function createScopedContext(parent, scope)`
Returns `{ ...parent, scope }`. Empty `scope` raises `RulesError(RULES.CONTEXT_INVALID)`.
## Implementation Notes
- The `ARRAY_INDEX_PATTERN` regex captures both the property name and the index in one match, so `items[0]` resolves the property `items` and then the array element at `0`.
- `ContextBuilder.build()` deliberately omits `undefined` keys so that `expect(ctx).toEqual({ data: { a: 1 } })` matches exactly without redundant optional fields polluting the output.
- The default resolver used by `getField` / `hasField` is non-strict. Callers that want strict-mode resolution must construct their own `new DefaultFieldResolver({ strict: true })`.
- `DefaultContextProvider.create` calls `this.clock()` exactly once per invocation, so the resulting `timestamp` is stable regardless of enhancer wall-time.
- All Zod schemas are declared once at module scope and reused per call to avoid per-invocation schema construction overhead.

View File

@@ -0,0 +1,141 @@
# Engine module — `@suaveplan/rules/engine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - operators-internal — `compareValues(actual, operato…
- [operators-internal](./operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
- operators-internal — `compareValues(actual, operato…
- [operators-internal](./operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
- operators-internal — `compareValues(actual, operato…
- [operators-internal](./operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
- operators-internal — `compareValues(actual, operato…
- [operators-internal](./operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
- [operators-internal](./operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
## Purpose
The `RuleEngine` evaluates rules from the discriminated `Rule` union against a `RuleContext`. It threads `AbortSignal` through every recursive boundary, enforces a configurable max depth, accepts plugin-registered custom operators, emits OTel histograms / counters / spans for every evaluation, and translates every failure into a typed `RulesError` code. The engine is the central public entrypoint of the package: callers can use it standalone or wire it into Hono middleware via `@suaveplan/rules-hono`.
The engine consumes built-in comparison operators from an internal dispatcher (`operators-internal.ts`) so it remains usable without the dedicated `operators` module installed. When the `operators` module ships, plugins can register or override operator definitions through `engine.use(plugin)` without touching the engine class itself — this is the same composition pattern used by `@suaveplan/crypto`'s engine adapters.
## Features
- `evaluate(rule, context, options?)` — single-rule evaluation returning `EvaluationResult` with `passed`, `errors`, optional `trace`, and a populated `metadata` block (start / end times, duration, evaluated count, max depth).
- `evaluateAll(rules, context, options?)` — array variant returning `readonly EvaluationResult[]` in input order.
- `evaluateRuleset(ruleset, context, options?)` — Zod-validates the ruleset, applies the ruleset's `operator` (`and` / `or`) over per-rule outcomes, honours `failFast`.
- `check(rule, context, options?)` — boolean-only convenience.
- `use(plugin)` — install a `RuleEnginePlugin` with idempotent registration; `hasPlugin(name)` reports installation status.
- Built-in operators: 33-entry comparison dispatcher covering equality / numeric / collection / existence / type / length / string operators.
- AbortSignal: pre-aborted signals short-circuit before any rule fires; signals abort across recursive boundaries; engine-level `defaultSignal` applies when no per-call signal is supplied.
- OTel: `rules.engine.evaluate.duration_ms`, `rules.engine.evaluations_total{outcome}`, `rules.engine.depth`, `rules.operator.unknown_total{name}`, plus a `rules.engine.evaluate` span with `outcome` attribute.
## Usage: Basic Example
```ts
import { context } from "@suaveplan/rules/context";
import { condition } from "@suaveplan/rules/builder";
import { RuleEngine } from "@suaveplan/rules/engine";
const engine = new RuleEngine();
const result = await engine.evaluate(
condition("age", "greaterThanOrEqual", 18),
context({ age: 21 }),
);
result.passed; // true
```
## Usage: Advanced Example
```ts
import { and, condition } from "@suaveplan/rules/builder";
import { context } from "@suaveplan/rules/context";
import { RuleEngine } from "@suaveplan/rules/engine";
import type { OperatorDefinition } from "@suaveplan/rules";
const divisibleBy: OperatorDefinition<number, number> = {
name: "divisibleBy",
description: "actual % expected === 0",
evaluate: (actual, expected) => Number(actual) % Number(expected) === 0,
};
const engine = new RuleEngine({ maxDepth: 16, trace: true });
engine.use({
name: "math-plugin",
install: (r) => r.registerOperator("divisibleBy", divisibleBy),
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 25); // hard 25ms deadline
const policy = and(
condition("age", "greaterThanOrEqual", 18),
condition("score", "divisibleBy", 3),
);
const result = await engine.evaluateRuleset(
{
id: "rs-onboarding",
name: "Onboarding",
operator: "and",
failFast: true,
rules: [policy, condition("region", "in", ["US", "CA"])],
},
context({ age: 21, score: 9, region: "US" }),
{ signal: controller.signal },
);
if (!result.passed) {
for (const err of result.errors) console.warn(err.code, err.message);
}
```
## API Reference
### `class RuleEngine`
- `new RuleEngine(config?)` — Zod-validates config. Throws `RulesError(RULES.CONTEXT_INVALID)` on invalid input.
- `config.maxDepth: number > 0` — defaults to `DEFAULT_MAX_DEPTH` (32).
- `config.trace: boolean` — engine-wide default for trace emission.
- `config.defaultSignal: AbortSignal` — applies when per-call `options.signal` is absent.
- `use(plugin) -> this` — install once per `plugin.name`. Subsequent `use(samePlugin)` are no-ops.
- `hasPlugin(name) -> boolean`.
- `evaluate(rule, context, options?) -> Promise<EvaluationResult>` — full evaluation, returns the result rather than throwing on miss.
- `evaluateAll(rules, context, options?) -> Promise<readonly EvaluationResult[]>` — preserves input order.
- `evaluateRuleset(ruleset, context, options?) -> Promise<EvaluationResult>` — Zod-validates the ruleset; rejects with `RULES.RULESET_INVALID` if the shape is wrong; applies `ruleset.operator` over child outcomes; honours `failFast`.
- `check(rule, context, options?) -> Promise<boolean>` — convenience wrapper.
### `interface RuleEngineConfig`
| Field | Type | Default | Meaning |
|---|---|---|---|
| `maxDepth` | `number` | `32` | Recursion ceiling. Past this, evaluation raises `RULES.ENGINE_DEPTH_EXCEEDED` (surfaced in `result.errors`). |
| `trace` | `boolean` | `false` | Engine-wide default for trace emission; per-call `options.trace` overrides. |
| `defaultSignal` | `AbortSignal` | — | Applied when per-call `options.signal` is omitted. |
### `interface RuleEnginePlugin`
- `name: string` — used for idempotency.
- `install(registrar): void` — registrar exposes `registerOperator(name, definition)`.
### `interface EvaluationOptions`
| Field | Type | Meaning |
|---|---|---|
| `signal` | `AbortSignal` | Per-call abort. Pre-aborted signals short-circuit before any rule fires. |
| `maxDepth` | `number` | Per-call depth ceiling; overrides engine config. |
| `trace` | `boolean` | Per-call trace toggle; overrides engine config. |
| `cache` | `boolean` | Reserved for cache integration (not yet implemented; documented in OpenSpec). |
## Implementation Notes
- The engine returns `passed: false` with `errors` populated rather than throwing for any normal evaluation outcome (miss, depth exceeded, abort, unknown operator). Throwing is reserved for truly exceptional cases — `RulesError(RULES.CONTEXT_INVALID)` on invalid config supplied at construction. This contract makes the engine safe to call from middleware that must not throw.
- The internal compare dispatcher is a single switch over 33 operator names. The fall-through `default` is typed as `never` so adding a new operator to the union surfaces as a compile error in the engine.
- Trace emission is `O(rules-visited)`. The trace array is captured per call and never shared across evaluations.
- `evaluateRuleset` deliberately re-runs every child through `evaluate(...)` (the trace boundary) so its own metadata aggregation is straightforward; it does not call the internal `evaluateRule` recursion. The cost is one extra `performance.now()` per child, which is negligible.
- The OTel surface is wired through `getInstruments()` (lazy, cache-stable). Tests asserting emission install an in-memory `MeterProvider` and call `resetInstruments()` to bind to it before exercising the engine.
- The engine instance is thread-safe in the JS sense: no per-call mutable state lives on `this`; per-call state (`traceEntries`, `maxDepthSeen`) is stack-local. Two concurrent `evaluate` calls do not interfere.

View File

@@ -0,0 +1,66 @@
# Internal operator dispatcher — `@suaveplan/rules/engine/operators-internal`
## Purpose
`compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedicated `operators` module ships. It maps every member of the 33-entry `ComparisonOperator` union to a deterministic boolean over `(actual, expected)`. Unknown operators raise `RulesError(RULES.OPERATOR_NOT_FOUND)`; the engine intercepts that error to increment the `rules.operator.unknown_total{name}` counter and converts the failure into a `passed: false` evaluation result.
This file is private to the engine (not re-exported from the package root). The public `operators` module — when it lands — will register the same 30+ operators against a real `OperatorRegistry` and the engine will dispatch through that registry instead. Both paths satisfy the same `OperatorDefinition.evaluate` contract, so call sites do not change.
## Features
- Single-function module — no class, no state, no module-level side effects.
- Exhaustive `switch` over `ComparisonOperator`; `default` is typed `never` so adding a new operator name to the union is a compile error here.
- Handles `null` / `undefined` / non-string / non-array inputs gracefully — every operator has a documented behaviour on the wrong shape rather than throwing.
- `matches` / `notMatches` accept either a string source or a `RegExp` instance; the source is recompiled to a `RegExp` per call.
## Usage: Basic Example
```ts
import { compareValues } from "@suaveplan/rules/engine/operators-internal";
compareValues(21, "greaterThanOrEqual", 18); // true
compareValues([1, 2, 3], "containsAll", [1, 2]); // true
```
## Usage: Advanced Example
```ts
import { compareValues } from "@suaveplan/rules/engine/operators-internal";
import { RulesError, isRulesError } from "@suaveplan/rules/errors";
function safeCompare(a: unknown, op: string, b: unknown): boolean {
try {
return compareValues(a, op as never, b);
} catch (err) {
if (isRulesError(err) && err.code === "RULES.OPERATOR_NOT_FOUND") {
return false; // unknown operator behaves as "no match"
}
throw err;
}
}
```
## API Reference
### `compareValues(actual: unknown, operator: ComparisonOperator, expected: unknown): boolean`
Returns the boolean outcome of applying `operator` to `(actual, expected)`. Documented per-operator semantics:
- **equality**: `equals` / `strictEquals` use `===`; `notEquals` / `strictNotEquals` use `!==`. Both pairs are equivalent — the strict variants are kept so consumers using a JSON DSL can be explicit.
- **numeric**: `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` cast both sides to `number`. `between` / `notBetween` accept `[min, max]` tuples and are inclusive on both bounds.
- **collection**: `in` / `notIn` test membership of `actual` in `expected` (an array). `contains` / `notContains` work on either arrays or strings; non-array, non-string `actual` returns `false` for `contains` and `true` for `notContains`. `containsAll` / `containsAny` / `overlaps` require both sides to be arrays.
- **existence**: `exists` is `actual !== undefined`; `notExists` is the inverse. `isNull` / `isNotNull` are strict `=== null` checks. `isEmpty` / `isNotEmpty` cover string / array / object / null / undefined.
- **type**: `isType` is `typeof actual === expected`; `isNotType` is the inverse. `hasProperty` is `Object.hasOwn(actual, expected)` over objects (false otherwise).
- **length**: `lengthEquals` / `lengthGreaterThan` / `lengthLessThan` operate on string / array / object (object uses `Object.keys` count). Other types yield `NaN` which makes the comparison fall through to `false`.
- **string**: `startsWith` / `endsWith` require `typeof actual === "string"`. `matches` accepts a string or `RegExp`; on non-string `actual`, `matches` is `false` and `notMatches` is `true`.
### Throws
- `RulesError(RULES.OPERATOR_NOT_FOUND)` when `operator` is outside the documented union.
## Implementation Notes
- The dispatcher does NOT memoise the per-call `RegExp` construction in `matches` / `notMatches`. For rules that fire frequently with the same pattern, callers can pre-compile and pass the `RegExp` instance instead of a string — `compareValues` accepts both.
- The `default` branch uses `const unknownOperator: never = operator;` to leverage the compiler's exhaustiveness checking; the runtime `throw` is a defensive belt-and-braces against `as never` casts at the boundary.
- `countable(value)` is the shared length helper; non-countable inputs return `NaN` so every length comparison naturally yields `false` (NaN comparisons are always false).
- The function is intentionally synchronous. Async operators are out of scope for the built-in dispatcher — plugins that register async operators on the engine go through the `OperatorDefinition.evaluate` contract which accepts a `Promise<boolean>` return.

View File

@@ -0,0 +1,104 @@
# `RulesError` — Typed error registry for `@suaveplan/rules`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RulesError` is the single error class thrown across the entire `@suaveplan/rules` surface. It extends `SuaveplanError<RulesErrorCode>` so every failure carries a stable, machine-readable code drawn from a frozen 24-entry registry, plus the standard `SuaveplanError` wire format, OTel-adapter integration, and cause-chain preservation. Callers should never catch a generic `Error` from this package — `instanceof RulesError` (or `isRulesError(value)`) is the canonical discrimination path.
The registry is defined at module load via `defineCodes("RULES", [...] as const)`, which exports `RULES_CODES` as a readonly tuple literal. The `RulesErrorCode` type is derived from that tuple, so the TypeScript compiler enforces that every `new RulesError(code, ...)` call site uses a code that actually exists in the registry. Adding a new code requires an OpenSpec change (the conformance script verifies the registry against the spec's `## ADDED Requirements`).
## Features
- 24 stable codes covering engine / operator / field-resolution / action / event / validator / serializer / optimizer / plugin failure modes.
- `RulesError` class extends `SuaveplanError` for cross-package wire-format and OTel-adapter compatibility.
- Optional `cause`, `traceId`, and `details` fields preserved through construction.
- `isRulesError(value)` type guard for catch arms that need to discriminate package errors from unrelated runtime errors.
- Every code is forward-declared at module load — no dynamic registration, no string-typed escape hatches.
## Usage: Basic Example
```ts
import { RulesError, isRulesError } from "@suaveplan/rules/errors";
function evaluate(rule: unknown) {
if (typeof rule !== "object" || rule === null) {
throw new RulesError("RULES.RULE_INVALID", "rule must be an object");
}
// ... real evaluation
}
try {
evaluate(null);
} catch (err) {
if (isRulesError(err) && err.code === "RULES.RULE_INVALID") {
// structured handling
} else {
throw err;
}
}
```
## Usage: Advanced Example
```ts
import { RulesError } from "@suaveplan/rules/errors";
async function resolveField(data: unknown, path: string, strict: boolean) {
try {
return walk(data, path);
} catch (cause) {
throw new RulesError(
"RULES.FIELD_RESOLUTION_FAILED",
`cannot resolve "${path}" in strict mode`,
{
cause,
traceId: currentTraceId(),
details: { path, strict, hasData: data !== undefined },
},
);
}
}
```
The `details` object becomes part of the structured wire format when the error
crosses a process boundary (for example, when a Hono middleware serialises a
denied request into a JSON envelope). The OTel adapter on `SuaveplanError`
projects `code`, `traceId`, and `details` as span attributes automatically.
## API Reference
### `RULES_CODES: readonly RulesErrorCode[]`
The frozen 24-entry registry of stable error codes. Every entry is prefixed
with `RULES.` and corresponds to a documented failure mode in the package's
OpenSpec contract.
### `type RulesErrorCode`
Literal-union type derived from `RULES_CODES`. Constructor calls that pass a
string not in the union are rejected at compile time.
### `class RulesError extends SuaveplanError<RulesErrorCode>`
- `new RulesError(code, message, opts?)` — constructor.
- `code: RulesErrorCode` — required, must be a member of `RULES_CODES`.
- `message: string` — required, human-readable summary.
- `opts.cause?: unknown` — original thrown value when wrapping.
- `opts.traceId?: string` — propagated to the SuaveplanError base; appears in OTel span attributes.
- `opts.details?: Readonly<Record<string, unknown>>` — structured context; omitted from `err.details` when not supplied.
- Inherits `code`, `message`, `cause`, `traceId`, `toWire()` from `SuaveplanError`.
- `name` is `"RulesError"`.
### `isRulesError(value: unknown): value is RulesError`
Returns `true` only when `value` is a `RulesError` instance. Plain `Error`
objects with a `code` field do not satisfy the guard.
## Implementation Notes
- The registry is intentionally flat (no nested namespaces). Sub-domain grouping is expressed via the prefix (`ENGINE_*`, `ACTION_*`, `VALIDATOR_*`) rather than a structured tree, matching the convention used by `@suaveplan/crypto-rsa` and `@suaveplan/webauthn`.
- `details` is stored only when explicitly supplied; the property is `undefined` (not an empty object) otherwise. This keeps the wire format compact and lets receivers test `if (err.details)` without false positives.
- `RulesError` is `extends SuaveplanError` not `extends Error` — the AGENTS.md §2c rule against deleting registry entries to satisfy a gate applies here. If a code is "unused", the fix is to wire it into the path the proposal designed it for, not to remove it.
- Cross-runtime: tests import runner primitives from `@suaveplan/testing/runner` per CLAUDE.md rule 11. Direct `vitest` / `bun:test` imports are banned in this package.

View File

@@ -0,0 +1,100 @@
# EventBus module — `@suaveplan/rules/events`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pub/sub bus used by the engine and downstream consumers (Hono middleware, audit logs, observability sinks) to broadcast lifecycle events without coupling. Topics are drawn from the typed `RuleEngineEvent` union — adding a new topic is a type change rather than a string literal. The bus supports plain `on` / `off` / `once` subscriptions, priority-ordered `hook` registrations (higher fires first), a bounded ring-buffer history (default 1000 entries), an awaitable `waitFor(event, { timeoutMs?, signal? })` API, and OTel emission counting every payload by `type`.
A sibling `HookManager` class scopes the priority-hook surface to a single topic so callers can manage hooks without holding the parent bus — useful for engine plugins that own a handful of lifecycle hooks but should not have a back-channel to the global event stream.
## Features
- Typed topic API — every `on` / `emit` is keyed by `RuleEngineEvent`.
- Priority hooks — `hook(event, { priority, listener })`; sorted descending by priority on register; finite-priority enforcement raises `RULES.HOOK_INVALID`.
- Awaitable `waitFor` — resolves with the payload on first match; rejects with `RULES.EVENT_TIMEOUT` past `timeoutMs` or `RULES.EVENT_ABORTED` when the supplied `AbortSignal` fires (including pre-aborted).
- Bounded history — `getHistory()` returns the last N payloads in arrival order; `clearHistory()` drops the buffer.
- Injectable `Clock` — `bus.now()` for callers that timestamp their own payloads.
- OTel counter — every `emit` / `emitSync` increments `rules.event.emitted_total{type}`.
- `HookManager` — standalone priority-hook bag; `register` / `remove` / `invoke` / `size`.
## Usage: Basic Example
```ts
import { createEventBus } from "@suaveplan/rules/events";
const bus = createEventBus();
bus.on("rule.passed", (payload) => {
console.log(`rule passed at ${payload.timestamp}`);
});
await bus.emit({ type: "rule.passed", timestamp: Date.now() });
```
## Usage: Advanced Example
```ts
import { createEventBus, createHookManager } from "@suaveplan/rules/events";
const bus = createEventBus({ historySize: 200, clock: () => Date.now() });
// Priority-ordered hooks — higher fires first
bus.hook("rule.passed", {
priority: 100,
listener: async (payload) => audit.append(payload),
});
bus.hook("rule.passed", {
priority: 10,
listener: async (payload) => metrics.increment("rule.passed", { type: payload.type }),
});
// Await the next event with a hard 5s ceiling and caller-side abort
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
try {
const payload = await bus.waitFor("ruleset.completed", {
timeoutMs: 5000,
signal: controller.signal,
});
console.log("ruleset finished:", payload.timestamp);
} catch (err) {
console.warn("waitFor failed:", err);
}
// Standalone hook manager — convenient for engine plugin internals
const mgr = createHookManager();
mgr.register({ priority: 1, listener: () => undefined });
await mgr.invoke({ type: "rule.failed", timestamp: bus.now() });
```
## API Reference
### `class EventBus`
- `new EventBus(options?)` — `options.clock` defaults to `() => Date.now()`. `options.historySize` defaults to `DEFAULT_EVENT_HISTORY = 1000`; `<= 0` raises `RULES.HOOK_INVALID`.
- `on(event, listener)` → unsubscribe fn.
- `off(event, listener)` → boolean.
- `once(event, listener)` → unsubscribe fn; auto-removes after first fire.
- `hook(event, { priority, listener, id? })` → unsubscribe fn. Sorted descending; non-finite priority raises `RULES.HOOK_INVALID`.
- `emit(payload)` → `Promise<void>`. Records history, increments OTel counter, runs hooks first (in priority order), then listeners (in registration order). Awaits every callback.
- `emitSync(payload)` — fire-and-forget variant; same ordering but callbacks are not awaited.
- `waitFor(event, options?)` → `Promise<EventPayload>`. Rejects with `RULES.EVENT_TIMEOUT` or `RULES.EVENT_ABORTED`.
- `getHistory()` / `clearHistory()` — history-buffer accessors.
- `now()` — convenience over the injected clock.
### `class HookManager`
Standalone priority-hook bag scoped to one topic. Methods: `register`, `remove`, `invoke`, `size`. Same priority + finite-number rules as the bus.
### Factory aliases
`createEventBus(options?)`, `createHookManager()`.
## Implementation Notes
- The bus runs hooks **before** listeners on `emit`. This lets plugins inspect and (in future) decorate the payload before user-land code observes it.
- `on` / `hook` snapshots their bucket via `[...bucket]` before iterating so that unsubscribing during emission does not skip subsequent callbacks.
- `waitFor` registers a one-shot listener plus optional abort + timeout handlers. Every cleanup path (resolve, reject-timeout, reject-abort, pre-abort) runs every cleanup function exactly once via the local `cleanup: (() => void)[]` queue.
- `historyBuffer` is a plain array trimmed with `shift()`. Trimming via a ring index would shave a microsecond per insert but adds bookkeeping; the cost is dominated by listener / hook execution in any realistic workload.
- OTel emission is per-`emit` and not per-listener, so the counter measures bus activity rather than work done by subscribers. Subscribers that want their own metrics emit via their own meter.
- `emitSync` does not return a value (no `Promise` to await), but callback exceptions are NOT silently swallowed — the runtime will surface them as unhandled promise rejections through the `void` discard. Callers that need synchronous error handling should use `emit` and `await` it.

View File

@@ -0,0 +1,84 @@
# Operators module — `@suaveplan/rules/operators`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the canonical `OperatorRegistry` class plus 33 built-in operator definitions covering equality, numeric comparison, collection membership, existence, type, length, and string-shape checks. The engine can dispatch comparison through this registry (or a caller-supplied alternative) instead of its internal `compareValues` table — both paths satisfy the same `OperatorDefinition.evaluate` contract, so call sites do not change.
The four logical operators (`and`, `or`, `not`, `xor`) are surfaced as placeholder `OperatorDefinition`s for callers who enumerate the universe of operators (e.g. building a UI). Their `evaluate` throws — the engine handles logical operators through its `evaluateLogical` path, not the registry.
## Features
- `OperatorRegistry` — `register`, `get`, `mustGet` (raises `RULES.OPERATOR_NOT_FOUND`), `has`, `names`, `unregister`. Supports per-operator `aliases`.
- 30 working comparison operators wrapping the engine's internal `compareValues` dispatcher so behaviour is identical between the registry path and the engine's built-in fast path.
- 4 logical placeholder operators (`and`/`or`/`not`/`xor`) that throw on direct invocation — they exist for enumeration and discoverability.
- `BUILTIN_OPERATORS` — readonly tuple of every built-in.
- `createDefaultOperatorRegistry()` — fresh registry instance pre-populated with the built-ins. Recommended path for tests and callers that want isolation.
- `defaultOperatorRegistry` — process-global pre-populated singleton for one-shot convenience.
## Usage: Basic Example
```ts
import { defaultOperatorRegistry } from "@suaveplan/rules/operators";
const op = defaultOperatorRegistry.mustGet("greaterThan");
await op.evaluate(21, 18); // true
```
## Usage: Advanced Example
```ts
import {
OperatorRegistry,
createDefaultOperatorRegistry,
} from "@suaveplan/rules/operators";
import type { OperatorDefinition } from "@suaveplan/rules";
const isOddOperator: OperatorDefinition<number, void> = {
name: "isOdd",
description: "actual % 2 !== 0",
evaluate: (actual) => Number(actual) % 2 !== 0,
};
const registry: OperatorRegistry = createDefaultOperatorRegistry();
registry.register("isOdd", isOddOperator);
// register an alias too:
registry.register("notDivisibleByTwo", { ...isOddOperator, aliases: ["odd"] });
registry.has("odd"); // true (alias)
registry.unregister("isOdd"); // true
```
## API Reference
### `class OperatorRegistry implements OperatorRegistryLike`
| Method | Returns | Notes |
|---|---|---|
| `register(name, definition)` | `void` | Empty name → `RULES.OPERATOR_INVALID`. Definition aliases are registered as additional names. Re-registration replaces the prior entry. |
| `get(name)` | `OperatorDefinition \| undefined` | Lenient lookup. |
| `mustGet(name)` | `OperatorDefinition` | Strict lookup; raises `RulesError(RULES.OPERATOR_NOT_FOUND)` when absent. |
| `has(name)` | `boolean` | Includes aliases. |
| `names()` | `readonly string[]` | Snapshot of all keys (includes aliases). |
| `unregister(name)` | `boolean` | `true` when removed, `false` when absent. |
### Built-in definitions
Every name in the `ComparisonOperator` union has a matching `<name>Operator` export — for example `equalsOperator`, `greaterThanOperator`, `betweenOperator`, `containsOperator`, `startsWithOperator`, `matchesOperator`, `lengthEqualsOperator`. Each definition delegates to `compareValues(actual, name, expected)` so registry-path and engine-internal-path behaviour are bit-identical.
### `createDefaultOperatorRegistry(): OperatorRegistry`
Returns a fresh registry pre-populated with `BUILTIN_OPERATORS`. Prefer this over `defaultOperatorRegistry` when you need isolation between tests, requests, or tenants.
### `defaultOperatorRegistry: OperatorRegistry`
Process-global pre-populated registry. Mutable — callers who register custom operators against it will see those changes from every other consumer of the same singleton. Use with care; prefer `createDefaultOperatorRegistry()` when in doubt.
## Implementation Notes
- The four logical placeholder operators (`andOperator`, `orOperator`, `notOperator`, `xorOperator`) throw on direct `evaluate` call because logical operators in this package have always been engine-managed (they need access to the child rule list, depth tracker, and abort signal — none of which fit the `OperatorDefinition.evaluate(actual, expected, context?)` shape). They are kept for enumeration completeness and would be removed if a future change unifies the dispatch model.
- `BUILTIN_OPERATORS` is `Object.freeze`d to make its shape part of the public contract — consumers can iterate it but cannot mutate.
- The registry's alias handling is deliberately simple: aliases register additional keys pointing at the same `OperatorDefinition` reference. `unregister(alias)` removes only the alias, not the primary name.
- This module does NOT register itself against the engine at import time. Engine consumers wire it explicitly via `engine.use(plugin)` when they want registry-based dispatch.

View File

@@ -0,0 +1,106 @@
# Optimizer — `@suaveplan/rules/optimizer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure functional optimiser for the `Rule` tree. Applies four independent passes — **flatten** (collapse nested AND-of-AND / OR-of-OR), **dedupe** (drop structurally-equal siblings via `hashRule`), **simplify** (unwrap single-child AND/OR), **sort** (rearrange AND/OR children by ascending estimated cost for short-circuit benefit) — each toggleable via `OptimizeOptions`. Companion analytics — `analyzeRule`, `estimateCost`, `findFields`, `hasAsyncComponents`, `countRules`, `isOptimisationStable` — expose the shapes the optimiser uses internally so callers can introspect rules without re-walking.
The optimiser never changes rule semantics — both passes either reduce work or reorder commutative siblings. NOT is preserved unchanged (single-child by design); group operators are sorted only when explicitly `and` / `or`.
## Features
- Four toggleable passes; every pass defaults to `true`.
- Structural hashing via `serializer.hashRule` for dedupe equality.
- `RuleStats` analytics: `totalRules`, `maxDepth`, `fields`, `hasAsync`, `estimatedCost`.
- `OptimizeResult` reports `before` and `after` stats so callers can measure savings.
- Per-operator cost estimates favour short-circuiting cheap conditions first.
## Usage: Basic Example
```ts
import { and, condition } from "@suaveplan/rules/builder";
import { optimizeRule } from "@suaveplan/rules/optimizer";
const r = and(
and(condition("a", "equals", 1), condition("b", "equals", 2)),
condition("c", "equals", 3),
);
const { rule: optimised, before, after } = optimizeRule(r);
// optimised is the flat 3-child AND; before.totalRules > after.totalRules
```
## Usage: Advanced Example
```ts
import { and, condition } from "@suaveplan/rules/builder";
import {
analyzeRule,
findFields,
optimizeRule,
} from "@suaveplan/rules/optimizer";
const policy = and(
condition("region", "matches", "^US|CA$"), // cost 10
condition("age", "greaterThanOrEqual", 18), // cost 1
condition("verified", "equals", true), // cost 1
);
// Only run the sort pass — keep nested structure intact
const { rule } = optimizeRule(policy, {
flatten: false,
dedupe: false,
simplify: false,
sort: true,
});
const stats = analyzeRule(rule);
console.log(stats); // { totalRules: 4, maxDepth: 1, fields: [...], hasAsync: false, estimatedCost: 12 }
console.log(findFields(rule)); // ["region", "age", "verified"]
```
## API Reference
### `optimizeRule(rule, options?)` → `OptimizeResult`
Runs the configured passes. Returns `{ rule, before, after }` where stats are from `analyzeRule`.
### `interface OptimizeOptions`
| Option | Default | Effect |
|---|---|---|
| `flatten` | `true` | Collapse same-operator nested logicals (AND-of-AND, OR-of-OR). |
| `dedupe` | `true` | Drop structurally-equal siblings (uses `hashRule`). |
| `simplify` | `true` | Unwrap single-child AND/OR (NOT is preserved). |
| `sort` | `true` | Reorder AND/OR children by ascending estimated cost. |
### Analytics
- `analyzeRule(rule)` → `RuleStats`.
- `estimateCost(rule)` → number — sum of per-node costs.
- `findFields(rule)` → list of distinct field paths touched.
- `hasAsyncComponents(rule)` → `true` when any node is a `CustomRule` or `ComputedRule`.
- `countRules(rule)` → total node count.
- `isOptimisationStable(rule)` → `true` when `optimizeRule(rule).rule` hashes the same as the input.
### Cost model
| Node | Cost |
|---|---|
| `equals` / `notEquals` / `strict*` / `gt` / `gte` / `lt` / `lte` / `isNull` / `isNotNull` / `exists` / `notExists` | 1 |
| `matches` / `notMatches` | 10 |
| `containsAll` / `containsAny` / `overlaps` | 5 |
| every other condition operator | 2 |
| `custom` / `computed` | 20 |
| `logical` / `group` | 0 (cost lives on children) |
Per-node costs are constants; the model assumes no live profiling of operator throughput. Callers that have measured workloads can build their own cost model and reorder explicitly via the builder.
## Implementation Notes
- The dedupe pass canonicalises children by `hashRule` so commutative-order-different but structurally-identical children collapse together. The first occurrence wins.
- Sort is stable per the JS spec; equal-cost children preserve input order.
- The `simplify` pass intentionally does NOT unwrap `not` because a single-child NOT is the only valid form — collapsing it would lose the inversion. Similarly, single-child groups are not unwrapped because their `name` is a contract for callers.
- `isOptimisationStable` is intended for build-time CI checks: rules that hash differently before vs after optimisation indicate either dead code in the rule tree or an opportunity to commit the optimised form back into source.
- Field discovery (`findFields`) walks both `ConditionRule.field` and `ComputedRule.field`. Group `name` is NOT considered a field.

View File

@@ -0,0 +1,81 @@
# OTel instrument surface — `@suaveplan/rules`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Centralises the OpenTelemetry meter, tracer, and pre-declared instruments consumed across the rules engine, validators, action executor, event bus, and serializer. Instruments are created lazily on first access against the global `MeterProvider` and `TracerProvider`, which keeps the package usable with or without an SDK installed and means no module-level side effects fire at import time. Tests install an in-memory `MeterProvider` per the AGENTS.md §13 row 5 rule ("OTel instruments emitted from production paths, asserted with a real in-memory `MeterProvider`") and read counters / histograms back via the SDK's reader API; no `vi.mock` is required or permitted.
The instrument names are the canonical surface declared in the OpenSpec spec delta. They are stable across minor versions of the package and act as a contract for downstream dashboards and alerts.
## Features
- `getInstruments()` — returns a frozen-shape `RulesInstruments` record on first call, then returns the cached reference on every subsequent call (lifecycle dedup — see AGENTS.md §13 row 6).
- `resetInstruments()` — test-only helper that drops the cache so the next `getInstruments()` rebuilds against a freshly-installed provider. Production code must never call it.
- Tracer + 10 instruments covering engine evaluate / depth / operator-unknown, action execute / executions, event emission, validator validate / validations, and serializer duration.
- No SDK dependency: the package depends only on `@opentelemetry/api`. When no SDK is installed the no-op meter is used and the instruments still satisfy the contract.
## Usage: Basic Example
```ts
import { getInstruments } from "@suaveplan/rules/otel";
const { engineEvaluateDuration, engineEvaluationsTotal } = getInstruments();
const start = performance.now();
// ... evaluate ...
const elapsed = performance.now() - start;
engineEvaluateDuration.record(elapsed);
engineEvaluationsTotal.add(1, { outcome: "pass" });
```
## Usage: Advanced Example
```ts
import { MeterProvider, InMemoryMetricReader } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import { getInstruments, resetInstruments } from "@suaveplan/rules/otel";
const reader = new InMemoryMetricReader();
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetInstruments(); // force the cache to pick up the fresh provider
const { engineEvaluationsTotal } = getInstruments();
engineEvaluationsTotal.add(1, { outcome: "pass" });
const { resourceMetrics } = await reader.collect();
// assert against resourceMetrics.scopeMetrics[0].metrics ...
```
## API Reference
### `interface RulesInstruments`
| Field | Type | Description |
|---|---|---|
| `tracer` | `Tracer` | Package-scoped tracer used for spans. |
| `engineEvaluateDuration` | `Histogram` | Engine `evaluate*` wall-time in ms. |
| `engineEvaluationsTotal` | `Counter` | Engine evaluation count, labelled `{outcome}`. |
| `engineDepth` | `Histogram` | Max recursion depth reached during an evaluate. |
| `operatorUnknownTotal` | `Counter` | Engine hit on an unknown operator name. |
| `actionExecuteDuration` | `Histogram` | Action handler wall-time in ms. |
| `actionExecutionsTotal` | `Counter` | Action execution count, labelled `{status}`. |
| `eventEmittedTotal` | `Counter` | EventBus emissions, labelled `{type}`. |
| `validatorValidateDuration` | `Histogram` | Validator wall-time in ms, labelled `{kind}`. |
| `validatorValidationsTotal` | `Counter` | Validator invocations, labelled `{kind, outcome}`. |
| `serializerDuration` | `Histogram` | Serializer wall-time in ms, labelled `{op}`. |
### `getInstruments(): RulesInstruments`
Returns the cached instrument record; constructs it on first call.
### `resetInstruments(): void`
Drops the cache. Tests use this between cases to bind to a fresh `MeterProvider`. Production code must never call this — it would force every downstream caller to re-resolve instruments on the next emission.
## Implementation Notes
- The `cached` module-local is the only mutable state in the file. It is `null` initially and is set exactly once per process under normal operation (or repeatedly under `resetInstruments()` in tests).
- Instrument names are namespaced under `rules.` and `rules_hono.` (the latter lives in the sibling package). Cardinality is kept low: outcomes are bounded enums (`pass | fail | aborted | error`) and `kind` / `op` enums are documented per call site.
- Re-importing the module after `resetInstruments()` is safe because the cache lives in module scope (ES module specifier identity guarantees one instance per process).

View File

@@ -0,0 +1,98 @@
# Serializer — `@suaveplan/rules/serializer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
JSON-based serializer for the `Rule` discriminated union and `Ruleset`. Rules round-trip through compact or pretty JSON, with a documented placeholder strategy for function-valued fields (`CustomRule.validate`, `ComputedRule.compute`). On deserialization, callers supply name-keyed resolvers (`customResolvers`, `computeResolvers`) that the serializer wires back into the reconstructed tree — missing resolvers raise `RULES.DESERIALIZE_FAILED` so callers can detect partial serialisation up front.
The module also exposes structural utilities — `cloneRule` (deep copy via round-trip), `hashRule` (commutative-order-insensitive FNV-1a hash), `areRulesEqual` (hash-based equality), and `validateSerializedRule` (boundary Zod validator).
## Features
- `serializeRule` / `serializeRuleToObject` / `serializeRuleset` / `serializeRulesetToObject` — JSON / object forms.
- `deserializeRule` / `deserializeRuleFromObject` / `deserializeRuleset` / `deserializeRulesetFromObject` — accept JSON or object input.
- `FUNCTION_PLACEHOLDER` constant exposed as the literal marker substituted for `validate` / `compute`.
- `cloneRule` produces a structurally-equal but distinct rule tree.
- `hashRule` produces a stable hash that is invariant under AND/OR/group child reordering.
- `areRulesEqual` is the hash-based equality.
- `validateSerializedRule` Zod-validates a `SerializedRule` shape; raises `RULES.SERIALIZE_FAILED` on rejection.
- OTel histogram `rules.serializer.duration_ms{op}` emitted for both `serialize` and `deserialize`.
## Usage: Basic Example
```ts
import { condition } from "@suaveplan/rules/builder";
import { deserializeRule, serializeRule } from "@suaveplan/rules/serializer";
const rule = condition("age", "greaterThanOrEqual", 18);
const json = serializeRule(rule);
const back = deserializeRule(json);
```
## Usage: Advanced Example
```ts
import { rule } from "@suaveplan/rules/builder";
import {
deserializeRuleset,
serializeRuleset,
} from "@suaveplan/rules/serializer";
const policy = rule()
.field("age").greaterThanOrEqual(18)
.and()
.custom("kyc-cleared", async (data: { id: string }) => verifyKyc(data.id))
.buildRuleset("rs-onboarding", "Onboarding");
const json = serializeRuleset(policy, { pretty: true });
const back = deserializeRuleset(json, {
customResolvers: {
"kyc-cleared": async (data: unknown) =>
verifyKyc((data as { id: string }).id),
},
});
declare function verifyKyc(id: string): Promise<boolean>;
```
## API Reference
### Serialization
- `serializeRuleToObject(rule)` → `SerializedRule`
- `serializeRule(rule, options?)` → `string` (pretty option indents)
- `serializeRulesetToObject(ruleset)` → `SerializedRuleset`
- `serializeRuleset(ruleset, options?)` → `string`
### Deserialization
- `deserializeRuleFromObject(input, options?)` → `Rule`
- `deserializeRule(json, options?)` → `Rule`
- `deserializeRulesetFromObject(input, options?)` → `Ruleset`
- `deserializeRuleset(json, options?)` → `Ruleset`
`options.customResolvers: Record<string, validator>` — keyed by `CustomRule.name`. `options.computeResolvers: Record<string, computeFn>` — keyed by `ComputedRule.field`.
### Structural utilities
- `cloneRule(rule)` → `Rule` — deep copy via round-trip.
- `hashRule(rule)` → `string` — FNV-1a (8 hex chars). Commutative-order-insensitive for AND / OR / group children.
- `areRulesEqual(a, b)` → `boolean` — hash equality.
- `validateSerializedRule(input)` → `SerializedRule` — Zod validator; raises `RULES.SERIALIZE_FAILED` on bad input.
### Errors
- `RULES.SERIALIZE_FAILED` — unknown rule type at serialize time, or `validateSerializedRule` schema rejection.
- `RULES.DESERIALIZE_FAILED` — bad JSON, schema rejection, missing resolver, or malformed ruleset.
## Implementation Notes
- Function-valued fields use the literal `"__suaveplan_rules_function__"` placeholder. Callers MUST supply a name-keyed resolver to reconstruct them — there is no implicit fallback, by design: silently dropping a custom validator would change rule semantics in production.
- `hashRule` canonicalises the rule tree by sorting children of `and`/`or`/`group` operators by their JSON representation before serialising. This means `and(a, b)` and `and(b, a)` hash to the same value, but order matters for `not` (which has exactly one child) and for sequence-sensitive future operators.
- The Zod schema for `SerializedRule` is a `z.discriminatedUnion` on `type`. The `value` field on conditions is `z.unknown()` because legitimate rule values include arbitrary JSON.
- The serializer is intentionally narrow on metadata: `RuleMetadata` is passed through with `passthrough()` so callers that extend it (e.g. with `tenant: string` field) survive round-trip without recompiling the schema.
- FNV-1a is sufficient for a structural hash (8 hex chars, ~32 bits, low collision rate for the rule sizes we expect). Cryptographic-strength hashing is not the goal; a stable, fast equality check is.
- The OTel histogram is recorded once per top-level entrypoint (`serializeRuleToObject` / `deserializeRuleFromObject`) rather than per-recursive-call to keep cardinality manageable.

View File

@@ -0,0 +1,103 @@
# CustomValidator — `@suaveplan/rules/validators/custom`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CustomValidator` is a single-purpose, named validator backed by a predicate function. Validators are registered against a `ValidatorRegistry` (so they can be looked up by name from caller schemas, plugins, and middleware) and exposed through 11 built-in instances (`emailValidator`, `urlValidator`, `uuidValidator`, `isoDateValidator`, `creditCardValidator`, `phoneValidator`, `strongPasswordValidator`, `alphanumericValidator`, `hexColorValidator`, `slugValidator`, `jsonValidator`) plus 5 parameterised factories (`enumValidator`, `lengthValidator`, `patternValidator`, `rangeValidator`, `asyncValidator`) and one composer (`composeValidators`).
The credit-card validator implements the **Luhn checksum** (mod-10 / mod-N) per ISO/IEC 7812-1 — every other validator that needs structural shape uses a regex plus a tightening predicate.
## Features
- `CustomValidator<T>` class with `validate(value, context?)` returning `ValidationResult<T>` and `isValid(value)` returning `boolean`.
- `ValidatorRegistry` with `register`, `get`, `mustGet` (raises `RULES.VALIDATOR_NOT_FOUND`), `has`, `names`, `unregister`.
- 11 built-in validators ready for use.
- 5 parameterised factories.
- `composeValidators` for short-circuit sequential composition.
- `defaultValidatorRegistry` pre-populated with every built-in; `createDefaultValidatorRegistry()` for callers that need isolation.
## Usage: Basic Example
```ts
import {
createValidator,
emailValidator,
} from "@suaveplan/rules/validators/custom";
const result = await emailValidator.validate("ada@example.com");
result.valid; // true
const custom = createValidator<string>({
name: "ada-only",
validate: (v) => (v === "ada" ? true : "must equal 'ada'"),
});
```
## Usage: Advanced Example
```ts
import {
asyncValidator,
composeValidators,
createDefaultValidatorRegistry,
creditCardValidator,
rangeValidator,
} from "@suaveplan/rules/validators/custom";
const registry = createDefaultValidatorRegistry();
const enterpriseAge = composeValidators([
rangeValidator(18, 100),
asyncValidator<number>(async (value) => {
const allowed = await isMinimumAgeForRegion(value);
return allowed ? true : "below regional minimum age";
}),
]);
registry.register(enterpriseAge);
const result = await creditCardValidator.validate("4111111111111111");
if (!result.valid) console.warn(result.errors[0]?.message);
declare function isMinimumAgeForRegion(age: number): Promise<boolean>;
```
## API Reference
### `class CustomValidator<T>`
- `new CustomValidator(definition)` — empty `name` raises `RULES.VALIDATOR_INVALID`.
- `validate(value, context?)` → `Promise<ValidationResult<T>>`.
- `isValid(value, context?)` → `Promise<boolean>` convenience.
### `class ValidatorRegistry`
Standard register/get/mustGet/has/names/unregister surface.
### Built-in validators
`emailValidator`, `urlValidator`, `uuidValidator`, `isoDateValidator`, `creditCardValidator` (Luhn), `phoneValidator`, `strongPasswordValidator` (length 8 + upper + lower + digit + symbol), `alphanumericValidator`, `hexColorValidator`, `slugValidator`, `jsonValidator`.
### Parameterised factories
- `enumValidator<T>(values)` — `value ∈ values`.
- `lengthValidator(min, max)` — string length window.
- `patternValidator(re, message?)` — string regex match.
- `rangeValidator(min, max)` — numeric range.
- `asyncValidator(fn)` — async predicate wrapper.
### `composeValidators(validators)` → `CustomValidator`
Returns a synthetic `"composed"` validator that runs each child in order, short-circuiting on the first failure.
### `createDefaultValidatorRegistry()` / `defaultValidatorRegistry`
Fresh and process-global registries pre-populated with every built-in.
## Implementation Notes
- The Luhn algorithm in `creditCardValidator` requires the digit-only input length to fall in `[12, 19]` (ISO 7812-1). Non-string inputs return `false` immediately.
- `strongPasswordValidator` returns a specific per-failure message (`min length 8`, `needs uppercase`, etc.) so callers can present targeted feedback. Tests assert each branch.
- `composeValidators` constructs a top-level `CustomValidator` whose `validate` synthesises an aggregate message from the first child failure. The synthetic validator name `"composed"` is suitable as-is for OTel labels.
- The parameterised factories are functions rather than classes so callers can hold references to identical instances when needed (`const v = lengthValidator(1, 10); /* reused */`).
- `defaultValidatorRegistry` is mutable. Production code that needs isolation between requests / tenants should construct its own via `createDefaultValidatorRegistry()`.

View File

@@ -0,0 +1,88 @@
# FieldValidator — `@suaveplan/rules/validators/field`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FieldValidator` is a chainable, immutable-per-call validator for a single value. Every `withX` method returns a fresh validator with the new stage appended, so preset chains can be shared across requests without aliasing concerns. `validateSync(value)` runs purely-sync chains; `validate(value, options?)` returns a Promise and threads `AbortSignal` through every stage. The module ships 12 preset chains covering the common shapes (email, password, username, phone, URL, UUID, date, integer, age, currency, percentage, positive number).
The chain is the natural fit when validating one value at a time (form field, query parameter, header). For object-tree validation, prefer `SchemaValidator`; for plug-in-style reusable validators with a registry, prefer `CustomValidator`.
## Features
- Immutable chain — every `.withX` returns a new `FieldValidator`.
- Sync + async stages; `validateSync` throws `RULES.VALIDATOR_INVALID` when invoked on an async chain.
- 9 builder methods: `required`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `oneOf`, `custom`, `customAsync`.
- 12 preset factories.
- OTel histogram (`rules.validator.validate.duration_ms{kind:"field"}`) + counter (`rules.validator.validations_total{kind:"field",outcome}`).
## Usage: Basic Example
```ts
import { field } from "@suaveplan/rules/validators/field";
const validator = field().required().minLength(3).maxLength(20);
const result = await validator.validate("ada");
result.valid; // true
```
## Usage: Advanced Example
```ts
import {
field,
passwordFieldValidator,
usernameFieldValidator,
} from "@suaveplan/rules/validators/field";
// Compose a preset with caller-specific extensions
const adminPasswordValidator = passwordFieldValidator()
.minLength(12, "admin passwords must be at least 12 chars")
.customAsync(async (value) => {
const breached = await checkHibp(value as string);
return breached ? "password appears in known-breach corpus" : true;
});
const username = usernameFieldValidator();
expect((await username.validate("ada42")).valid).toBe(true);
declare function checkHibp(password: string): Promise<boolean>;
declare const expect: { (received: unknown): { toBe(v: unknown): void } };
```
## API Reference
### `class FieldValidator`
- `FieldValidator.create()` / `createFieldValidator()` / `field()` — entry points.
- `required(message?)`, `minLength(min, message?)`, `maxLength(max, message?)`, `min(min, message?)`, `max(max, message?)`, `pattern(re, message?)`, `oneOf(values, message?)`, `custom(fn, message?)`, `customAsync(fn, message?)` — each returns a new `FieldValidator`.
- `validate(value, options?)` → `Promise<FieldValidatorResult>` where `FieldValidatorResult = { valid: boolean; errors: readonly string[] }`. Honours `options.signal`.
- `validateSync(value)` → `FieldValidatorResult`. Throws `RulesError(RULES.VALIDATOR_INVALID)` when the chain has any async stage.
- `isAsync()` → `true` when the chain contains any async stage.
### Preset chains
| Factory | Behaviour |
|---|---|
| `emailFieldValidator()` | required + email regex |
| `urlFieldValidator()` | required + `^https?://` |
| `uuidFieldValidator()` | required + RFC-4122 8-4-4-4-12 |
| `phoneFieldValidator()` | required + `^\+?[\d\s\-().]{7,}$` |
| `usernameFieldValidator()` | required + `^[a-z0-9_]{3,32}$` (case-insensitive) |
| `passwordFieldValidator()` | required + minLength 8 + upper + lower + digit |
| `dateFieldValidator()` | `instanceof Date && !NaN` |
| `integerFieldValidator()` | `Number.isInteger` |
| `ageValidator()` | required + range 0..150 |
| `currencyValidator()` | required + non-negative number |
| `percentageValidator()` | required + range 0..100 |
| `positiveNumberValidator()` | required + > 0 |
## Implementation Notes
- Every stage runs against the original input value (not the previous stage's output) — `FieldValidator` is a multi-predicate aggregator, not a transform pipeline. For transforms, use `SchemaValidator` with the `transforms` field.
- `validate` always records every stage that fails — it does not short-circuit. This makes the error list complete in a single pass, which matches the convention used by Zod and Joi.
- A pre-aborted signal short-circuits the loop and records the synthetic `"validation aborted"` error. Subsequent stages do not run.
- The async-stage detector (`isAsync`) is `O(stages)` and runs once on `validateSync`. Callers that want fast-path sync validation should check `isAsync()` first.
- `custom` accepts a fn returning `true | false | string`. `false` falls back to either the supplied `message` or the default `"custom validation failed"`; a string return is treated as the error message verbatim.
- Preset chains are factory functions (not module-level constants) so consumers get an independent instance per call and can chain further without affecting other callers.

View File

@@ -0,0 +1,129 @@
# SchemaValidator — `@suaveplan/rules/validators/schema`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SchemaValidator` validates a `Record<string, unknown>` against a JSON-schema-like `Schema` definition. It covers the 8 primitive `FieldType`s (`string`, `number`, `integer`, `boolean`, `object`, `array`, `date`, `datetime`, `any`), 7 string formats (`email`, `url`, `uuid`, `date`, `datetime`, `ipv4`, `ipv6`, `regex`), 5 transforms (`trim`, `lowercase`, `uppercase`, `parseNumber`, `parseDate`), `enum` checks, `min`/`max`/`minLength`/`maxLength`/`pattern` constraints, recursive nested objects, recursive array items, and caller-supplied custom validators keyed by field path.
Every input schema is Zod-validated at construction — malformed schemas raise `RulesError(RULES.SCHEMA_INVALID)`. The `validate` method returns a `ValidationResult` rather than throwing for any normal failure mode (missing required, wrong type, format miss, custom-refinement failure). OTel histogram and counter fire per invocation.
## Features
- 8 field types with strict matching (e.g. `integer` rejects non-integer numbers).
- 7 format checks via shared regex map.
- 5 transforms applied before validation; non-applicable transforms (e.g. `lowercase` on a number) are no-ops.
- Nested objects + arrays validated recursively with structured paths (`user.profile.name`, `items[3].sku`).
- `enum` membership checks.
- `min` / `max` / `minLength` / `maxLength` / `pattern` constraints.
- Custom validators registered by field path; return `true`, `false`, or a string error message.
- Zod-validated `Schema` at construction → `RULES.SCHEMA_INVALID` on rejection.
- Pre-aborted `AbortSignal` short-circuits the recursion with `RULES.SCHEMA_INVALID`.
- Returns `{ valid, errors, value }` where `value` carries the transformed data tree.
## Usage: Basic Example
```ts
import { SchemaValidator } from "@suaveplan/rules/validators/schema";
const validator = new SchemaValidator({
email: { type: "string", required: true, format: "email" },
age: { type: "integer", min: 18 },
});
const result = await validator.validate({ email: "ada@example.com", age: 30 });
result.valid; // true
```
## Usage: Advanced Example
```ts
import { SchemaValidator } from "@suaveplan/rules/validators/schema";
const validator = new SchemaValidator(
{
username: {
type: "string",
required: true,
minLength: 3,
maxLength: 20,
transforms: ["trim", "lowercase"],
},
profile: {
type: "object",
properties: {
bio: { type: "string", maxLength: 240 },
tags: {
type: "array",
items: { type: "string", minLength: 2 },
},
},
},
registeredAt: { type: "date", transforms: ["parseDate"], required: true },
},
{
customValidators: {
username: async (value) =>
typeof value === "string" && !value.startsWith("admin")
? true
: "reserved username prefix",
},
},
);
const result = await validator.validate({
username: " ADA ",
profile: { bio: "Mathematician", tags: ["math", "logic"] },
registeredAt: "1815-12-10",
});
if (!result.valid) {
for (const err of result.errors) console.warn(err.path, err.code, err.message);
}
console.log(result.value); // { username: "ada", profile: {...}, registeredAt: Date }
```
## API Reference
### `class SchemaValidator`
- `new SchemaValidator(schema, options?)` — Zod-validates the schema; raises `RULES.SCHEMA_INVALID` on rejection.
- `options.customValidators: Record<string, CustomValidatorFn>` — keyed by field path. Each validator receives `(value, ValidatorContext)` and returns `true`, `false`, or a `string` error.
- `validate(data, options?)` → `Promise<ValidationResult>`. `options.signal` short-circuits.
### `interface Schema`
`Record<string, FieldSchema>` where `FieldSchema` carries `type`, `required`, `format`, `min`, `max`, `minLength`, `maxLength`, `pattern`, `enum`, `items` (recursive), `properties` (recursive), `transforms`, `default`.
### Format dispatch
| Format | Regex |
|---|---|
| `email` | `^[^\s@]+@[^\s@]+\.[^\s@]+$` |
| `url` | `^https?://.+` (case-insensitive) |
| `uuid` | RFC-4122 8-4-4-4-12 hex |
| `date` | `^\d{4}-\d{2}-\d{2}$` |
| `datetime` | ISO 8601 prefix `YYYY-MM-DDTHH:MM:SS` |
| `ipv4` | dotted-quad shape |
| `ipv6` | hex-and-colons (not strictly compliant; pre-filter only) |
| `regex` | accepts anything (`.*`) — placeholder slot for future tightening |
### Transform dispatch
| Transform | Applies when | Effect |
|---|---|---|
| `trim` | string | `value.trim()` |
| `lowercase` | string | `value.toLowerCase()` |
| `uppercase` | string | `value.toUpperCase()` |
| `parseNumber` | string | `Number(value)` if not `NaN`; else leave |
| `parseDate` | string | `new Date(value)` if valid; else leave |
## Implementation Notes
- `validateField` is recursive. Pre-aborted signals are checked at each top-level field boundary; nested recursion inherits the signal via the `ValidatorContext`.
- The transformed value is accumulated into a parallel `transformed` tree mirroring the schema shape. Callers consume `result.value` to get the post-transform view of the input.
- `format: "regex"` is a placeholder that always matches. The field-level `pattern` option is the way to assert a specific regex; future hardening can replace the format slot with a per-pattern compile-cache.
- The validator deliberately does NOT short-circuit on first error — it accumulates every issue across the tree so callers can present them all at once. Tests assert specific codes against the first error for clarity; production callers should iterate `result.errors`.
- The CustomValidatorFn return type `boolean | string | Promise<boolean | string>` matches the convention used by zxcvbn and Joi: a string return is treated as a custom error message, otherwise a falsy return generates the default message.
- Type matching is strict: `number` rejects `NaN`; `date` / `datetime` accept only `Date` instances with valid `getTime()`. The `parseDate` transform converts ISO strings into `Date` before the type check fires.