7.6 KiB
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.codeas"MY.X.Y" | "MY.X.Z"(notstring) insideinstanceof MyErrorbranches
Scenario: Stack trace preserved
- WHEN a
SuaveplanErrorsubclass is thrown - THEN
err.stackcontains the subclass name (notError) 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
clockoption - THEN
err.timestampequals theDate.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.Ahas type"PKG.A"(notstring)
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 = aandwalkCauses(a)is consumed - THEN iteration yields
athenband 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 viatoJSONand parsed back viafromJSON - THEN the reconstructed error is
instanceof SuaveplanErrorAND 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
SuaveplanErrorsubclass withcode === "ERROR.WIRE.MALFORMED"ANDcause 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")andtoJSON(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"ANDattributes["exception.type"] === err.constructor.nameANDattributes["exception.message"] === err.messageANDattributes["exception.stacktrace"] === err.stack ?? ""ANDattributes["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/errorruns - 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
SuaveplanErrorsubclass instances - THEN every instance survives
toJSON→fromJSONstructurally equal (modulo redactions) AND every instance survivestoBinary→fromBinarystructurally equal