385 lines
23 KiB
Markdown
385 lines
23 KiB
Markdown
<!-- 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 -->
|