Skip to content

Implement Node's deep equality algorithm for node:assert - #33068

Open
robobun wants to merge 4 commits into
mainfrom
farm/4b16d969/node-assert-deep-equal
Open

Implement Node's deep equality algorithm for node:assert#33068
robobun wants to merge 4 commits into
mainfrom
farm/4b16d969/node-assert-deep-equal

Conversation

@robobun

@robobun robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes #29030
Fixes #28760
Fixes #23877

assert.deepEqual, assert.deepStrictEqual and assert.partialDeepStrictEqual delegated to Bun.deepEquals(), which implements the Jest expect().toEqual() algorithm, not the one Node documents for assert. The two disagree in both directions, so assertions silently pass on Bun that fail on Node, and vice versa.

Passing on Bun, failing on Node (every line below now throws):

import assert from "node:assert";

const re = /a/g; re.lastIndex = 3;
assert.deepStrictEqual(re, /a/g);                                    // lastIndex ignored
assert.deepStrictEqual(Object.create(null), {});                     // prototypes ignored (#29030)
assert.deepStrictEqual(Buffer.from([1, 2]), new Uint8Array([1, 2])); // constructors ignored
assert.deepStrictEqual(Promise.resolve(1), Promise.resolve(2));      // unobservable state
assert.deepStrictEqual(Object.assign(new Date(0), { x: 1 }), new Date(0));
assert.deepStrictEqual(new AggregateError([a], "m"), new AggregateError([b], "m"));
assert.deepStrictEqual({ x: undefined }, {});
assert.deepEqual(new Set([{ a: 1 }, { a: 1 }]), new Set([{ a: 1 }, { a: 2 }])); // #28760

Failing on Bun, passing on Node (every line below now passes):

assert.deepStrictEqual(new Date(NaN), new Date(NaN)); // SameValue, not ==
assert.deepStrictEqual(new Proxy({ a: 1 }, {}), { a: 1 });
assert.deepEqual({ a: 1 }, { a: "1" });               // legacy deepEqual is == based
assert.deepEqual("+00000000", false);                 // #23877

Fix

  • Port Node v26.3.0's lib/internal/util/comparisons.js to src/js/internal/util/comparisons.ts and use it for assert.deepEqual / notDeepEqual / deepStrictEqual / notDeepStrictEqual / partialDeepStrictEqual and util.isDeepStrictEqual, the same way Node wires it. The hand-rolled partialDeepStrictEqual in assert.ts is deleted: Node folded that into comparisons.js as its partial mode.
  • Add a native getOwnNonIndexProperties(object, filter) host function (UtilInspect.cpp), the counterpart of the Node binding the algorithm relies on. JSC's getOwnNonIndexPropertyNames makes it proportional to the named properties. Emulating it in JS has to materialize every index key, which turns assert.deepStrictEqual of two 1 MB Uint8Arrays into ~700 ms instead of microseconds.
  • Bun.deepEquals (so expect().toEqual / toStrictEqual) keeps its Jest semantics and is untouched except for one bug: two invalid dates now compare equal. Their NaN timestamps were compared with ==; Jest and Node both treat them as equal.
  • Define Symbol.toStringTag as "global" on globalThis (own, non-enumerable), like Node. Node's comparison is tag-driven and its test-assert-checktag.js, already in our suite, relies on Object.prototype.toString.call(globalThis) === "[object global]" to tell a copy of the global's enumerable properties apart from the global itself.

Verification

  • New test/js/node/assert/assert-deep-equal.test.ts: 29 of 53 cases fail on current Bun; every expectation was checked against Node v26.3.0.
  • The ported module was differentially tested against Node's real internal/util/comparisons (via --expose-internals) on 116k comparisons over a curated corpus plus structural fuzzing: no mismatches in any of the three modes. The single known deviation is one where Node itself throws an internal TypeError (assert.deepEqual(dataView, arrayBufferOfSameByteLength) passes a non-view to Node's buffer binding); the port reports not-equal instead of throwing.
  • Node's own test/parallel/test-assert-deep.js goes from 23/54 to 52/54 subtests and test-assert-partial-deep-equal.js from 114/140 to 139/140 on this branch. The three remaining subtests are blocked on unrelated pre-existing gaps (util.inspect proxy rendering under showProxy, util.inspect of errors inside the generated diff message, and File not extending Blob), which is why those two files are not vendored in this PR.
  • test-assert.js, test-assert-checktag.js, test-assert-typedarray-deepequal.js, test-assert-deep-with-error.js and everything in test/js/node/assert/ pass.

Two vendored Node tests were passing vacuously

The first CI run surfaced two vendored Node tests whose assertions rely on deepStrictEqual distinguishing Buffer from Uint8Array. They passed before only because Bun.deepEquals cannot tell them apart, and they now fail for pre-existing reasons outside node:assert. Both are recorded in test/expectations.txt with the root cause:

  • test-child-process-advanced-serialization.js: over serialization: "advanced" IPC, Node's v8 DefaultDeserializer revives every Uint8Array view as a Buffer, so { buffer: Buffer.from("Hello!") } round-trips as a Buffer in Node but as a plain Uint8Array in Bun. v8.deserialize(v8.serialize(buf)) instanceof Buffer is false in Bun and true in Node, so this needs a fix in the serializer, not in assert.
  • test-stream-iter-readable-interop.js: Node's Buffer.from(string) allocates out of the 8 KB pool, so stream/iter's concatBytes copies such a chunk into a plain Uint8Array (it never covers its whole backing allocation). Bun's Buffers are not pool-allocated, so the same zero-copy fast path returns the pushed Buffer itself. This cannot be papered over in concatBytes: Node's test-stream-iter-transform-sync.js pins the opposite result type (a Buffer out of bytes()) for exact-allocation chunks like zlib output, and the only discriminator in Node is the allocation pool.

Related PRs

#29037, #32872 (null prototypes), #28763 (Map/Set) and #32948 (Proxy) each fix individual cases inside Bun__deepEquals. Those still matter for expect(), but after this change node:assert no longer goes through Bun__deepEquals at all, so the three assert issues above are fixed here regardless.

assert.deepEqual, assert.deepStrictEqual and assert.partialDeepStrictEqual
delegated to Bun.deepEquals, which implements the Jest expect().toEqual
algorithm, not Node's. Port Node v26.3.0's lib/internal/util/comparisons.js
and use it for node:assert and util.isDeepStrictEqual.

- Add a native getOwnNonIndexProperties binding (the algorithm depends on
  Node's equivalent; emulating it in JS is O(indices) for typed arrays).
- Bun.deepEquals now treats two invalid dates as equal (SameValue, not ==),
  matching both Jest and Node. It is otherwise unchanged.
- Define Symbol.toStringTag on globalThis as "global" like Node;
  Node's tag-driven comparison and test-assert-checktag.js depend on it.

Fixes #29030
Fixes #28760
Fixes #23877
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4629990d-d3a6-40d4-9e8f-6d6f97907505

📥 Commits

Reviewing files that changed from the base of the PR and between 033e0b1 and 8423cd5.

📒 Files selected for processing (11)
  • src/js/internal/primordials.js
  • src/js/internal/util/comparisons.ts
  • src/js/node/assert.ts
  • src/js/node/util.ts
  • src/jsc/bindings/UtilInspect.cpp
  • src/jsc/bindings/UtilInspect.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/bindings.cpp
  • test/expectations.txt
  • test/js/bun/bun-object/deep-equals.spec.ts
  • test/js/node/assert/assert-deep-equal.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:27 AM PT - Jun 29th, 2026

@robobun, your commit 8423cd5 has 4 failures in Build #66812 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33068

That installs a local version of the PR into your bun-33068 executable, so you can run:

bun-33068 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Compare prototypes in deepStrictEqual #29037 - Also fixes assert.deepStrictEqual ignores prototype differences #29030 (null-prototype objects in deepStrictEqual) by modifying assert.ts; would be fully superseded by this PR's complete Node algorithm port
  2. Fix Map and Set deep equality for structurally-equal, non-identical entries #28763 - Also fixes assert.deepEqual / deepStrictEqual does not throw for unequal Sets #28760 (Map/Set structural deep equality) by patching Bun__deepEquals in C++; the node:assert path would be superseded by this PR
  3. Bun.deepEquals: fix four false-positive equality classes #32872 - Also fixes assert.deepStrictEqual ignores prototype differences #29030 plus three other Bun.deepEquals false positives; the node:assert portion would be superseded by this PR's algorithm port

🤖 Generated with Claude Code

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Not duplicates, but they overlap on outcomes. #29037, #32872 and #28763 change Bun__deepEquals, the native algorithm behind expect().toEqual / toStrictEqual, which is also what node:assert used to delegate to. After this PR, node:assert and util.isDeepStrictEqual use Node's own comparison algorithm instead, so the assert-facing issues those PRs reference (#29030, #28760) are fixed here together with the rest of Node's semantics (RegExp#lastIndex, Error#cause and AggregateError#errors, objects with unobservable state, symbol keys, sparse arrays, the == coercion of the legacy deepEqual, and partialDeepStrictEqual).

The Bun__deepEquals changes in those PRs are still relevant for expect(), where Jest is the reference. This PR leaves that algorithm alone apart from the invalid-Date case, which Jest and Node agree on.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I didn't find any bugs, but this is a large change — a ~1000-line port of Node's comparison algorithm, new native C++ bindings, and a globally-visible Symbol.toStringTag on globalThis — so it warrants a human review before landing.

Extended reasoning...

Overview

This PR replaces node:assert's deep-equality implementation (previously delegated to Bun.deepEquals) with a faithful port of Node v26.3.0's lib/internal/util/comparisons.js (~1000 new lines in src/js/internal/util/comparisons.ts). It also: adds a native getOwnNonIndexProperties host function in UtilInspect.cpp; defines globalThis[Symbol.toStringTag] = "global" in ZigGlobalObject.cpp; fixes invalid-Date equality in bindings.cpp; rewires assert.ts / util.ts to the new module; and adds ~300 lines of tests.

Security risks

None apparent. The change is pure comparison/inspection logic with no auth, crypto, network, or filesystem surface. The new C++ host function is read-only property enumeration over a caller-supplied object and follows existing JSC patterns with proper exception scopes.

Level of scrutiny

High. This is a behavioral rewrite of widely-used node:assert APIs (deepEqual, deepStrictEqual, partialDeepStrictEqual) and util.isDeepStrictEqual, plus native bindings changes and a globally-observable modification to globalThis (Symbol.toStringTag). While the port is well-documented and the PR description shows extensive differential testing against Node (116k fuzzed comparisons, Node's own test-assert-deep.js going from 23/54 → 52/54), the sheer size and the cross-cutting nature (JS runtime + C++ + global object) put it well outside the "simple, mechanical" bar for auto-approval.

Other factors

The bug-hunting system found no issues, the new test file is thorough, and the verification story in the description is strong. Still, design decisions here (e.g., adding Symbol.toStringTag to the global, the perf trade-off of moving from native Bun.deepEquals to a JS-side algorithm, and the one acknowledged divergence around assert.deepEqual(dataView, arrayBuffer)) are the kind of calls a maintainer should sign off on.

…cted failures

Bun's oxlint no-duplicate-conditional-property-access rule requires hoisting
repeated property reads in comparisons.ts.

Two vendored Node tests asserted Buffer vs Uint8Array distinctions that
Bun.deepEquals could not see, so they passed vacuously. Both now fail for
pre-existing reasons outside node:assert and are recorded in
test/expectations.txt:
- test-child-process-advanced-serialization.js: advanced IPC revives a
  Buffer as a plain Uint8Array (Node's v8 DefaultDeserializer returns a
  Buffer for every Uint8Array view).
- test-stream-iter-readable-interop.js: Buffer.from(string) is not
  pool-allocated in Bun, so stream/iter's concatBytes zero-copy fast path
  returns the pushed Buffer where Node copies into a plain Uint8Array.
@robobun
robobun requested a review from Jarred-Sumner as a code owner June 29, 2026 12:52
Comment thread test/expectations.txt
…bling

Same single-gc()+single-setImmediate FinalizationRegistry race already
documented for test-tls-connect-memleak.js, on the same two alpine x64
targets (build 66749). The test only uses assert.strictEqual; the trigger is
the startup heap-layout shift from registering a new builtin module, not the
comparison change.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No bugs found, and the earlier CI-quarantine nit is addressed in 8b7436d — but this is a ~1000-line port of Node's comparison algorithm plus a new native host function and a globally-visible Symbol.toStringTag on globalThis, so it deserves a human pass.

Extended reasoning...

Overview

This PR replaces node:assert's deep-equality backend (previously Bun.deepEquals) with a faithful port of Node v26.3.0's lib/internal/util/comparisons.js (~1050 new lines in src/js/internal/util/comparisons.ts), rewires assert.deepEqual / deepStrictEqual / partialDeepStrictEqual and util.isDeepStrictEqual to it, adds a new native getOwnNonIndexProperties host function in UtilInspect.cpp, defines Symbol.toStringTag = "global" on globalThis in ZigGlobalObject.cpp, and fixes the NaN-date case in Bun__deepEquals (bindings.cpp). It also adds a 290-line test file and quarantines three vendored Node tests in expectations.txt whose false-passes were unmasked by the stricter comparison.

Security risks

None apparent. The comparison algorithm is pure inspection (no eval, I/O, or privilege boundaries). The new C++ host function reads own property names via JSC's existing getOwnNonIndexPropertyNames / method-table getOwnPropertyNames and follows the standard THROW_SCOPE / RETURN_IF_EXCEPTION pattern. The globalThis[Symbol.toStringTag] addition matches Node and is non-enumerable/read-only.

Level of scrutiny

High. node:assert underpins both user test suites and Bun's own vendored Node test corpus, so semantic changes here ripple widely (the PR itself documents two vendored tests that flipped from vacuous-pass to fail). The ported algorithm is intricate — cycle detection via a stack-overflow-then-memoize fallback, Set/Map bipartite matching with direction heuristics, partial-mode subsequence matching, loose-mode == coercion paths — and any subtle deviation from Node's behavior is exactly the class of bug this PR is meant to eliminate. The globalThis toStringTag change is small but globally observable. New native code in UtilInspect.cpp (Proxy fallback path, reifyAllStaticProperties, jsOwnedString lifetime) merits a human eye.

Other factors

  • The bug-hunting system found nothing, and my earlier inline comment (missing test-net-connect-memleak quarantine on linux-x64-musl) was addressed in 8b7436d and is now resolved.
  • Test coverage is thorough (53 new cases checked against Node v26.3.0, plus differential fuzzing described in the PR body), and the PR description carefully documents the known deviations and unmasked failures.
  • The latest CI report on the thread (build #66756 for bab3430) still shows three musl build-bun failures; there is no CI result posted yet for the current HEAD 8b7436d. That, plus the overlap with three other open PRs (#29037, #28763, #32872), is another reason a maintainer should sign off on the direction here rather than auto-merge.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: every lane that exercises this diff is green; the remaining red is three macOS jobs, none related to the change.

Final state of the two runs of this exact tree (66763 at 8b7436d and 66812 at 8423cd5, which is an empty retrigger commit):

  • darwin 26 aarch64 - test-bun failed identically in both runs before executing a single test:
    Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
    
  • The macOS 14 lanes time out in a different set of unrelated tests in each run of the same code: 66763 timed out in bun-serve-file.test.ts (aarch64); 66812 timed out in bun-serve-file.test.ts and fetch-file-upload.test.ts (aarch64) and in terminal.test.ts (x64). Each of those passes on every other platform in the same run, and all of them passed on the macOS 14 lanes in the first full run of this diff (66749). The same aarch64 runner also cannot start the amd64 autobahn Docker image ("exec format error").

Everything CI attributed to this PR in the first run is addressed: the oxlint errors are fixed, and the two vendored Node tests whose Buffer-vs-Uint8Array assertions were passing vacuously, plus the musl FinalizationRegistry sibling, are recorded in test/expectations.txt with their root causes.

Not pushing further retriggers to avoid spinning CI; this is ready for review.

Comment thread src/js/internal/util/comparisons.ts
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status check against current main (f426a8e), since #29030 was closed as fixed by #34660 in the meantime.

I copied this PR's test/js/node/assert/assert-deep-equal.test.ts onto main and ran it: 39 of 53 pass, 14 fail. #29030 itself (prototype identity in deepStrictEqual) is fixed on main, but the other two issues this PR targets still reproduce there, along with a few more of the cases in this file:

  • assert.deepEqual on main still delegates to Bun.deepEquals(a, b, false), so the legacy == semantics are missing: assert.deepEqual({ a: 1 }, { a: "1" }), assert.deepEqual("+00000000", false) (assert.deepEqual() behaves differently in Bun compared to Node.js #23877), null vs undefined values, == Map keys and Set values, and two invalid dates all throw on main and pass on node v26.3.0; assert.deepEqual({ x: undefined }, {}) and sparse array vs [undefined] pass on main and throw on node.
  • assert.deepEqual(new Set([{ a: 1 }, { a: 1 }]), new Set([{ a: 1 }, { a: 2 }])) still passes on main (assert.deepEqual / deepStrictEqual does not throw for unequal Sets #28760).
  • deepStrictEqual gaps remaining on main: a transparent Proxy vs its target's shape throws (node passes), AggregateErrors with different errors pass (node throws), and lastIndex is still ignored in the loose comparison.

One correction to the description: the Bun.deepEquals change for two invalid dates does not match Jest. @jest/expect-utils 29.7 compares dates with +a == +b and explicitly documents invalid dates as not equivalent, so Bun.deepEquals(new Date(NaN), new Date(NaN)) === false on main is the Jest behavior; only the node:assert side should treat them as equal.

So this is not covered by main; leaving it open. It conflicts with main now that #34660 restructured assert.ts, so it needs a rebase (and the invalid-date change to Bun.deepEquals dropped) before it is reviewable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant