Skip to content

assert: node v26 deep-equality fidelity — loose mode, errors, set/map pairing (+0 tests, correctness only) - #35393

Closed
cirospaciari wants to merge 3 commits into
claude/node-v26-sweep-afrom
claude/node-v26-deep-equal
Closed

assert: node v26 deep-equality fidelity — loose mode, errors, set/map pairing (+0 tests, correctness only)#35393
cirospaciari wants to merge 3 commits into
claude/node-v26-sweep-afrom
claude/node-v26-deep-equal

Conversation

@cirospaciari

Copy link
Copy Markdown
Member

Stacked on #35382 — review that first.

What this does

Brings assert.deepEqual / assert.deepStrictEqual up to node v26.3.0 fidelity. No test
files are added here: this is a correctness change to the comparator that hundreds of
already-vendored tests assert through, where a wrong "equal" verdict silently makes
assertions pass that should fail.

Measured against upstream test/parallel/test-assert-deep.js, which is not vendored yet:
24 of its 54 subtests failed before this branch, 4 do now. The four that remain are
listed at the bottom; none of them is a deep-equality bug.

Why now

Two other branches in the v26 sweep are blocked behind this comparator, and every
deepStrictEqual in the vendored suite inherits its verdicts.

What changed — all in Bun__deepEquals (src/jsc/bindings/bindings.cpp)

1. assert.deepEqual now uses node's loose comparison, not Bun's. It was wired to
Bun.deepEquals(a, b, false) — the relation expect().toEqual() uses, which is a
different relation from node's loose mode. It produced wrong verdicts in both directions:
assert.deepEqual(4, '4') threw (node passes), while a Date subclass carrying extra own
properties compared equal to a plain Date (node fails it). It now goes through a
node-loose instantiation of the same native comparator as deepStrictEqual.

Relative to node's strict mode, node's loose mode: compares non-objects with == (counting
callables as non-objects, per typeof val !== 'object'), does not compare prototypes,
compares Object.prototype.toString tags instead — this is what separates an arguments
object from a plain object with the same keys — and ignores symbol-keyed properties.

Bun.deepEquals and expect() never set checkPrototypes, so every new branch is
compiled out for them.

2. Errors are compared by node's rules even when they are not Error instances.
AggregateError's errors is own-but-non-enumerable and was never compared, so
new AggregateError([e1], 'x') equalled new AggregateError([e1, e2], 'x'). And an object
with Error.prototype in its chain that is not a real ErrorInstance — what
Object.create(Object.getPrototypeOf(err), descriptors) produces — was compared as a plain
object, so two of them with different non-enumerable message values compared equal. Both
now follow node: message, name, cause and errors are compared unless the right-hand
side owns them enumerably, in which case the ordinary key walk already covers them.

3. Sets and maps are paired off instead of matched repeatedly.
deepStrictEqual(new Set([{a:1},{a:1}]), new Set([{a:1},{a:2}])) reported equal: every
element of the first set found some deep-equal partner in the second, and nothing
recorded that {a:1} had already been spent. Maps had the same hole for deep-equal but
distinct object keys. The node entry point now consumes each match the way node's
setEquiv / mapEquiv do. The pairing comparisons memoise through the existing cycle
stack — without that a self-referential set recurses until the stack overflows, which is
how the first version of this change was caught.

How this was verified

  • test-assert-deep.js run the way CI runs it: 30 → 50 of 54 subtests passing.
  • test-assert-partial-deep-equal.js: 139 of 140, unchanged by this branch.
  • All 60 already-vendored test-assert-*, test-util-* and test-dns-* upstream files
    still pass, including test-util-isDeepStrictEqual.js.
  • bun test test/js/bun/test/expect.test.js: 415 pass, 0 fail — the jest-facing relation
    is untouched.
  • Each loose-mode rule was checked against the node v26.3.0 binary rather than inferred,
    including the asymmetric promise rule and the sparse-array cases.

Still failing in test-assert-deep.js (4), none of them deep-equality

  • Crypto — needs the KeyObject / CryptoKey material comparison being added on
    another branch. It lands inside specialObjectsDequal, under the same checkPrototypes
    gate, so it will cover loose mode too once merged.
  • URLsutil.inspect(url, { customInspect: false }) prints URL {} in Bun and
    http://foo/ in node, so the assertion message does not contain the href the test
    matches on. The comparison verdict itself is already correct.
  • Check proxies — needs util.inspect's showProxy rendering inside the assertion
    diff.
  • Handle sparse arrayspartialDeepStrictEqual([1,,,3], [1,undefined,,3]) passes in
    Bun and throws in node: a hole on the left must not satisfy an explicit undefined on
    the right. That is in the separate partial comparator (Bun__deepMatch), not in
    Bun__deepEquals.

test-assert-partial-deep-equal.js's one remaining failure is unrelated to comparison too:
Bun sets File.prototype to Blob.prototype rather than making File a real subclass, so
Object.getPrototypeOf(File.prototype) !== Blob.prototype. There is a standing TODO for
that in src/jsc/bindings/JSDOMFile.cpp.

assert.deepEqual and assert.notDeepEqual were routed to
Bun.deepEquals(a, b, false), the comparison expect().toEqual() uses.
That is a different relation from node's loose mode, and it produced
wrong 'equal' verdicts for a long list of inputs: 4 vs '4' compared as
unequal while a Date subclass carrying extra own properties compared as
equal to a plain Date.

They now go through a node-loose instantiation of the same native
comparator as deepStrictEqual. Relative to node's strict mode, node's
loose mode compares non-objects with == (counting callables as
non-objects), does not compare prototypes, compares
Object.prototype.toString tags instead, and ignores symbol-keyed
properties. Bun.deepEquals and expect() do not set checkPrototypes and
are unaffected by every new branch.

Also fixes two error-comparison gaps that applied to both modes:
AggregateError's non-enumerable .errors was never compared, and objects
with Error.prototype in their chain that are not real Error instances
were compared as plain objects rather than by message/name/cause/errors.
deepStrictEqual(new Set([{a:1},{a:1}]), new Set([{a:1},{a:2}])) reported
equal: every element of the first set found *some* deep-equal partner in
the second, and nothing recorded that {a:1} had already been spent. Maps
had the same hole for deep-equal-but-distinct object keys.

The node entry point now consumes each match, the way node's setEquiv
and mapEquiv do, so multiplicity is significant. Bun.deepEquals and
expect() keep the previous rule. The pairing comparisons memoise through
the existing cycle stack, without which a self-referential set recurses
until the stack overflows.
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator
Updated 10:52 PM PT - Jul 23rd, 2026

@cirospaciari, your commit d1e9ec5 is still building in Build #79388, but has 2 failures so far (All Failures):

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. assert.deepEqual / deepStrictEqual does not throw for unequal Sets #28760 - PR adds consumption-based Set/Map pairing, directly fixing assert.deepEqual/deepStrictEqual not throwing for unequal Sets
  2. assert.deepStrictEqual ignores prototype differences #29030 - PR properly compares prototypes in strict mode, fixing assert.deepStrictEqual({}, Object.create(null)) incorrectly passing
  3. assert.deepEqual() behaves differently in Bun compared to Node.js #23877 - PR introduces Bun__deepEqualsNodeLoose with proper == coercion, fixing assert.deepEqual behaving differently from Node.js

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #28760
Fixes #29030
Fixes #23877

🤖 Generated with Claude Code

@cirospaciari cirospaciari changed the title assert: node v26 deep-equality fidelity (loose mode, errors, set/map pairing) assert: node v26 deep-equality fidelity — loose mode, errors, set/map pairing (+0 tests, correctness only) Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Implement Node's deep equality algorithm for node:assert #33068 - Implements Node's deep equality algorithm for node:assert, covering the same loose-mode semantics, Set/Map pairing fixes, and Error/AggregateError comparison
  2. Fix Map and Set deep equality for structurally-equal, non-identical entries #28763 - Fixes Map and Set deep equality pairing for structurally-equal, non-identical entries (same Set/Map consumption semantics)
  3. Bun.deepEquals: fix four false-positive equality classes #32872 - Fixes four false-positive equality classes in Bun.deepEquals, overlapping prototype and property enumeration changes in bindings.cpp
  4. Compare prototypes in deepStrictEqual #29037 - Adds prototype comparison in deepStrictEqual, which assert: node v26 deep-equality fidelity — loose mode, errors, set/map pairing (+0 tests, correctness only) #35393 subsumes via its loose-mode tag-vs-prototype distinction

🤖 Generated with Claude Code

@cirospaciari

Copy link
Copy Markdown
Member Author

Superseded by #35391, which now combines eight branches stacked on claude/callback-throw-uncaught into one review surface. Every source change and every vendored test from this branch is carried over unchanged — verified line-by-line against the merge-base diff — and #35391's description lists the merge conflicts that needed a decision.

The branch is not deleted.

@cirospaciari

Copy link
Copy Markdown
Member Author

🤖 From the combined PR #35391: your change makes 28 tests in test/js/node/assert/deep-equal.test.ts pass that are still marked .failing, so the file reports them as failures on every lane:

✗ assert.deepEqual > accepts '1' and 1 (Bun reports not equal)
  ^ this test is marked as failing but it passed. Remove `.failing` if tested behavior now works

That is your fix working — '1'/1, 0/-0, null/undefined, 1n/1, {}/{a: undefined}, boxed strings with extra own properties, and the rest. CLAUDE.md: un-skipping the tests a fix makes pass is the acceptance criterion, so the .failing markers need removing in the same PR.

Confirmed member-caused, not a merge interaction: on your own branch's build (d1e9ec58a5) the same file reports 238 pass / 24 fail, all the same 'marked as failing but it passed' shape. The combined branch shows 28 — the extra 4 come from sweep-a's proxy and WeakMap changes flipping four more.

Please strip the now-obsolete .failing markers; I will re-merge and re-run. Nothing else from your branch is red.

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