From 188f078056dec12173e7c2cd139aa4396cc81b6c Mon Sep 17 00:00:00 2001 From: Jonathan Glanz Date: Tue, 21 Jul 2026 11:40:57 -0400 Subject: [PATCH] fix(eslint): identity + no-swallow error law; enhance shared NestedError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the identity + error-preservation eslint law from wire-tools-ts (the two repos share the ban set) and enhances the shared error primitive it depends on: - @wireio/shared NestedError: takes a new message AND preserves the full cause chain — the real Error objects survive in `.causes` and native `Error.cause` (each cause keeps its own stack trace + message), a nested-exception `Caused by:` section per cause is appended to `.stack`, and structured `context` is folded into the message. + unit tests. - eslint.config.mjs: `preserve-caught-error` ON (was off); `BanErrorMessageRestring` + `BanBareErrorInHandler` (a bare Error in an error handler that RECEIVES the error — Either.mapLeft/ifLeft/recoverWith, Promise.catch, Future.onFailure — NOT Option.orElse/ifNone); a `wire-local/no-identity-arrow` custom rule. - Sweep: `x => x` identity arrows -> lodash `identity` (serializer builtins via `identity` to keep the exact signature); caught-error rewrites -> `NestedError({ cause })` in `api/Client` and `serializer/Decoder`; plus the pre-existing `BanNullUnionReturn` / `BanInlineTypeLiteral` violations the now-enforced lint surfaces in the new `sysio.authex` / `sysio.reserv` code (`ci.yaml` runs test:ci, not lint) — dropped the unenforced `| null` return unions (strictNullChecks is off) and named the inline object types. - CLAUDE.md: correct the stale "No ESLint configured" note (eslint IS configured, copied from wire-tools-ts). Co-Authored-By: Claude Opus 4.8 (1M context) Change-Id: I50bf25b03fda4ca66f7b3a8ffc9d11f4716f08ba --- CLAUDE.md | 5 +- eslint.config.mjs | 74 ++++++- packages/sdk-core/src/api/Client.ts | 11 +- .../src/contracts/sysio/authex/Client.ts | 4 +- .../src/contracts/sysio/authex/Signing.ts | 12 +- .../src/contracts/sysio/reserv/Actions.ts | 7 +- .../src/contracts/sysio/reserv/Client.ts | 2 +- packages/sdk-core/src/serializer/Builtins.ts | 6 +- packages/sdk-core/src/serializer/Decoder.ts | 9 +- packages/shared/src/errors/NestedError.ts | 198 ++++++++++++++++-- .../shared/tests/errors/NestedError.test.ts | 126 +++++++---- 11 files changed, 379 insertions(+), 75 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a08abad..4da2648 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ pnpm build # Build all packages via tsc -b pnpm build:dev # Watch mode (incremental) pnpm test # Build + jest (all packages) pnpm format # Prettier on **/*.{ts,tsx,md} +pnpm lint # ESLint (eslint.config.mjs) — no-restricted-syntax bans + typescript-eslint ``` Per-package commands (run from package directory): @@ -92,7 +93,7 @@ Assets (Rust runtime, Solidity templates, Handlebars templates) are embedded via ## Code Style & Approach -Enforced by Prettier (`.prettierrc.js`): +Enforced by Prettier (`.prettierrc.js`) + ESLint (`eslint.config.mjs`): - **modern code** Use forEach, ... (spreads), map, filter, and reduce modern paradigms instead of for loops and other legacy style code - **OPP & FP (functional programming)** is preferred over old-school if/else/switch and generally branching code. - Use `Future` from `@3fv/prelude-ts` for async flows. @@ -104,7 +105,7 @@ Enforced by Prettier (`.prettierrc.js`): - No trailing commas - 2-space indent, no tabs - Arrow parens: avoid when possible -- No ESLint configured +- **ESLint is configured** (`eslint.config.mjs`, run via `pnpm lint` = `eslint .`) — a flat config **copied from `wire-tools-ts`** that encodes the same `no-restricted-syntax` bans (`BanSwitch`, `BanInlineIife`, `BanNullUnionReturn`, `BanInlineTypeLiteral`, `BanPickParameter`, `BanStringLiteralUnion`, `BanAsOptionAwait`, `BanMemberCoalesceDeclarator`) on top of `typescript-eslint`'s recommended set. The per-selector grandfather debt-file lists are **ratchets**: touching a listed file pays down its debt and deletes its entry — never add one. ## Code Quality Invariants diff --git a/eslint.config.mjs b/eslint.config.mjs index 657ff75..7b6c69f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,6 +21,41 @@ import eslint from "@eslint/js" import tseslint from "typescript-eslint" import prettier from "eslint-config-prettier" +// prefer-lodash-identity.md (author law, 2026-07-21): an identity arrow `x => x` +// IS lodash `identity` — import { identity } from "lodash". A custom rule because +// no-restricted-syntax (esquery) cannot assert body-name === param-name. +const wireLocalPlugin = { + meta: { name: "wire-local" }, + rules: { + "no-identity-arrow": { + meta: { + type: "problem", + docs: { description: "Use lodash `identity` instead of an `x => x` arrow." }, + messages: { + useLodashIdentity: + "Use `identity` from lodash instead of an `x => x` arrow (prefer-lodash-identity.md)." + }, + schema: [] + }, + create(context) { + return { + ArrowFunctionExpression(node) { + const [param] = node.params + if ( + node.params.length === 1 && + param.type === "Identifier" && + node.body.type === "Identifier" && + node.body.name === param.name + ) { + context.report({ node, messageId: "useLodashIdentity" }) + } + } + } + } + } + } +} + // STYLE.md "match() over switch always". const BanSwitch = { selector: "SwitchStatement", @@ -98,6 +133,29 @@ const BanMemberCoalesceDeclarator = { "Destructure with a default — `const { member: local = Default } = obj` — not `const local = obj.member ?? Default` (STYLE.md 'Destructuring over member-coalesce')." } +// nested-error-preserve-cause.md (author law, 2026-07-21): NEVER rewrite a caught +// error into a bare Error that restrings it — the root cause + its stack + message +// are SWALLOWED. Wrap it: NestedError(message, { cause, context }). (a) the restring +// tell — a caught error's `.message`/`.stack` (or `toMessage(err)`) interpolated +// into a new Error. +const BanErrorMessageRestring = { + selector: + "NewExpression[callee.name=/^(Error|TypeError|RangeError|EvalError|SyntaxError|URIError)$/] :matches(CallExpression[callee.name='toMessage'], MemberExpression[property.name=/^(message|stack)$/][object.name=/^(err|error|e|ex|cause|reason)$/])", + message: + "Don't restring a caught error into a bare Error — use NestedError(message, { cause, context }) so the root cause + its stack survive (nested-error-preserve-cause.md)." +} +// (b) a bare (no-cause) native Error constructed inside an error handler that +// RECEIVES the error (Either.mapLeft/ifLeft/recoverWith, Promise.catch, +// Future.onFailure) — it discards the very error being handled. Throw a +// NestedError with { cause }. NOT Option's orElse/ifNone — those handle ABSENCE +// (a None is not an error), so a fresh Error there has no cause to preserve. +const BanBareErrorInHandler = { + selector: + "CallExpression[callee.property.name=/^(mapLeft|ifLeft|catch|recoverWith|onFailure)$/] NewExpression[callee.name=/^(Error|TypeError|RangeError|EvalError|SyntaxError|URIError)$/][arguments.length<2]", + message: + "A bare Error in an error handler (mapLeft/ifLeft/catch/recoverWith/onFailure) SWALLOWS the handled error — throw a NestedError with { cause } (nested-error-preserve-cause.md)." +} + // Pre-existing debt, grandfathered per file per ban — RATCHETS: touch a listed // file → pay its debt → DELETE its entry. Never add an entry. Computed from // the 2026-07-18 baseline lint run. @@ -325,7 +383,9 @@ const AllBans = [ BanPickParameter, BanStringLiteralUnion, BanAsOptionAwait, - BanMemberCoalesceDeclarator + BanMemberCoalesceDeclarator, + BanErrorMessageRestring, + BanBareErrorInHandler ] const DebtListsBySelector = [ [BanSwitch.selector, SwitchDebtFiles], @@ -384,11 +444,15 @@ export default tseslint.config( ...tseslint.configs.recommended, { files: ["**/*.ts", "**/*.tsx"], + plugins: { "wire-local": wireLocalPlugin }, rules: { // use-logging-framework.md: console is banned; the framework writes // through (jest buffers console.*). Carve-outs below. "no-console": "error", + // prefer-lodash-identity.md: an `x => x` arrow IS lodash `identity`. + "wire-local/no-identity-arrow": "error", + "no-restricted-syntax": ["error", ...AllBans], // standard-names-not-invented.md: get-or-throw helpers are assert*, @@ -432,9 +496,11 @@ export default tseslint.config( // semantic structure — `interface FooConfig extends Required // {}` (STYLE.md three-layer options) and `{}` generic defaults. "@typescript-eslint/no-empty-object-type": "off", - // `{ cause }` chaining is NOT a codified house law and enforcing it - // would force edits to untouched files. Off deliberately, not forgotten. - "preserve-caught-error": "off", + // nested-error-preserve-cause.md (author law, 2026-07-21): a catch clause + // that rethrows a bare Error SWALLOWS the original (its message + stack). + // Preserve it as `{ cause }` — pair with NestedError for the + // { cause, context } form. + "preserve-caught-error": "error", "prefer-const": "error", "no-var": "error" } diff --git a/packages/sdk-core/src/api/Client.ts b/packages/sdk-core/src/api/Client.ts index fb33669..ba00c32 100644 --- a/packages/sdk-core/src/api/Client.ts +++ b/packages/sdk-core/src/api/Client.ts @@ -1,3 +1,5 @@ +import { NestedError } from "@wireio/shared" + import { APIProvider, FetchProvider, FetchProviderOptions } from "./Provider.js" import { ABISerializableConstructor, @@ -258,7 +260,7 @@ export class APIClient { const msg = details .map((d: any) => d.message.replace(/Error:/g, "").trim()) .join(", ") - throw new Error(msg) + throw new NestedError(msg, { cause: e }) } else throw e } } @@ -294,10 +296,9 @@ export class APIClient { .trim() ) .join(", ") - throw new Error(msg) + throw new NestedError(msg, { cause: e }) } else { - const msg = e.message.replace(/Error:/g, "") || e - throw new Error(msg) + throw new NestedError("pushTransaction failed", { cause: e }) } } } @@ -325,7 +326,7 @@ export class APIClient { const { msgBytes } = transaction.signingDigest(info.chain_id, keyType) const sigBytes = await this.signer.sign(msgBytes).catch(err => { - throw new Error(err) + throw new NestedError("transaction signing failed", { cause: err }) }) const signature = Signature.fromRaw(sigBytes, keyType) return SignedTransaction.from({ ...transaction, signatures: [signature] }) diff --git a/packages/sdk-core/src/contracts/sysio/authex/Client.ts b/packages/sdk-core/src/contracts/sysio/authex/Client.ts index 7a50139..8cab575 100644 --- a/packages/sdk-core/src/contracts/sysio/authex/Client.ts +++ b/packages/sdk-core/src/contracts/sysio/authex/Client.ts @@ -210,7 +210,7 @@ export class AuthexClient { async getLink( account: NameType, chainKind: SysioContracts.SysioAuthexChainkind - ): Promise { + ): Promise { const links = await this.getLinks(account) return ( links.find(row => authExChainKindValue(row.chain_kind) === chainKind) || @@ -221,7 +221,7 @@ export class AuthexClient { /** Reads the first link matching an external public key using the `bypubkey` index. */ async getLinkByPublicKey( publicKey: PublicKeyType - ): Promise { + ): Promise { const key = PublicKey.from(publicKey), hash = publicKeyHash(key), result = await this.contractClient.tables.links.rows({ diff --git a/packages/sdk-core/src/contracts/sysio/authex/Signing.ts b/packages/sdk-core/src/contracts/sysio/authex/Signing.ts index efced3e..00f3b5f 100644 --- a/packages/sdk-core/src/contracts/sysio/authex/Signing.ts +++ b/packages/sdk-core/src/contracts/sysio/authex/Signing.ts @@ -157,12 +157,16 @@ export function createLinkSignatureFromRaw( return new Signature(KeyType.ED, Bytes.from(signature96)) } +/** Options for {@link signCreateLink} — the create-link inputs plus the external signer. */ +export interface SignCreateLinkOptions + extends Omit { + signer: SignerProvider + publicKey?: PublicKeyType +} + /** Signs the create-link proof with the supplied external-wallet signer. */ export async function signCreateLink( - options: Omit & { - signer: SignerProvider - publicKey?: PublicKeyType - } + options: SignCreateLinkOptions ): Promise { const prepared = prepareCreateLink({ account: options.account, diff --git a/packages/sdk-core/src/contracts/sysio/reserv/Actions.ts b/packages/sdk-core/src/contracts/sysio/reserv/Actions.ts index 8539ee8..bb2ed78 100644 --- a/packages/sdk-core/src/contracts/sysio/reserv/Actions.ts +++ b/packages/sdk-core/src/contracts/sysio/reserv/Actions.ts @@ -9,8 +9,13 @@ import { descriptor as reservDescriptor } from "./Descriptor.js" import { reserveSlugData } from "./Slug.js" import type { MatchReserveOptions, ReserveQuoteOptions } from "./Types.js" +/** Any value with a string form — an amount as a number, string, bigint, or a BN/Decimal-like object. */ +interface Stringifiable { + toString(): string +} + function amountString( - value: number | string | bigint | { toString(): string } + value: number | string | bigint | Stringifiable ): string { return value.toString() } diff --git a/packages/sdk-core/src/contracts/sysio/reserv/Client.ts b/packages/sdk-core/src/contracts/sysio/reserv/Client.ts index 1f7294e..3decd53 100644 --- a/packages/sdk-core/src/contracts/sysio/reserv/Client.ts +++ b/packages/sdk-core/src/contracts/sysio/reserv/Client.ts @@ -236,7 +236,7 @@ export class ReserveClient { } /** Reads one reserve by its three-part identity, or null when absent. */ - async getReserve(identity: ReserveIdentity): Promise { + async getReserve(identity: ReserveIdentity): Promise { const reserveCode = reserveSlugValue(identity.reserveCode), rows = await this.listReserveRows({ chainCode: identity.chainCode, diff --git a/packages/sdk-core/src/serializer/Builtins.ts b/packages/sdk-core/src/serializer/Builtins.ts index 7a86c37..31a6d9a 100644 --- a/packages/sdk-core/src/serializer/Builtins.ts +++ b/packages/sdk-core/src/serializer/Builtins.ts @@ -1,3 +1,5 @@ +import { identity } from "lodash" + import { ABIDecoder } from "./Decoder.js" import { ABIEncoder } from "./Encoder.js" import { ABIField, ABISerializableConstructor } from "./Serializable.js" @@ -35,7 +37,7 @@ const StringType = { fromABI: (decoder: ABIDecoder) => { return decoder.readString() }, - from: (string: string): string => string, + from: identity, toABI: (string: string, encoder: ABIEncoder) => { encoder.writeString(string) } @@ -47,7 +49,7 @@ const BoolType = { fromABI: (decoder: ABIDecoder) => { return decoder.readByte() !== BOOL_FALSE_BYTE }, - from: (value: boolean): boolean => value, + from: identity, toABI: (value: boolean, encoder: ABIEncoder) => { encoder.writeByte(value === true ? BOOL_TRUE_BYTE : BOOL_FALSE_BYTE) } diff --git a/packages/sdk-core/src/serializer/Decoder.ts b/packages/sdk-core/src/serializer/Decoder.ts index 9fc2b2c..1ad44a2 100644 --- a/packages/sdk-core/src/serializer/Decoder.ts +++ b/packages/sdk-core/src/serializer/Decoder.ts @@ -2,6 +2,8 @@ * Antelope/SYSIO ABI Decoder */ +import { NestedError } from "@wireio/shared" + import { ABI, ABIDef } from "../chain/Abi.js" import { Bytes, BytesType } from "../chain/Bytes.js" import { Variant } from "../chain/Variant.js" @@ -128,9 +130,10 @@ export function abiDecode( abi = synthesized.abi customTypes.push(...synthesized.types) } catch (error: any) { - throw Error( - `Unable to synthesize ABI for: ${typeName} (${error.message}). ` + - "To decode non-class types you need to pass the ABI definition manually." + throw new NestedError( + `Unable to synthesize ABI for: ${typeName}. ` + + "To decode non-class types you need to pass the ABI definition manually.", + { cause: error } ) } } diff --git a/packages/shared/src/errors/NestedError.ts b/packages/shared/src/errors/NestedError.ts index 8bfd6b9..499455e 100644 --- a/packages/shared/src/errors/NestedError.ts +++ b/packages/shared/src/errors/NestedError.ts @@ -1,27 +1,197 @@ -export class NestedError extends Error { - readonly causes: Array +/** + * Structured, machine-readable diagnostic context captured at a {@link NestedError} + * throw site — the values that make a failure diagnosable (the `text` that failed to + * parse, a schema / type name, an id, …). Heterogeneous by nature, so the values are + * `unknown` (a documented type-erased bag, not a shortcut). + */ +export type NestedErrorContext = Record - constructor(message: string, ...causes: Array) { - super(message) +/** + * Options for {@link NestedError} — what it preserves about the failure it wraps. + */ +export interface NestedErrorOptions< + Context extends NestedErrorContext = NestedErrorContext +> { + /** + * The originating error(s) — the root-cause chain that must NOT be swallowed. A + * non-Error thrown value is coerced to an Error; the first becomes the native + * ES2022 `Error.cause`. + */ + cause?: unknown + /** Diagnostic context captured at the throw site. */ + context?: Context +} + +/** + * An Error that PRESERVES what it wraps instead of restringing it. Given a new + * message string, it ALSO retains the full cause chain: the originating error(s) + * survive as {@link NestedError.causes} (and the native `Error.cause`) — the real + * Error objects, so **each cause keeps its own stack trace and message** — and a + * nested-exception `Caused by:` section per cause is appended to this error's + * {@link NestedError.stack}, so the whole chain (each cause's stack + message) is + * visible wherever the error is printed, not only via the programmatic accessors. + * The diagnostic {@link NestedError.context} (the `text`, the schema / type name, …) + * is BOTH a structured property AND folded into the message. + * + * Never rewrite a caught error into a bare ``new Error(`… ${err.message}`)`` — that + * SWALLOWS the cause and the context. Wrap it: + * `new NestedError("…", { cause: err, context: { … } })`. + */ +export class NestedError< + Context extends NestedErrorContext = NestedErrorContext +> extends Error { + /** The originating error chain (flattened + coerced to Error); `causes[0]` is the native `.cause`. */ + readonly causes: Error[] + + /** The diagnostic context captured at the throw site. */ + readonly context: Context + + /** + * @param message - The high-level failure description. + * @param options - The {@link NestedErrorOptions.cause} to preserve + the {@link NestedErrorOptions.context} to capture. + */ + constructor(message: string, options: NestedErrorOptions = {}) { + const causes = NestedError.toErrors(options.cause) + super( + NestedError.composeMessage(message, options.context), + causes.length > 0 ? { cause: causes[0] } : undefined + ) this.name = "NestedError" - this.causes = causes.flat() + this.causes = causes + this.context = options.context ?? ({} as Context) + // Retain each cause's stack + message VISIBLY: append a `Caused by:` section + // per cause so the full chain shows in `.stack` (and any logger that prints it), + // not only via the programmatic `.causes` / native `.cause`. + this.stack = NestedError.composeStack(this.stack, causes) } } export namespace NestedError { - export function create( + /** Max characters of any single context value folded into the message — long `text` is truncated. */ + export const ContextPreviewMaxChars = 200 + + /** The marker appended when a folded context value is truncated. */ + export const TruncationSuffix = "…" + + /** Prefix marking each retained cause in the composed stack (nested-exception style). */ + export const CausedByPrefix = "Caused by: " + + /** + * Append a `Caused by:` section per cause to `stack`, so every cause's stack trace + * AND message stay visible in `.stack` (and any logger that prints it) — not only + * via the programmatic {@link NestedError.causes} / native `Error.cause`. Each + * cause's own `.stack` already begins with its `name: message`, so both are kept. + * + * @param stack - The wrapping error's own stack (may be undefined in exotic runtimes). + * @param causes - The retained cause chain. + * @returns The wrapping stack with one appended `Caused by:` section per cause. + */ + export function composeStack(stack: string | undefined, causes: Error[]): string { + return causes.reduce( + (composed, cause) => + `${composed}\n${CausedByPrefix}${cause.stack ?? `${cause.name}: ${cause.message}`}`, + stack ?? "" + ) + } + + /** + * Create a {@link NestedError}. + * + * @param message - The high-level failure description. + * @param options - The cause to preserve + the context to capture. + * @returns The constructed error. + */ + export function create( message: string, - ...causes: Array - ): NestedError { - return new NestedError(message, ...causes) + options: NestedErrorOptions = {} + ): NestedError { + return new NestedError(message, options) } - export function throwError( + /** + * Create + throw a {@link NestedError}. + * + * @param message - The high-level failure description. + * @param options - The cause to preserve + the context to capture. + */ + export function throwError< + Context extends NestedErrorContext = NestedErrorContext + >(message: string, options: NestedErrorOptions = {}): never { + throw create(message, options) + } + + /** + * Coerce a thrown value (an Error, an array of them, or any value) into a flat + * `Error[]`, so a caught `unknown` can be preserved as a cause without loss. + * + * @param cause - The caught / rejected value. + * @returns The coerced error chain (empty when `cause` is nullish). + */ + export function toErrors(cause: unknown): Error[] { + return (Array.isArray(cause) ? cause : cause == null ? [] : [cause]).map( + toError + ) + } + + /** + * Coerce a single thrown value into an Error (Errors pass through unchanged). + * + * @param value - The caught value. + * @returns `value` when it is an Error, else a new Error wrapping its string form. + */ + export function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)) + } + + /** + * Fold a compact, truncated {@link NestedErrorContext} summary into the message so + * it is visible wherever the message is (logs, stack traces), not only on the + * `.context` property. + * + * @param message - The base message. + * @param context - The context to summarize, if any. + * @returns `message` with a ` (key=value, …)` suffix when context is non-empty. + */ + export function composeMessage( message: string, - ...causes: Array - ): never { - throw create(message, ...causes) + context?: NestedErrorContext + ): string { + const entries = context == null ? [] : Object.entries(context) + return entries.length === 0 + ? message + : `${message} (${entries + .map(([key, value]) => `${key}=${previewValue(value)}`) + .join(", ")})` + } + + /** + * Render one context value for the message suffix — strings verbatim, everything + * else via a bigint-/cycle-safe form, truncated to {@link ContextPreviewMaxChars}. + * + * @param value - The context value. + * @returns The truncated preview string. + */ + export function previewValue(value: unknown): string { + const rendered = typeof value === "string" ? value : safeStringify(value) + return rendered.length > ContextPreviewMaxChars + ? `${rendered.slice(0, ContextPreviewMaxChars)}${TruncationSuffix}` + : rendered } } -export default NestedError +/** + * A best-effort rendering of `value` for a message preview: JSON when serializable, + * else `String(value)`. Pure display fallback — a BigInt or a circular object makes + * `JSON.stringify` throw — NOT error handling, so the catch is a deliberate render + * fallback rather than a swallow. + * + * @param value - The value to render. + * @returns A single-line string form of `value`. + */ +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value) + } catch { + return String(value) + } +} diff --git a/packages/shared/tests/errors/NestedError.test.ts b/packages/shared/tests/errors/NestedError.test.ts index 656c6d4..0e8c1ee 100644 --- a/packages/shared/tests/errors/NestedError.test.ts +++ b/packages/shared/tests/errors/NestedError.test.ts @@ -1,62 +1,114 @@ import { NestedError } from "@wireio/shared/errors/NestedError" describe("NestedError", () => { - it("sets message, name, and causes", () => { - const cause = new Error("root cause") - const error = new NestedError("wrapper", cause) + it("keeps the new message and preserves the caught error as cause", () => { + const cause = new Error("root boom") + const error = new NestedError("wrapper failed", { cause }) - expect(error.message).toBe("wrapper") + expect(error).toBeInstanceOf(Error) expect(error.name).toBe("NestedError") + expect(error.message).toBe("wrapper failed") + // The real Error object is retained — native `.cause` AND `.causes` — so its + // stack trace and message survive intact. + expect(error.cause).toBe(cause) expect(error.causes).toEqual([cause]) + expect(error.causes[0].message).toBe("root boom") + expect(error.causes[0].stack).toBe(cause.stack) }) - it("extends Error", () => { - const error = new NestedError("wrapper") - expect(error).toBeInstanceOf(Error) - expect(error).toBeInstanceOf(NestedError) + it("makes each cause's stack + message visible in .stack (nested-exception style)", () => { + const inner = new Error("inner detail") + const error = new NestedError("outer failed", { cause: inner }) + + expect(error.stack).toContain("outer failed") + expect(error.stack).toContain(NestedError.CausedByPrefix) + // The cause's stack begins with `Error: inner detail`, so its message is kept too. + expect(error.stack).toContain("inner detail") + expect(error.stack).toContain(inner.stack) }) - it("flattens nested cause arrays", () => { + it("retains every cause when given an array of causes", () => { const first = new Error("first") const second = new Error("second") - const third = new Error("third") - const error = new NestedError("wrapper", [first, second], third) + const error = new NestedError("multi", { cause: [first, second] }) - expect(error.causes).toEqual([first, second, third]) + expect(error.causes).toEqual([first, second]) + // The first is the native `.cause`; both show in the composed stack. + expect(error.cause).toBe(first) + expect(error.stack).toContain("first") + expect(error.stack).toContain("second") }) - it("accepts no causes", () => { - const error = new NestedError("wrapper") + it("coerces a non-Error thrown value into a retained Error cause", () => { + const error = new NestedError("boom", { cause: "a plain string throw" }) - expect(error.causes).toEqual([]) + expect(error.causes).toHaveLength(1) + expect(error.causes[0]).toBeInstanceOf(Error) + expect(error.causes[0].message).toBe("a plain string throw") + expect(error.cause).toBe(error.causes[0]) }) - describe("create", () => { - it("returns a NestedError with message and causes", () => { - const cause = new Error("root cause") - const error = NestedError.create("wrapper", cause) - - expect(error).toBeInstanceOf(NestedError) - expect(error.message).toBe("wrapper") - expect(error.causes).toEqual([cause]) + it("captures structured context AND folds a summary into the message", () => { + const error = new NestedError("invalid JSON", { + context: { text: "{bad", schemaName: "ClusterConfig" } }) + + expect(error.context).toEqual({ text: "{bad", schemaName: "ClusterConfig" }) + expect(error.message).toContain("text={bad") + expect(error.message).toContain("schemaName=ClusterConfig") }) - describe("throwError", () => { - it("throws a NestedError with message and causes", () => { - const cause = new Error("root cause") + it("truncates a long folded context value", () => { + const long = "x".repeat(NestedError.ContextPreviewMaxChars + 50) + const error = new NestedError("big", { context: { text: long } }) - expect(() => NestedError.throwError("wrapper", cause)).toThrow( - NestedError - ) - expect(() => NestedError.throwError("wrapper", cause)).toThrow("wrapper") + expect(error.message).toContain(NestedError.TruncationSuffix) + // The folded preview is bounded regardless of the source length. + expect(error.message.length).toBeLessThan( + long.length + NestedError.ContextPreviewMaxChars + ) + // The full untruncated value is still on the structured property. + expect(error.context.text).toBe(long) + }) - try { - NestedError.throwError("wrapper", cause) - } catch (error) { - expect(error).toBeInstanceOf(NestedError) - expect((error as NestedError).causes).toEqual([cause]) - } - }) + it("renders a bigint context value without throwing (JSON.stringify would)", () => { + const error = new NestedError("amount", { context: { amount: 5n } }) + expect(error.message).toContain("amount=5") + }) + + it("has no cause chain and an empty context when none are supplied", () => { + const error = new NestedError("standalone") + expect(error.causes).toEqual([]) + expect(error.cause).toBeUndefined() + expect(error.context).toEqual({}) + expect(error.message).toBe("standalone") + }) + + it("create returns an instance; throwError throws one", () => { + const cause = new Error("x") + expect(NestedError.create("made", { cause })).toBeInstanceOf(NestedError) + expect(() => NestedError.throwError("thrown", { cause })).toThrow(NestedError) + try { + NestedError.throwError("thrown", { cause }) + } catch (error) { + expect((error as NestedError).causes).toEqual([cause]) + } + }) + + it("toError passes Errors through and wraps non-Errors", () => { + const real = new Error("real") + expect(NestedError.toError(real)).toBe(real) + expect(NestedError.toError("str")).toBeInstanceOf(Error) + expect(NestedError.toError("str").message).toBe("str") + }) + + it("toErrors flattens and coerces; nullish yields an empty chain", () => { + const a = new Error("a") + expect(NestedError.toErrors([a, "b"]).map(error => error.message)).toEqual([ + "a", + "b" + ]) + expect(NestedError.toErrors(null)).toEqual([]) + expect(NestedError.toErrors(a)).toEqual([a]) }) })