Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,152 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: rules-hono (enterprise-rules-migration)
**Package**: `@suaveplan/rules-hono`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line at authoring time. (AGENTS.md §13.)
## ADDED Requirements
### Requirement: ruleMiddleware evaluates a single rule per request
The package SHALL expose `ruleMiddleware<TContext>(rule, options)` returning a Hono `MiddlewareHandler`. The middleware SHALL invoke `options.extractData?` to build the evaluation context (defaulting to a context derived from `c.req`), call `options.engine ?? getDefaultEngine()` to evaluate, store the `EvaluationResult` in `c.set("ruleResult", result)`, and call `options.onDenied?` (or short-circuit with 403) when the rule fails.
#### Scenario: passes through to next handler when rule passes
- **WHEN** a Hono app uses `ruleMiddleware(passingRule)` and a request is dispatched
- **THEN** the next handler is invoked and `c.var.ruleResult.passed === true`
#### Scenario: 403 short-circuit when rule fails
- **WHEN** a Hono app uses `ruleMiddleware(failingRule)` and no `onDenied` is supplied
- **THEN** the response status is 403 and the next handler is not invoked
**Verified by:** `src/middleware/rule.test.ts::"passes through on rule pass"` AND `src/middleware/rule.test.ts::"403 short-circuits on rule fail"`
---
### Requirement: validationMiddleware validates request body, query, params, and headers
The package SHALL expose `validationMiddleware(options)` accepting per-source schemas under `body` / `query` / `params` / `headers`. The middleware SHALL collect errors per source as `HonoValidationError`, call `options.onError?` (or short-circuit with 400 returning a JSON body) when any errors exist, and honour `abortEarly` to stop at the first failing source.
#### Scenario: body schema rejects malformed JSON
- **WHEN** a request with `Content-Type: application/json` and a body that fails the body schema is dispatched
- **THEN** the response status is 400 and the body lists every failing field with `source: "body"`
#### Scenario: abortEarly stops at first failing source
- **WHEN** body and query both fail and `abortEarly: true` is set
- **THEN** the response body lists only `body` errors
**Verified by:** `src/middleware/validation.test.ts::"body schema rejects malformed JSON"` AND `src/middleware/validation.test.ts::"abortEarly stops at first failing source"`
---
### Requirement: accessControl evaluates a ruleset with caching
The package SHALL expose `accessControl<TContext>(options)` taking a single rule or a ruleset, optionally an injectable `cache: { get(k): Result | undefined; set(k, v, ttl): void }` keyed by `options.cacheKey?(c)`. On pass, the result SHALL be stored in `c.set("accessResult", result)`; on fail, `options.onDenied?` runs or a 403 is returned.
#### Scenario: cache hit short-circuits evaluation
- **WHEN** the cache returns a passed `EvaluationResult` for the computed key
- **THEN** the rule engine is not invoked and the next handler runs with `c.var.accessResult` populated from cache
#### Scenario: 403 on ruleset denial includes errors
- **WHEN** a ruleset evaluates to `passed: false`
- **THEN** the response status is 403 and the JSON body includes the `errors` array
**Verified by:** `src/middleware/access-control.test.ts::"cache hit short-circuits engine"` AND `src/middleware/access-control.test.ts::"403 includes ruleset errors"`
---
### Requirement: responseValidation validates outgoing JSON bodies
The package SHALL expose `responseValidation(options)` that runs AFTER the downstream handler. The middleware SHALL parse the response body when `content-type` is `application/json`, validate it against `options.schema`, and call `options.onError?` (or replace the response with a 500 and a JSON envelope) when validation fails.
#### Scenario: invalid response body replaced with 500 envelope
- **WHEN** the downstream handler returns JSON that fails the schema
- **THEN** the final response has status 500 and a JSON body with `errors`
**Verified by:** `src/middleware/response-validation.test.ts::"invalid body replaced with 500"`
---
### Requirement: rateLimitRule returns 429 when its rule fails
The package SHALL expose `rateLimitRule<TContext>(options)` evaluating `options.rules` against a context built by `options.extractContext`. On failure, the response SHALL be 429 with a `Retry-After` header when `options.retryAfterSeconds?` is provided, optionally allowing `options.onLimited?` to customise the response.
#### Scenario: 429 with Retry-After when over limit
- **WHEN** the rule fails and `retryAfterSeconds: 60` is configured
- **THEN** the response status is 429 and `Retry-After: 60`
**Verified by:** `src/middleware/rate-limit-rule.test.ts::"429 with Retry-After"`
---
### Requirement: combineRules chains middlewares with early-response short-circuit
The package SHALL expose `combineRules(...middlewares)` returning a single `MiddlewareHandler`. The combined middleware SHALL run each child sequentially, short-circuit on the first child that writes a response (no further children run), and pass control to the next handler if all children pass.
#### Scenario: first early response short-circuits
- **WHEN** the first child sends a 403 response
- **THEN** the second child is not invoked
#### Scenario: all-pass chains through
- **WHEN** every child passes through
- **THEN** the next handler runs and observes context variables set by each child
**Verified by:** `src/middleware/combine.test.ts::"first response short-circuits"` AND `src/middleware/combine.test.ts::"all-pass chains through"`
---
### Requirement: Default engine is lazy and per-process
The package SHALL expose `getDefaultEngine()` returning a process-singleton `RuleEngine` constructed on first call (no module-level instantiation). Tests SHALL be able to reset the singleton via `resetDefaultEngine()` (exported under the `./testing` subpath only).
#### Scenario: first call constructs the singleton
- **WHEN** `getDefaultEngine()` is called twice
- **THEN** the second call returns the identical reference (`===`)
#### Scenario: resetDefaultEngine forces reconstruction
- **WHEN** `resetDefaultEngine()` is called, then `getDefaultEngine()` returns a fresh instance
- **THEN** the new instance is not `===` to the previous one
**Verified by:** `src/engine/default.test.ts::"singleton identity"` AND `src/engine/default.test.ts::"resetDefaultEngine forces reconstruction"`
---
### Requirement: All middleware options are validated via Zod at construction
Every middleware factory SHALL Zod-validate its `options` argument at call time and raise `RulesError` with `code: "RULES.PLUGIN_FAILED"` (re-used from the rules core for option-validation failures) when validation rejects.
#### Scenario: validationMiddleware rejects malformed options
- **WHEN** `validationMiddleware({ body: "not-a-schema" } as any)` is called
- **THEN** the call throws `RulesError` `code: "RULES.PLUGIN_FAILED"`
**Verified by:** `src/middleware/options.spec-conformance.test.ts::"rejects malformed options"`
---
### Requirement: OTel surface emits from middleware paths
The package SHALL emit `rules_hono.middleware.<name>.duration_ms{outcome}` and `rules_hono.middleware.<name>_total{outcome}` for each of the six middleware factories, asserted via an in-memory `MeterProvider`.
#### Scenario: every middleware emits its duration histogram
- **WHEN** the full test suite runs against an in-memory `MeterProvider`
- **THEN** each of the six middlewares has at least one recorded `duration_ms` measurement
**Verified by:** `src/middleware/otel.spec-conformance.test.ts::"every middleware emits duration_ms"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,384 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: rules (enterprise-rules-migration)
**Package**: `@suaveplan/rules`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line at authoring time. (AGENTS.md §13.)
## ADDED Requirements
### Requirement: RuleEngine evaluates rules against a context
The package SHALL expose a `RuleEngine` class whose `evaluate(rule, context, options?)` method returns an `EvaluationResult` for any `Rule<TContext>` (condition / logical / group / custom / computed). The result SHALL discriminate pass / fail through the `passed: boolean` field and carry typed `RulesError` codes for every failure mode (depth exceeded, abort, timeout, operator unknown, field resolution failure).
#### Scenario: condition rule passes when operator returns true
- **WHEN** `engine.evaluate({ type: "condition", field: "age", operator: "greaterThan", value: 18 }, { age: 21 })` is called
- **THEN** the result has `passed: true` and `errors.length === 0`
#### Scenario: logical AND short-circuits on first false
- **WHEN** an AND rule contains a failing condition followed by an async condition
- **THEN** the async condition is not awaited and `errors` carries only the first failure
#### Scenario: depth limit raises ENGINE_DEPTH_EXCEEDED
- **WHEN** a rule nests deeper than `config.maxDepth`
- **THEN** the result has `passed: false` and `errors[0].code === "RULES.ENGINE_DEPTH_EXCEEDED"`
#### Scenario: AbortSignal aborts in-flight async evaluation
- **WHEN** `evaluate` is invoked with a pre-aborted `AbortSignal`
- **THEN** the result has `passed: false` and `errors[0].code === "RULES.ENGINE_ABORTED"`
#### Scenario: OTel span emitted per evaluation
- **WHEN** `evaluate` runs against an in-memory `MeterProvider`
- **THEN** `rules.engine.evaluate.duration_ms` is recorded and `rules.engine.evaluations_total{outcome}` is incremented
**Verified by:** `src/engine/engine.test.ts::"evaluates condition rule"` AND `src/engine/engine.test.ts::"short-circuits AND on first failure"` AND `src/engine/engine.test.ts::"raises RULES.ENGINE_DEPTH_EXCEEDED past maxDepth"` AND `src/engine/engine.test.ts::"aborts when signal is pre-aborted"` AND `src/engine/engine.spec-conformance.test.ts::"emits rules.engine.evaluate.duration_ms"`
---
### Requirement: RuleEngine supports evaluateAll, evaluateRuleset, and check
`RuleEngine` SHALL expose `evaluateAll(rules, context, options?)` returning an array of `EvaluationResult`, `evaluateRuleset(ruleset, context, options?)` returning a single combined `EvaluationResult` per the ruleset's operator (`and` / `or`), and `check(rule, context, options?)` returning a `Promise<boolean>` for callers that only need pass/fail.
#### Scenario: evaluateAll preserves rule order
- **WHEN** `evaluateAll([ruleA, ruleB], ctx)` is called
- **THEN** the returned array has `[resultA, resultB]` in input order
#### Scenario: ruleset with operator "or" passes if any child passes
- **WHEN** a ruleset of `{ operator: "or", rules: [failingRule, passingRule] }` is evaluated
- **THEN** the combined result has `passed: true`
#### Scenario: check returns boolean without exposing errors
- **WHEN** `check(rule, ctx)` is called
- **THEN** the return type is `Promise<boolean>` and resolves to the same value as `evaluate(...).passed`
**Verified by:** `src/engine/engine.test.ts::"evaluateAll preserves order"` AND `src/engine/engine.test.ts::"ruleset OR passes when any child passes"` AND `src/engine/engine.test.ts::"check returns boolean"`
---
### Requirement: RuleEngine accepts plugin registrations
`RuleEngine` SHALL accept `RuleEnginePlugin` instances via `engine.use(plugin)` that contribute operators, validators, or actions without forking. Plugins MUST be installed before any evaluation that depends on them.
#### Scenario: plugin installs a custom operator
- **WHEN** a plugin registers operator `"divisibleBy"` and a condition rule references it
- **THEN** the rule evaluates without raising `RULES.OPERATOR_NOT_FOUND`
#### Scenario: plugin install is idempotent
- **WHEN** `engine.use(plugin)` is called twice with the same plugin
- **THEN** the operator is registered exactly once and no error is thrown
**Verified by:** `src/engine/engine.test.ts::"plugin installs custom operator"` AND `src/engine/engine.spec-conformance.test.ts::"plugin install is idempotent"`
---
### Requirement: RuleBuilder offers a fluent DSL
The package SHALL expose `RuleBuilder` and `FieldBuilder` classes plus factory functions `rule()`, `condition()`, `and()`, `or()`, `not()`, `group()` for declarative rule construction. Output SHALL satisfy the `Rule` discriminated union and round-trip through the serializer.
#### Scenario: fluent builder produces a Rule
- **WHEN** `rule("age-check").when("age").greaterThan(18).withMetadata({ priority: "high" }).build()` is called
- **THEN** the returned value matches `ConditionRule` with `field: "age"`, `operator: "greaterThan"`, `value: 18`, `metadata.priority: "high"`
#### Scenario: nested and/or composes
- **WHEN** `and(condition("a","equals",1), or(condition("b","equals",2), condition("c","equals",3)))` is called
- **THEN** the returned tree is a `LogicalRule` with `operator: "and"` whose second child is a `LogicalRule` with `operator: "or"` and two condition children
**Verified by:** `src/builder/builder.test.ts::"fluent builder produces ConditionRule with metadata"` AND `src/builder/builder.test.ts::"and/or compose into nested LogicalRule"`
---
### Requirement: ContextBuilder resolves fields with dot-path and array-index syntax
The package SHALL expose `ContextBuilder`, `DefaultContextProvider`, `DefaultFieldResolver`, and helpers `context()`, `contextWith()`, `getField()`, `hasField()`, `createRequestContext()`, `createScopedContext()`. Field resolution SHALL support dot-path (`"user.profile.name"`) and array-index (`"items[0].sku"`) traversal. The default context provider SHALL take an injectable `Clock` for `timestamp` derivation — no `new Date()` at module scope.
#### Scenario: dot-path resolves nested field
- **WHEN** `getField({ user: { profile: { name: "Ada" } } }, "user.profile.name")` is called
- **THEN** the return value is `"Ada"`
#### Scenario: array-index resolves indexed element
- **WHEN** `getField({ items: [{ sku: "X" }, { sku: "Y" }] }, "items[1].sku")` is called
- **THEN** the return value is `"Y"`
#### Scenario: missing field raises FIELD_RESOLUTION_FAILED in strict mode
- **WHEN** `getField({}, "absent", { strict: true })` is called
- **THEN** the call throws `RulesError` with `code: "RULES.FIELD_RESOLUTION_FAILED"`
#### Scenario: createRequestContext uses injected Clock for timestamp
- **WHEN** `createRequestContext({ data: {}, clock: fakeClock(1700000000000) })` is called
- **THEN** the resulting context has `timestamp` equal to `new Date(1700000000000)`
**Verified by:** `src/context/context.test.ts::"resolves dot-path"` AND `src/context/context.test.ts::"resolves array-index"` AND `src/context/context.test.ts::"raises FIELD_RESOLUTION_FAILED in strict"` AND `src/context/context.test.ts::"uses injected Clock"`
---
### Requirement: Action subsystem executes named handlers with timeout and abort
The package SHALL expose `Action`, `ActionRegistry`, `ActionExecutor` plus `createAction`, `createActionRegistry`, `createActionExecutor`, `validateActionConfig`, and `builtInHandlers` covering `log` / `set` / `delay` / `noop`. `ActionExecutor` SHALL support sequential and parallel execution, configurable timeout per action, and `AbortSignal` propagation. Timeouts SHALL raise `ACTION_TIMEOUT`; aborts SHALL raise `ACTION_ABORTED`. The `log` handler SHALL accept an injectable `LoggerLike` (no `console.log`).
#### Scenario: parallel executor runs actions concurrently
- **WHEN** three 100ms actions are run via `executor.executeParallel(...)` against `fakeClock`
- **THEN** total elapsed time is ≈100ms (not ≈300ms) measured against the clock
#### Scenario: timeout raises ACTION_TIMEOUT
- **WHEN** an action with `timeoutMs: 50` exceeds 50ms
- **THEN** the result has `status: "failed"` and `errorMessage` references `RULES.ACTION_TIMEOUT`
#### Scenario: pre-aborted signal short-circuits before handler runs
- **WHEN** `executor.executeSequential([action], { signal: alreadyAbortedSignal })` is called
- **THEN** the handler is not invoked and the result reports `RULES.ACTION_ABORTED`
**Verified by:** `src/actions/actions.test.ts::"executeParallel runs concurrently"` AND `src/actions/actions.test.ts::"raises ACTION_TIMEOUT past deadline"` AND `src/actions/actions.test.ts::"pre-aborted signal short-circuits"`
---
### Requirement: EventBus emits typed events with priority hooks and bounded history
The package SHALL expose `EventBus`, `HookManager`, `createEventBus`, `createHookManager`. The bus SHALL emit typed `RuleEngineEvent` payloads, maintain a bounded `eventHistory` (capacity injectable; default 1000), support priority-ordered hook registration, and provide `waitFor(eventType, { timeoutMs?, signal? })` returning a `Promise<EventPayload>`. Timeouts SHALL raise `EVENT_TIMEOUT`; aborts SHALL raise `EVENT_ABORTED`.
#### Scenario: history is bounded by capacity
- **WHEN** capacity is 3 and 5 events are emitted
- **THEN** `eventBus.getHistory()` returns exactly the last 3 in insertion order
#### Scenario: higher-priority hooks run first
- **WHEN** two hooks register for the same event with priorities 10 and 1
- **THEN** the priority-10 hook is invoked before the priority-1 hook
#### Scenario: waitFor rejects with EVENT_TIMEOUT past deadline
- **WHEN** `waitFor("rule.passed", { timeoutMs: 50 })` is called and no event fires within the deadline (asserted via `fakeClock`)
- **THEN** the promise rejects with `RulesError` `code: "RULES.EVENT_TIMEOUT"`
**Verified by:** `src/events/event-bus.test.ts::"history is bounded"` AND `src/events/event-bus.test.ts::"hooks run in priority order"` AND `src/events/event-bus.test.ts::"waitFor rejects with EVENT_TIMEOUT"`
---
### Requirement: OperatorRegistry exposes 30 built-in operators
The package SHALL expose `OperatorRegistry`, `defaultOperatorRegistry`, and 30 built-in operators covering: logical (`and`, `or`, `not`, `xor`), comparison (`equals`, `notEquals`, `greaterThan`, `greaterThanOrEquals`, `lessThan`, `lessThanOrEquals`, `between`), collection (`in`, `notIn`, `contains`, `notContains`, `containsAll`, `containsAny`, `overlaps`), string (`startsWith`, `endsWith`, `matches`), existence (`exists`, `isNull`, `isNotNull`, `isEmpty`, `isNotEmpty`, `isType`, `hasProperty`), length (`lengthEquals`, `lengthGreaterThan`, `lengthLessThan`). Each operator SHALL implement the `OperatorDefinition` contract and be registered against `defaultOperatorRegistry` at module load.
#### Scenario: every built-in operator is registered on defaultOperatorRegistry
- **WHEN** `defaultOperatorRegistry.get(name)` is called for each of the 30 documented names
- **THEN** every lookup returns an `OperatorDefinition` (no `undefined`)
#### Scenario: unknown operator raises OPERATOR_NOT_FOUND
- **WHEN** `defaultOperatorRegistry.get("nonexistent")` is called
- **THEN** the call throws `RulesError` with `code: "RULES.OPERATOR_NOT_FOUND"`
#### Scenario: between is inclusive on both bounds
- **WHEN** `betweenOperator.evaluate(5, [5, 10])` is called
- **THEN** the return value is `true`
**Verified by:** `src/operators/operators.test.ts::"defaultOperatorRegistry contains 30 built-ins"` AND `src/operators/operators.test.ts::"unknown operator raises OPERATOR_NOT_FOUND"` AND `src/operators/operators.test.ts::"between is inclusive on both bounds"`
---
### Requirement: SchemaValidator validates objects against a JSON-schema-like definition
The package SHALL expose `SchemaValidator` whose `validate(data, options?)` returns a `ValidationResult` covering nested objects, arrays, formats (email/url/uuid/date/datetime/ipv4/ipv6/regex), transforms (trim/lowercase/uppercase/parseNumber/parseDate), and pluggable custom validators. The `Schema` input SHALL be validated via Zod at the boundary, raising `SCHEMA_INVALID` on malformed schemas. `AbortSignal` SHALL short-circuit recursion at every nested-call boundary.
#### Scenario: email format rejects malformed input
- **WHEN** `new SchemaValidator({ email: { type: "string", format: "email" } }).validate({ email: "not-an-email" })` is called
- **THEN** the result has `valid: false` and one error at path `"email"` with code referencing format failure
#### Scenario: transforms run before validation
- **WHEN** schema declares `{ name: { type: "string", transforms: ["trim", "lowercase"] } }` and input is `{ name: " ADA " }`
- **THEN** the result has `valid: true` and the transformed value `"ada"` is reported
**Verified by:** `src/validators/schema/schema.test.ts::"email format rejects malformed"` AND `src/validators/schema/schema.test.ts::"transforms run before validation"`
---
### Requirement: FieldValidator offers a chainable validator DSL with 12 preset chains
The package SHALL expose `FieldValidator`, `createFieldValidator`, `field()`, and 12 preset chainable validators (`emailFieldValidator`, `passwordFieldValidator`, `usernameFieldValidator`, `phoneFieldValidator`, `urlFieldValidator`, `uuidFieldValidator`, `dateFieldValidator`, `integerFieldValidator`, `ageValidator`, `currencyValidator`, `percentageValidator`, `positiveNumberValidator`). The chain SHALL be immutable per call and support sync `validateSync` (throws if async stages exist) and async `validate(value, { signal? })`.
#### Scenario: passwordFieldValidator accepts a strong password
- **WHEN** `passwordFieldValidator().validate("ABCdef123!")` is called
- **THEN** the result has `valid: true`
#### Scenario: chain rejects short username
- **WHEN** `field().minLength(3).validate("ab")` is called
- **THEN** the result has `valid: false` with one error referencing min-length
#### Scenario: validateSync throws when chain has async stage
- **WHEN** a chain contains an async stage and `validateSync(value)` is called
- **THEN** the call throws `RulesError` with `code: "RULES.VALIDATOR_INVALID"`
**Verified by:** `src/validators/field/field.test.ts::"passwordFieldValidator accepts strong"` AND `src/validators/field/field.test.ts::"chain rejects short username"` AND `src/validators/field/field.test.ts::"validateSync throws on async stage"`
---
### Requirement: CustomValidator + ValidatorRegistry expose 17 built-in validators
The package SHALL expose `CustomValidator`, `ValidatorRegistry`, `defaultValidatorRegistry`, `createValidator`, `composeValidators`, and 17 built-in validators (`emailValidator`, `urlValidator`, `uuidValidator`, `isoDateValidator`, `creditCardValidator`, `phoneValidator`, `strongPasswordValidator`, `alphanumericValidator`, `hexColorValidator`, `slugValidator`, `jsonValidator`, `enumValidator`, `lengthValidator`, `patternValidator`, `rangeValidator`, `asyncValidator`, `composeValidators`). `creditCardValidator` SHALL implement the Luhn algorithm.
#### Scenario: creditCardValidator accepts a valid Luhn-checksum number
- **WHEN** `creditCardValidator.validate("4111111111111111")` is called
- **THEN** the result has `valid: true`
#### Scenario: composeValidators short-circuits on first failure
- **WHEN** `composeValidators([failingValidator, throwingValidator]).validate(x)` is called
- **THEN** the throwing validator is not invoked and the failure carries the first validator's error
#### Scenario: defaultValidatorRegistry contains 17 built-ins
- **WHEN** `defaultValidatorRegistry.list()` is called
- **THEN** the returned names cover the 17 documented validators
**Verified by:** `src/validators/custom/custom.test.ts::"creditCardValidator accepts valid Luhn"` AND `src/validators/custom/custom.test.ts::"composeValidators short-circuits"` AND `src/validators/custom/custom.test.ts::"defaultValidatorRegistry contains 17 built-ins"`
---
### Requirement: Serializer round-trips rules through JSON with function-placeholder strategy
The package SHALL expose `serializeRule`, `serializeRuleset`, `serializeRuleToObject`, `serializeRulesetToObject`, `deserializeRule`, `deserializeRuleset`, `deserializeRuleFromObject`, `deserializeRulesetFromObject`, `cloneRule`, `hashRule`, `areRulesEqual`, `validateSerializedRule`. `SerializedRule` SHALL be Zod-validated at the boundary. Function-valued fields (`CustomRule.validate`, `ComputedRule.compute`) SHALL serialize to a documented placeholder marker and deserialize through a caller-supplied resolver; missing-resolver SHALL raise `DESERIALIZE_FAILED`.
#### Scenario: ConditionRule round-trips byte-for-byte
- **WHEN** `deserializeRule(serializeRule(rule))` is called for a `ConditionRule` fixture
- **THEN** the resulting rule deep-equals the input
#### Scenario: hashRule is order-insensitive over commutative operators
- **WHEN** `hashRule(and(A, B)) === hashRule(and(B, A))`
- **THEN** the equality holds
#### Scenario: areRulesEqual handles nested structures
- **WHEN** two structurally-identical nested AND/OR trees with reordered commutative children are compared
- **THEN** `areRulesEqual` returns `true`
**Verified by:** `src/serializer/serializer.test.ts::"ConditionRule round-trips"` AND `src/serializer/serializer.test.ts::"hashRule is commutative-order-insensitive"` AND `src/serializer/serializer.test.ts::"areRulesEqual handles nested"`
---
### Requirement: Optimizer simplifies and analyses rule trees
The package SHALL expose `optimizeRule`, `analyzeRule`, `estimateCost`, `findFields`, `hasAsyncComponents`, `countRules`. Optimization SHALL flatten nested same-operator logical rules (AND-of-AND → flat AND), dedupe structurally-equal children, simplify constant-true / constant-false branches, and sort children by estimated cost ascending for short-circuit benefit. `analyzeRule` SHALL return `RuleStats { totalRules, maxDepth, fields, hasAsync, estimatedCost }`.
#### Scenario: flatten collapses nested AND-of-AND
- **WHEN** `optimizeRule(and(and(A, B), C))` is called
- **THEN** the result is `and(A, B, C)` (depth-1 AND with three children)
#### Scenario: dedupe removes structurally-equal duplicates
- **WHEN** `optimizeRule(and(condA, condA, condB))` is called and `condA` and `condB` are distinct
- **THEN** the result is `and(condA, condB)`
#### Scenario: countRules walks the full tree
- **WHEN** `countRules(and(condA, or(condB, condC)))` is called
- **THEN** the return value is `5`
**Verified by:** `src/optimizer/optimizer.test.ts::"flatten collapses nested AND"` AND `src/optimizer/optimizer.test.ts::"dedupe removes structural duplicates"` AND `src/optimizer/optimizer.test.ts::"countRules walks full tree"`
---
### Requirement: Type guards discriminate the Rule union
The package SHALL expose `isConditionRule`, `isLogicalRule`, `isGroupRule`, `isCustomRule`, `isComputedRule`. Each guard SHALL narrow the `Rule` discriminated union by `type` and SHALL return `false` for `null` / `undefined` / non-object input rather than throwing.
#### Scenario: isConditionRule narrows the type at compile time
- **WHEN** a `Rule` is tested with `isConditionRule(rule)`
- **THEN** the resulting branch types `rule` as `ConditionRule` (asserted via `expectTypeOf`)
#### Scenario: guards return false for null
- **WHEN** any guard is invoked with `null`
- **THEN** the return value is `false` and no exception is thrown
**Verified by:** `src/types/guards.test.ts::"isConditionRule narrows"` AND `src/types/guards.spec-conformance.test.ts::"guards return false for null"`
---
### Requirement: RulesError exposes a typed code registry
The package SHALL expose `RulesError extends SuaveplanError<RulesErrorCode>` with `defineCodes("RULES", [...])` covering 24 codes: `ENGINE_DEPTH_EXCEEDED`, `ENGINE_EVALUATION_FAILED`, `ENGINE_ABORTED`, `ENGINE_TIMEOUT`, `OPERATOR_NOT_FOUND`, `OPERATOR_INVALID`, `FIELD_RESOLUTION_FAILED`, `CONTEXT_INVALID`, `ACTION_TIMEOUT`, `ACTION_ABORTED`, `ACTION_HANDLER_FAILED`, `ACTION_NOT_FOUND`, `SCHEMA_INVALID`, `VALIDATOR_INVALID`, `VALIDATOR_NOT_FOUND`, `EVENT_TIMEOUT`, `EVENT_ABORTED`, `HOOK_INVALID`, `SERIALIZE_FAILED`, `DESERIALIZE_FAILED`, `RULE_INVALID`, `RULESET_INVALID`, `OPTIMIZER_FAILED`, `PLUGIN_FAILED`. Every code SHALL be thrown by at least one production code path (verified at gate time).
#### Scenario: every code in the registry is thrown by ≥1 production path
- **WHEN** the openspec-conformance script scans `src/` for every code
- **THEN** every code has at least one originating `throw new RulesError(...)` site outside tests
#### Scenario: SuaveplanError wire format round-trips
- **WHEN** a `RulesError` is serialised via the base `toWire()` adapter and reconstructed via `fromWire(...)`
- **THEN** code, message, and cause-chain match the original
**Verified by:** `src/errors/rules-error.test.ts::"every code is thrown by a production path"` AND `src/errors/rules-error.spec-conformance.test.ts::"SuaveplanError wire format round-trips"`
---
### Requirement: All public boundaries validate input via Zod
Every public function and class constructor whose input is a structured object SHALL validate that input against a Zod schema at the boundary, raising the appropriate typed code (e.g. `RULE_INVALID`, `RULESET_INVALID`, `SCHEMA_INVALID`, `CONTEXT_INVALID`) when the schema rejects.
#### Scenario: malformed Rule input rejected at engine.evaluate
- **WHEN** `engine.evaluate({ type: "condition" /* missing field */ } as any, {})` is called
- **THEN** the result reports `RULES.RULE_INVALID`
**Verified by:** `src/engine/engine.spec-conformance.test.ts::"rejects malformed Rule with RULE_INVALID"`
---
### Requirement: OTel instruments emit from production paths
The package SHALL emit the documented OTel surface (`rules.engine.evaluate.duration_ms`, `rules.engine.evaluations_total{outcome}`, `rules.engine.depth`, `rules.validator.validate.duration_ms{kind}`, `rules.validator.validations_total{kind,outcome}`, `rules.serializer.duration_ms{op}`, `rules.action.execute.duration_ms`, `rules.action.executions_total{status}`, `rules.event.emitted_total{type}`, `rules.operator.unknown_total{name}`) from production code paths, asserted against an in-memory `MeterProvider` (no `vi.mock`).
#### Scenario: every documented instrument emits at least once across the test suite
- **WHEN** the full test suite runs against an in-memory `MeterProvider`
- **THEN** each documented instrument has at least one recorded measurement
**Verified by:** `src/engine/engine.spec-conformance.test.ts::"emits documented OTel surface"`
---
### Requirement: Package ships subpath exports per feature module
The package SHALL declare subpath exports in `package.json` for `./engine`, `./builder`, `./context`, `./actions`, `./events`, `./operators`, `./validators/schema`, `./validators/field`, `./validators/custom`, `./serializer`, `./optimizer`, `./types`, `./errors`, each pointing to the feature's `dist/<path>/index.{d.ts,js}`. The package SHALL set `"sideEffects": false`.
#### Scenario: each subpath resolves a real entrypoint
- **WHEN** `node --experimental-resolve-self -e 'import "@suaveplan/rules/engine"'` runs against the built package
- **THEN** the import succeeds
**Verified by:** `src/__exports__/exports.test.ts::"every subpath export resolves"`
<!-- markdownlint-enable MD024 -->