node:assert: compare constructors like node in deepStrictEqual, not [[Prototype]] identity - #40131
Conversation
…[Prototype]] identity
Node's strict deep equality compares val1.constructor with === when the
constructor is inherited or is a well-known built-in, and falls back to
[[Prototype]] identity only when the object has no constructor or an own
non-built-in one. The 1.4.0 check required [[Prototype]] identity for
every object pair, so Object.create({ x: 1 }) stopped being equal to {}.
Fixes #40127
|
Warning Review limit reached
On-demand reviews are free for the next 27 days. After that, they cost $0.25 per reviewed file. Or wait 21 minutes for your next included review. View limit detailsLimit details: You’ve used the included review currently available. Your 68 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour. WalkthroughChangesJSBuffer constructor refactor
Node deep equality
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
@coderabbitai review |
|
|
@coderabbitai review |
|
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it re-encodes Node's objectComparisonStart semantics in the JSC deep-equals path (new observable [[Get]]s on .constructor, a well-known-constructor set, exception-scope changes), a human look would still be worthwhile.
What was reviewed
- Exception handling: every
get/getOwnPropertySlot/getPrototype/strictEqualhas aRETURN_IF_EXCEPTION;isWellKnownConstructoris pure structure/pointer checks. - Branch logic against the PR's stated Node rule — undefined → proto, inherited → ctor, own well-known → ctor, own non-well-known → proto — matches the code.
- Includes:
FunctionConstructor.handJSPromiseConstructor.hare already in the file; the new headers cover the rest. - Ruled out:
SharedArrayBufferomission from the well-known set (verifier confirmed not a bug).
Extended reasoning...
Overview
Replaces the [[Prototype]]-identity check in the checkPrototypes instantiation of Bun__deepEquals (bindings.cpp) with Node's constructor-based rule from lib/internal/util/comparisons.js. Adds isWellKnownConstructor (17 inherits<> checks + typed-array/Buffer identity checks) and 9 new matrix cases in test/js/node/assert/deep-equal.test.ts. Scope is limited to the node:assert/util.isDeepStrictEqual entry point via the checkPrototypes && !skipPrototypeIdentity template guard — Bun.deepEquals and expect() are unchanged.
Security risks
None identified. The new code reads .constructor via ordinary [[Get]] (can trigger user getters/Proxy traps), but Node does the same and every call site has RETURN_IF_EXCEPTION. No new allocations sized from untrusted input; isWellKnownConstructor is side-effect-free apart from possibly lazy-initializing the Buffer constructor via JSBufferConstructor() (main-thread only, which this path always is).
Level of scrutiny
Medium-high. This is a user-observable semantic change to assert.deepStrictEqual that fixes a regression (#40127) but requires faithfully mirroring Node's objectComparisonStart. The C++ is in a hot, exception-sensitive JSC path. The implementation looks correct — the four-way branch (undefined/inherited/own-well-known/own-other) matches the described Node rule, and the test matrix covers each arm plus both directions of the reporter's case. But confirming the well-known set exactly matches Node's wellKnownConstructors, and that the observable side-effect ordering (Bun does [[Get]] before [[GetOwnProperty]]; Node does hasOwnProperty first) is acceptable, is worth a maintainer's eye.
Other factors
- Test coverage is strong: new cases are cross-checked against Node v26.3.0, existing null-prototype/subclass cases from #34660 are preserved, and the PR ran the vendored Node assert tests plus the reporter's es-sax suite.
typedArrayConstructorConcurrentlyis used deliberately (no lazy init) with a sound justification in the comment.- No prior human or bot reviews with substantive feedback; CodeRabbit was rate-limited.
|
On the side-effect ordering note: the order matches node. Node evaluates |
Is that not a common thing? |
|
No, it is rare. For almost every object, The difference only shows when |
…l, not constructor names
Node checks the type tag after the constructor rule; the node entry point
was still comparing calculatedClassName, so two objects on one prototype
with different own constructor functions, or a Proxy of {} and {}, were
reported unequal. The tag check also applies in skipPrototype mode, as in
node.
… walk for the constructor lookup isWellKnownConstructor now compares ClassInfo pointers for every entry, including the typed array, DataView and Buffer constructors, instead of mixing inherits<> with current-realm identity. JSBufferConstructor's declaration moves to JSBuffer.h so its info() is reachable. The constructor lookup does a single getPropertySlot walk: the slot base answers hasOwn(val1, 'constructor'), and when the slot is a cacheable data property on a prototype chain shared by both objects (same mono-proto structure) val2.constructor is the same value, so the second walk is skipped.
…eepStrictEqual matrix No-Verification-Needed: test-only change
|
Thanks for the review and the follow-up commits. |
The calculatedClassName guards must stay off for skipPrototypeIdentity without checkPrototypes too; that instantiation backs Bun.deepEquals's fourth argument.
No-Verification-Needed: comment-only change
|
Updated 2:45 AM PT - Aug 24th, 2026
✅ @dylan-conway, your commit 4e18b48bc9c758e5bc458ce2d4e7b82382a30414 passed in 🧪 To try this PR locally: bunx bun-pr 40131That installs a local version of the PR into your bun-40131 --bun |
There was a problem hiding this comment.
I re-reviewed after 0dddff9, fece231, and b31d7c3 — both earlier findings are addressed and this pass found no bugs. Given the change is a semantic port of node's objectComparisonStart into the C++ deep-equality path (property-slot walk, Proxy/isTaintedByOpaqueObject branch, the mono-proto structure shortcut, and the new objectPrototypeToString tag comparison), a human look at the algorithm fidelity would still be worthwhile.
Checked this run: exception propagation after every JS-entering call in the new block; both calculatedClassName guards now gate on !checkPrototypes && !skipPrototypeIdentity so Bun.deepEquals(a, b, true, true) stays prototype-blind; isWellKnownConstructor against node's set (SharedArrayBuffer is intentionally absent in node's list too); the JSBufferConstructor header move is a pure relocation.
Extended reasoning...
Overview
The PR replaces the [[Prototype]] identity check in Bun__deepEquals<…, checkPrototypes=true, …> (bindings.cpp) with node's constructor/prototype rule from lib/internal/util/comparisons.js objectComparisonStart, plus an Object.prototype.toString tag comparison. It adds isWellKnownConstructor (~30 ClassInfo checks), moves JSBufferConstructor from JSBuffer.cpp to JSBuffer.h so its info() is visible, and adds ~40 matrix cases to test/js/node/assert/deep-equal.test.ts plus a 4-arg Bun.deepEquals regression test.
Security risks
None. This is comparison-semantics logic in node:assert/util.isDeepStrictEqual. No auth, crypto, filesystem, or network surface. The new code reads .constructor via getPropertySlot (can fire getters/Proxy traps) but node does the same, and every JS-entering call has RETURN_IF_EXCEPTION.
Level of scrutiny
High. This is hand-written C++ in the JSC bindings layer implementing a subtle Node.js compat algorithm with several branches (well-known constructor vs inherited vs own, Proxy taint fallback, mono-proto structure shortcut). It affects assert.deepStrictEqual, util.isDeepStrictEqual, and every downstream test using them. The inheritedFromSharedChain optimization (same mono-proto structureID ⇒ same inherited data-slot value) is correct but non-obvious enough that a maintainer should confirm it. The checkPrototypes instantiation is also on the hot path for the vendored node test suite.
Other factors
Two prior claude[bot] findings were fixed in follow-up commits: the <true,false,false,true> guard regression (0dddff9, now covered by deep-equals.test.ts) and a duplicate test case (b31d7c3). The comment-cop bot flagged long comments which were trimmed (fece231). A maintainer (dylan-conway) is already shepherding the PR. The test matrix is comprehensive and cross-checked against node v26.3.0. Verifier agents ruled out the SharedArrayBuffer omission — node's wellKnownConstructors set does not include it either. Given the algorithm complexity and that it changes user-visible node:assert semantics, deferring rather than approving.
Problem
assert.deepStrictEqual(Object.create({ x: 1 }), {})throws in Bun 1.4.0. It passes in Node and in Bun 1.3.14. The error is "Values have same structure but are not reference-equal". This broke the es-sax test suite (assert.deepEqualregression in Bun v1.4.0 #40127, 13 failures).src/jsc/bindings/bindings.cpp. Node does not require prototype identity.Fix
objectComparisonStartinlib/internal/util/comparisons.js). Whenval1.constructoris inherited, or is a well-known built-in constructor, compare the twoconstructorvalues with===. Otherwise compare the[[Prototype]]s with===. SoObject.create({ x: 1 })equals{}, whileObject.create(null)against{}, class instances against literals, and subclass instances against base instances stay unequal.Object.prototype.toStringtags like node. This replaces the oldcalculatedClassNamecomparison and also applies inskipPrototypemode.isWellKnownConstructormirrors node'swellKnownConstructorsset by ClassInfo: the built-in constructors, the typed arrays, DataView, and Buffer. One property-slot walk supplies both theconstructorvalue and the own-property check.test/js/node/assert/deep-equal.test.ts(new matrix cases, stock bun fails the new strict checks). Also the fulltest/js/node/assert/suite, the vendored node assert tests,Bun.deepEqualsandexpect()suites, and the reporter's es-sax suite (117 pass, was 101).Background
checkPrototypesinstantiation serves onlynode:assertandutil.isDeepStrictEqual.Bun.deepEqualsandexpect()use prototype-blind instantiations and do not change here.constructorproperty whose value is not a built-in (an untrustworthy constructor).Notes
Behavior table (node v26.3.0 = bun with this fix):
Object.create({x:1})vs{}Object.create({x:1})vsObject.create({z:2})Object.create(null)vs{}new A()vs{}new Map()constructor: Object, different prototypesconstructor: fn, different prototypesThe well-known check only changes the outcome when the object has an own
constructorproperty: an inherited defined constructor always takes the constructor-comparison branch.The tag comparison matches node's
hasUnequalTag/slowHasUnequalTag: two objects on one prototype with different own constructor functions, or a Proxy of{}and{}, are equal in node and were reported unequal by the oldcalculatedClassNamecomparison.val1.constructoris an ordinary [[Get]] in node too, so getters and proxy traps fire the same way in both implementations.Suites run:
test/js/node/assert/(438 pass),test-assert.js,test-assert-checktag.js,test-assert-deep-with-error.js,test-assert-typedarray-deepequal.js,test-assert-class.js(vendored node tests),test/js/bun/bun-object/deep-equals.test.ts+test/js/bun/test/expect.test.js(460 pass), es-sax atgameroman-npm/es-sax#renovate/bun-1.x(117 pass, 0 fail).no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/bun-object/deep-equals.test.ts