Skip to content

fix: explicit undefined option no longer disables the recursion limits - #1

Merged
MichaelLeeHobbs merged 1 commit into
mainfrom
fix/maxdepth-undefined-disables-depth-guard
Jul 17, 2026
Merged

fix: explicit undefined option no longer disables the recursion limits#1
MichaelLeeHobbs merged 1 commit into
mainfrom
fix/maxdepth-undefined-disables-depth-guard

Conversation

@MichaelLeeHobbs

Copy link
Copy Markdown
Owner

The bug

stderr() spread ...options over the resolved defaults:

const opts: NormalizeOptionsInternal = {
    maxDepth: _maxDepth,
    // ...
    ...options,   // an explicit `undefined` overwrites the default
};

Passing an explicit undefined — e.g. stderr(e, { maxDepth: config.maxDepth }) where the config key is absent, the most natural caller pattern — overwrote the default with undefined instead of falling back to it.

validateOption() returns early on undefined, so the value passed validation. checkDepthLimit then evaluated depth >= undefined, which is always false, disabling the depth guard entirely. Normalization recursed until the stack overflowed, turning the bounded-recursion guarantee (ADR-001, ADR-003) into a RangeError.

The NormalizeOptionsInternal = Required<NormalizeOptions> annotation could not catch this — the spread is type-compatible.

Reproduced against dist/ on main:

=== default (maxDepth=8) ===  [cause]: [Max depth of 8 reached]
=== maxDepth: undefined ===   THREW: RangeError - Maximum call stack size exceeded

The fix

Resolve each field with ?? rather than spreading. Four lines in src/stderr.ts.

Blast radius

Narrower than it first appears, and worth recording: toString() reads maxDepth off the StdError instance (MAX_DEPTH_SYMBOL ?? StdError.defaultMaxDepth, which independently defaults to 8), not off the normalize options. Display therefore stayed bounded either way — only the normalize recursion ran unbounded.

The regression tests assert the normalized structure rather than toString() for exactly this reason; a toString()-based assertion passes even with the bug present.

Tests

Four tests added under Recursion & Depth Limiting. Two reproduce the bug (verified failing with the fix reverted, passing with it applied); two are guards that explicit values and the other two options still resolve correctly.

Full suite: 313/313 passing, 100% coverage, typecheck clean.

Not included

No version bump, no release. package.json untouched at 2.2.0.

stderr() spread `...options` over the resolved defaults, so passing an
explicit `undefined` (e.g. `stderr(e, { maxDepth: config.maxDepth })` with
an absent config key) overwrote the default with `undefined` rather than
falling back to it.

validateOption() returns early on `undefined`, so the value passed
validation, and `checkDepthLimit` then evaluated `depth >= undefined`,
which is always false. That disabled the depth guard entirely and let
normalization recurse until the stack overflowed -- turning the library's
bounded-recursion guarantee (ADR-001, ADR-003) into a RangeError. The
`NormalizeOptionsInternal = Required<NormalizeOptions>` annotation could
not catch it, since the spread is type-compatible.

Resolve each field with `??` instead of spreading.

Note the blast radius was the normalize walker only: the display walker
reads maxDepth off the StdError instance, which independently defaults to
8, so toString() stayed bounded either way. The regression tests assert
the normalized structure accordingly.
Copilot AI review requested due to automatic review settings July 17, 2026 02:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a recursion-limit bypass in stderr() where explicitly passing undefined in options could overwrite default limits and disable depth guarding, leading to unbounded recursion and potential stack overflows.

Changes:

  • Resolve NormalizeOptionsInternal fields with per-option ?? fallback instead of spreading options over defaults.
  • Add regression tests to ensure explicitly undefined options fall back to defaults and that explicit values still take effect.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/stderr.ts Fixes option resolution so undefined no longer unsets recursion/normalization limits.
test/stderr.test.ts Adds regression coverage for explicit-undefined options and default limit behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread test/stderr.test.ts
Comment on lines +589 to +593
const deepChain = () => {
let e = new Error('leaf');
for (let i = 0; i < 5000; i++) e = new Error(`L${i}`, { cause: e });
return e;
};
@MichaelLeeHobbs
MichaelLeeHobbs merged commit bc51513 into main Jul 17, 2026
1 check passed
@MichaelLeeHobbs
MichaelLeeHobbs deleted the fix/maxdepth-undefined-disables-depth-guard branch July 17, 2026 04:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants