Initial Commit
This commit is contained in:
142
openspec/archive/2026-04-19-enterprise-error/specs/error/spec.md
Normal file
142
openspec/archive/2026-04-19-enterprise-error/specs/error/spec.md
Normal file
@@ -0,0 +1,142 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Zero workspace and runtime dependencies
|
||||
|
||||
The `@suaveplan/error` package `dependencies`, `peerDependencies`, and `devDependencies` MUST NOT list any `@suaveplan/*` scoped package. Runtime dependencies MUST be limited to `tslib` and `zod`; all other external packages live in `devDependencies`.
|
||||
|
||||
#### Scenario: Dep-graph guard
|
||||
|
||||
- **WHEN** `bunx turbo ls --filter=@suaveplan/error...` is run
|
||||
- **THEN** no `@suaveplan/*` package appears in the forward closure of `@suaveplan/error`
|
||||
|
||||
#### Scenario: No Node / browser built-ins
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd
|
||||
- **THEN** it MUST NOT reference `node:fs`, `node:path`, `node:url`, `process`, `Buffer`, `document`, `window`, or any DOM / Node-only API
|
||||
|
||||
### Requirement: SuaveplanError base class
|
||||
|
||||
The package SHALL export an abstract `SuaveplanError` class extending the built-in `Error`, carrying `{ code, message, cause?, context?, timestamp, traceId? }`. `code` SHALL be a string of the form `PACKAGE.DOMAIN.REASON` (plain uppercase ASCII, dots as separators). The `code` field is generic so subclasses can narrow the code union.
|
||||
|
||||
#### Scenario: Subclass narrows code union
|
||||
|
||||
- **WHEN** a consumer defines `class MyError extends SuaveplanError<"MY.X.Y" | "MY.X.Z">`
|
||||
- **THEN** TypeScript infers `err.code` as `"MY.X.Y" | "MY.X.Z"` (not `string`) inside `instanceof MyError` branches
|
||||
|
||||
#### Scenario: Stack trace preserved
|
||||
|
||||
- **WHEN** a `SuaveplanError` subclass is thrown
|
||||
- **THEN** `err.stack` contains the subclass name (not `Error`) AND the originating call site
|
||||
|
||||
#### Scenario: Timestamp is injectable
|
||||
|
||||
- **WHEN** a subclass is constructed with `{ clock: () => 1234 }`
|
||||
- **THEN** `err.timestamp === 1234`
|
||||
|
||||
#### Scenario: Default timestamp uses Date.now
|
||||
|
||||
- **WHEN** a subclass is constructed with no `clock` option
|
||||
- **THEN** `err.timestamp` equals the `Date.now()` reading at construction time
|
||||
|
||||
### Requirement: Stable error-code registry helper
|
||||
|
||||
The package SHALL export a `defineCodes<TNamespace, TReasons>()` helper that returns a frozen `Record<TReasons, \`${TNamespace}.${TReasons}\`>` with full literal-type inference. Rebuilding the registry MUST preserve existing codes; removal requires a `@deprecated` marker that emits a TypeScript deprecation warning at call sites.
|
||||
|
||||
#### Scenario: Registry is frozen
|
||||
|
||||
- **WHEN** a consumer attempts `codes.REASON = "X.Y.Z"`
|
||||
- **THEN** the assignment throws in strict mode (object is frozen)
|
||||
|
||||
#### Scenario: Code values are literal-typed
|
||||
|
||||
- **WHEN** a consumer writes `const codes = defineCodes("PKG", ["A", "B"] as const)`
|
||||
- **THEN** `codes.A` has type `"PKG.A"` (not `string`)
|
||||
|
||||
### Requirement: Cause-chain traversal is cycle-safe and depth-bounded
|
||||
|
||||
The package SHALL export `walkCauses(err, { maxDepth?: number })` as a generator yielding each error in the chain. Default `maxDepth` is 32. Cycles MUST be detected via a `WeakSet` and terminate iteration without throwing.
|
||||
|
||||
#### Scenario: Linear chain fully traversed
|
||||
|
||||
- **WHEN** `a.cause = b; b.cause = c; c.cause = d`
|
||||
- **THEN** `Array.from(walkCauses(a))` returns `[a, b, c, d]`
|
||||
|
||||
#### Scenario: Cycle terminates iteration
|
||||
|
||||
- **WHEN** `a.cause = b; b.cause = a` and `walkCauses(a)` is consumed
|
||||
- **THEN** iteration yields `a` then `b` and ends (does not throw, does not loop forever)
|
||||
|
||||
#### Scenario: Depth cap enforced
|
||||
|
||||
- **WHEN** a chain of 100 errors is walked with `{ maxDepth: 10 }`
|
||||
- **THEN** exactly 10 errors are yielded
|
||||
|
||||
### Requirement: Lossless wire serialization
|
||||
|
||||
The package SHALL export `toJSON(err)` and `fromJSON(value)` producing a Zod-validated wire payload. Round-tripping a `SuaveplanError` subclass through JSON MUST preserve `code`, `message`, `context` (redactions applied), `timestamp`, `traceId`, and the cause chain (up to `maxDepth`). The binary form (`toBinary` / `fromBinary`) is a deterministic UTF-8 encoding of the canonicalised JSON form (no external encoder dep); both halves of a round-trip produce structurally identical output.
|
||||
|
||||
#### Scenario: JSON round-trip preserves all fields
|
||||
|
||||
- **WHEN** an error with `{ code, message, context, cause }` is serialized via `toJSON` and parsed back via `fromJSON`
|
||||
- **THEN** the reconstructed error is `instanceof SuaveplanError` AND every field is structurally equal to the original (redacted fields excepted)
|
||||
|
||||
#### Scenario: Binary round-trip matches JSON round-trip
|
||||
|
||||
- **WHEN** the same error is passed through `toBinary`/`fromBinary`
|
||||
- **THEN** the resulting error is structurally equal to the JSON round-trip output
|
||||
|
||||
#### Scenario: Zod rejects malformed wire payload
|
||||
|
||||
- **WHEN** `fromJSON({ code: 123 })` is called (wrong type)
|
||||
- **THEN** it throws a `SuaveplanError` subclass with `code === "ERROR.WIRE.MALFORMED"` AND `cause instanceof ZodError`
|
||||
|
||||
### Requirement: Redaction at the serialization boundary
|
||||
|
||||
The package SHALL export `markSensitive(value)` producing a tagged wrapper. During `toJSON` / `toBinary`, tagged values MUST be replaced with the literal string `"[REDACTED]"`. In-memory access to the original value via `err.context` is UNAFFECTED.
|
||||
|
||||
#### Scenario: Sensitive field redacted on the wire
|
||||
|
||||
- **WHEN** `err.context.token = markSensitive("abc")` and `toJSON(err)` runs
|
||||
- **THEN** the serialized payload contains `context.token === "[REDACTED]"` (string)
|
||||
|
||||
#### Scenario: In-memory value remains accessible
|
||||
|
||||
- **WHEN** `err.context.token = markSensitive("abc")` (unserialized)
|
||||
- **THEN** `unwrapSensitive(err.context.token) === "abc"`
|
||||
|
||||
### Requirement: OTel span event adapter
|
||||
|
||||
The package SHALL export `toSpanEvent(err)` returning `{ name: "exception", attributes: { "exception.type", "exception.message", "exception.stacktrace", "exception.code", "exception.traceId"? } }` with all values already string-serialized. No OpenTelemetry SDK is imported.
|
||||
|
||||
#### Scenario: Span event matches OTel spec
|
||||
|
||||
- **WHEN** `toSpanEvent(err)` is called
|
||||
- **THEN** the returned `name === "exception"` AND `attributes["exception.type"] === err.constructor.name` AND `attributes["exception.message"] === err.message` AND `attributes["exception.stacktrace"] === err.stack ?? ""` AND `attributes["exception.code"] === err.code`
|
||||
|
||||
### Requirement: i18n-ready messages
|
||||
|
||||
No user-visible string literal SHALL be embedded in a `throw` site. Subclasses MUST instead reference an i18n key (of the form `error.<package>.<domain>.<reason>`) which the default implementation renders by returning the key itself. Consumers can plug in a translator via `setMessageResolver(fn)`.
|
||||
|
||||
#### Scenario: Default resolver returns the key
|
||||
|
||||
- **WHEN** no resolver has been set and a subclass formats `"error.pkg.domain.reason"`
|
||||
- **THEN** the rendered message equals `"error.pkg.domain.reason"` verbatim
|
||||
|
||||
#### Scenario: Custom resolver applied
|
||||
|
||||
- **WHEN** `setMessageResolver((key, params) => translations[key](params))` has been set
|
||||
- **THEN** subclass construction calls the resolver and stores the returned string on `err.message`
|
||||
|
||||
### Requirement: Test coverage at 100%
|
||||
|
||||
The package MUST maintain `statements: 100`, `branches: 100`, `functions: 100`, `lines: 100` thresholds in `vitest.config.ts`. No `istanbul ignore` pragmas are permitted (they do not survive the esbuild transform). Defensive code whose false branch is unreachable MUST be rewritten as an `as` assertion with an invariant comment rather than a runtime `if`.
|
||||
|
||||
#### Scenario: Coverage thresholds enforced
|
||||
|
||||
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/error` runs
|
||||
- **THEN** it exits non-zero if any of statements / branches / functions / lines drops below 100
|
||||
|
||||
#### Scenario: Property-based round-trip
|
||||
|
||||
- **WHEN** fast-check generates arbitrary `SuaveplanError` subclass instances
|
||||
- **THEN** every instance survives `toJSON` → `fromJSON` structurally equal (modulo redactions) AND every instance survives `toBinary` → `fromBinary` structurally equal
|
||||
Reference in New Issue
Block a user