From d9306ce4d8f8be449dcd6f26de3007a39aafa362 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 05:12:50 +0000 Subject: [PATCH 1/3] assert: give deepEqual node's loose comparison semantics 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. --- src/js/internal/util/comparisons.ts | 6 +- src/js/node/assert.ts | 5 +- src/jsc/bindings/bindings.cpp | 198 +++++++++++++++++++++--- src/jsc/modules/NodeUtilTypesModule.cpp | 12 ++ src/jsc/modules/NodeUtilTypesModule.h | 4 + 5 files changed, 201 insertions(+), 24 deletions(-) diff --git a/src/js/internal/util/comparisons.ts b/src/js/internal/util/comparisons.ts index 6ed38ed6c6bc..1af825c9f48f 100644 --- a/src/js/internal/util/comparisons.ts +++ b/src/js/internal/util/comparisons.ts @@ -6,4 +6,8 @@ // kStrictWithoutPrototypes mode). node v26.3.0 exposes fn.length === 3. const isDeepStrictEqual = $newCppFunction("NodeUtilTypesModule.cpp", "jsFunctionIsDeepStrictEqual", 3); -export default { isDeepStrictEqual }; +// node's loose mode, behind assert.deepEqual / assert.notDeepEqual. Not the +// same relation as Bun.deepEquals(a, b, false), which expect() uses. +const isDeepEqual = $newCppFunction("NodeUtilTypesModule.cpp", "jsFunctionIsDeepEqual", 2); + +export default { isDeepStrictEqual, isDeepEqual }; diff --git a/src/js/node/assert.ts b/src/js/node/assert.ts index 6e170654c5dc..dc1527192c81 100644 --- a/src/js/node/assert.ts +++ b/src/js/node/assert.ts @@ -46,11 +46,8 @@ type nodeAssert = typeof import("node:assert"); const kOptions = Symbol("options"); -const { isDeepStrictEqual } = require("internal/util/comparisons"); +const { isDeepStrictEqual, isDeepEqual } = require("internal/util/comparisons"); -function isDeepEqual(a, b) { - return Bun.deepEquals(a, b, false); -} var _inspect; function lazyInspect() { diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index d15413e92ab1..e179fbadb827 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -70,6 +70,7 @@ #include "JavaScriptCore/Strong.h" #include "JavaScriptCore/JSSetIterator.h" #include "JavaScriptCore/JSString.h" +#include "JavaScriptCore/ObjectPrototype.h" #include "JavaScriptCore/ProxyObject.h" #include "JavaScriptCore/Microtask.h" #include "JavaScriptCore/MicrotaskQueue.h" @@ -667,14 +668,70 @@ static bool looseFloatContentsEqual(std::span a, std::span b) return true; } +// `val instanceof Error` without invoking a user-visible Symbol.hasInstance: +// walk the prototype chain looking for %Error.prototype%. +static bool inheritsFromErrorPrototype(JSC::JSGlobalObject* globalObject, ThrowScope& scope, JSObject* object) +{ + JSObject* errorPrototype = globalObject->errorPrototype(); + JSValue proto = object->getPrototype(globalObject); + RETURN_IF_EXCEPTION(scope, false); + while (proto.isObject()) { + if (proto.getObject() == errorPrototype) return true; + proto = asObject(proto)->getPrototype(globalObject); + RETURN_IF_EXCEPTION(scope, false); + } + return false; +} + +// node compares an error's message/name/cause/errors even though they are +// normally non-enumerable, unless the right-hand side owns the property +// enumerably - in which case the ordinary key walk already covers it. +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/comparisons.js#L392-L404 +template +static bool errorLikeFieldsEqual(JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSObject* o1, JSObject* o2) +{ + VM& vm = globalObject->vm(); + const PropertyName fields[] = { + PropertyName(vm.propertyNames->message), + PropertyName(vm.propertyNames->name), + PropertyName(vm.propertyNames->cause), + PropertyName(vm.propertyNames->errors), + }; + for (const PropertyName& field : fields) { + PropertySlot slot2(o2, PropertySlot::InternalMethodType::GetOwnProperty); + bool o2OwnsField = o2->methodTable()->getOwnPropertySlot(o2, globalObject, field, slot2); + RETURN_IF_EXCEPTION(scope, false); + if (o2OwnsField && !(slot2.attributes() & PropertyAttribute::DontEnum)) { + continue; + } + JSValue left = o1->get(globalObject, field); + RETURN_IF_EXCEPTION(scope, false); + JSValue right = o2->get(globalObject, field); + RETURN_IF_EXCEPTION(scope, false); + bool equal = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, false); + if (!equal) return false; + } + + // An absent `cause` is not the same as `cause: undefined`. + const PropertyName cause(vm.propertyNames->cause); + bool o1HasCause = o1->hasOwnProperty(globalObject, cause); + RETURN_IF_EXCEPTION(scope, false); + bool o2HasCause = o2->hasOwnProperty(globalObject, cause); + RETURN_IF_EXCEPTION(scope, false); + return o1HasCause == o2HasCause; +} + // node compares the non-index own properties of typed arrays as well; // only the node entry point (checkPrototypes) pays for this. template static bool nonIndexOwnPropertiesEqual(JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSC::JSObject* o1, JSC::JSObject* o2) { VM& vm = globalObject->vm(); - JSC::PropertyNameArrayBuilder a1(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); - JSC::PropertyNameArrayBuilder a2(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); + // node's loose mode enumerates with SKIP_SYMBOLS; its strict mode does not. + constexpr PropertyNameMode kPropertyNameMode = (!isStrict && checkPrototypes) ? PropertyNameMode::Strings : PropertyNameMode::StringsAndSymbols; + JSC::PropertyNameArrayBuilder a1(vm, kPropertyNameMode, PrivateSymbolMode::Exclude); + JSC::PropertyNameArrayBuilder a2(vm, kPropertyNameMode, PrivateSymbolMode::Exclude); o1->getOwnNonIndexPropertyNames(globalObject, a1, DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(scope, false); o2->getOwnNonIndexPropertyNames(globalObject, a2, DontEnumPropertiesMode::Exclude); @@ -726,6 +783,13 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return false; } + // node's loose mode (`assert.deepEqual`): node semantics everywhere except + // that non-objects compare with `==`, prototypes are not compared, and + // symbol-keyed properties are ignored. node's strict mode is + // `isStrict && checkPrototypes`; Bun's own comparators never set + // checkPrototypes and are unaffected by every `kNodeLoose` branch below. + constexpr bool kNodeLoose = !isStrict && checkPrototypes; + // need to check this before primitives, asymmetric matchers // can match against any type of value. if constexpr (enableAsymmetricMatchers) { @@ -763,6 +827,23 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if (v1.isEmpty() || v2.isEmpty()) return v1.isEmpty() == v2.isEmpty(); + // node's loose mode splits on `typeof x !== 'object'`, which counts + // callables as non-objects, and compares two non-objects with `==` (plus + // NaN == NaN). Only node's `assert.deepEqual` behaves this way; Bun's own + // loose comparison stays value-identity based. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/comparisons.js#L152-L167 + if constexpr (kNodeLoose) { + bool v1IsObject = v1.isObject() && !v1.isCallable(); + bool v2IsObject = v2.isObject() && !v2.isCallable(); + if (!v1IsObject || !v2IsObject) { + if (v1IsObject != v2IsObject) return false; + bool looselyEqual = JSValue::equal(globalObject, v1, v2); + RETURN_IF_EXCEPTION(scope, false); + if (looselyEqual) return true; + return v1.isNumber() && v2.isNumber() && v1.asNumber() != v1.asNumber() && v2.asNumber() != v2.asNumber(); + } + } + if (v1.isPrimitive() || v2.isPrimitive()) return false; @@ -799,8 +880,9 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, // Node's deepStrictEqual compares [[Prototype]]s with ===. Only the // node:assert/node:util entry point does this; Bun.deepEquals and - // expect() keep their prototype-blind semantics. - if constexpr (checkPrototypes && !skipPrototypeIdentity) { + // expect() keep their prototype-blind semantics. node's loose mode does + // not compare prototypes at all, only the object tag (checked below). + if constexpr (isStrict && checkPrototypes && !skipPrototypeIdentity) { JSObject* protoCheck1 = v1.getObject(); JSObject* protoCheck2 = v2.getObject(); if (protoCheck1 && protoCheck2) { @@ -814,6 +896,22 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } } + // node's loose mode does not compare prototypes, but it does compare + // Object.prototype.toString tags, which is what separates an Arguments + // object from a plain object with the same keys. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/comparisons.js#L184-L196 + if constexpr (kNodeLoose) { + JSString* tag1 = JSC::objectPrototypeToString(globalObject, v1); + RETURN_IF_EXCEPTION(scope, false); + JSString* tag2 = JSC::objectPrototypeToString(globalObject, v2); + RETURN_IF_EXCEPTION(scope, false); + bool sameTag = JSValue::strictEqual(globalObject, tag1, tag2); + RETURN_IF_EXCEPTION(scope, false); + if (!sameTag) { + return false; + } + } + if constexpr (checkPrototypes) { // Distinct WeakMaps, WeakSets and Promises are never equal - their // contents cannot be inspected. node tests this on the LEFT operand @@ -835,6 +933,27 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, JSObject* o1 = v1.getObject(); JSObject* o2 = v2.getObject(); + // node treats every object with %Error.prototype% in its chain as an error, + // not just real Error instances (which specialObjectsDequal handled above), + // and compares their non-enumerable message/name/cause/errors. + if constexpr (checkPrototypes) { + if (o1 && o2 && c1->type() != ErrorInstanceType) { + bool leftIsErrorLike = inheritsFromErrorPrototype(globalObject, scope, o1); + RETURN_IF_EXCEPTION(scope, false); + if (leftIsErrorLike) { + bool rightIsErrorLike = c2->type() == ErrorInstanceType; + if (!rightIsErrorLike) { + rightIsErrorLike = inheritsFromErrorPrototype(globalObject, scope, o2); + RETURN_IF_EXCEPTION(scope, false); + } + if (!rightIsErrorLike) return false; + bool fieldsEqual = errorLikeFieldsEqual(globalObject, gcBuffer, stack, scope, o1, o2); + RETURN_IF_EXCEPTION(scope, false); + if (!fieldsEqual) return false; + } + } + } + bool v1Array = isArray(globalObject, v1); RETURN_IF_EXCEPTION(scope, false); bool v2Array = isArray(globalObject, v2); @@ -849,7 +968,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, size_t array1Length = array1->length(); size_t array2Length = array2->length(); - if constexpr (isStrict) { + if constexpr (isStrict || kNodeLoose) { if (array1Length != array2Length) { return false; } @@ -862,16 +981,14 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, JSValue right = getIndexWithoutAccessors(globalObject, o2, i); RETURN_IF_EXCEPTION(scope, false); - if constexpr (isStrict) { + if constexpr (isStrict || kNodeLoose) { if (left.isEmpty() && right.isEmpty()) { continue; } if (left.isEmpty() || right.isEmpty()) { return false; } - } - - if constexpr (!isStrict) { + } else { if (((left.isEmpty() || right.isEmpty()) && (left.isUndefined() || right.isUndefined()))) { continue; } @@ -895,10 +1012,15 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, JSC::PropertyNameArrayBuilder a1(vm, PropertyNameMode::Symbols, PrivateSymbolMode::Exclude); JSC::PropertyNameArrayBuilder a2(vm, PropertyNameMode::Symbols, PrivateSymbolMode::Exclude); - JSObject::getOwnPropertyNames(o1, globalObject, a1, DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(scope, false); - JSObject::getOwnPropertyNames(o2, globalObject, a2, DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(scope, false); + // node's loose mode enumerates with SKIP_SYMBOLS, so symbol-keyed + // properties are invisible to it. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/comparisons.js#L272-L277 + if constexpr (!kNodeLoose) { + JSObject::getOwnPropertyNames(o1, globalObject, a1, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, false); + JSObject::getOwnPropertyNames(o2, globalObject, a2, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, false); + } size_t propertyLength = a1.size(); if constexpr (isStrict) { @@ -922,7 +1044,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, JSValue prop2 = o2->getIfPropertyExists(globalObject, propertyName1); RETURN_IF_EXCEPTION(scope, false); - if constexpr (!isStrict) { + if constexpr (!isStrict && !kNodeLoose) { if (prop1.isUndefined() && prop2.isEmpty()) { continue; } @@ -960,7 +1082,10 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } JSC::Structure* o1Structure = o1->structure(); - if (!o1Structure->hasNonReifiedStaticProperties() && o1Structure->canPerformFastPropertyEnumeration()) { + // The structure walk below reads every non-DontEnum slot, symbols + // included; node's loose mode ignores symbol keys, so it takes the + // general path where the enumeration mode can be restricted. + if (!kNodeLoose && !o1Structure->hasNonReifiedStaticProperties() && o1Structure->canPerformFastPropertyEnumeration()) { JSC::Structure* o2Structure = o2->structure(); // The mixed-structure fast path resolves properties with getDirect(), // which also finds non-enumerable ones node ignores; the node entry @@ -1078,8 +1203,11 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } } - JSC::PropertyNameArrayBuilder a1(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); - JSC::PropertyNameArrayBuilder a2(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); + // node's loose mode enumerates with SKIP_SYMBOLS. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/comparisons.js#L493-L505 + constexpr PropertyNameMode kPropertyNameMode = kNodeLoose ? PropertyNameMode::Strings : PropertyNameMode::StringsAndSymbols; + JSC::PropertyNameArrayBuilder a1(vm, kPropertyNameMode, PrivateSymbolMode::Exclude); + JSC::PropertyNameArrayBuilder a2(vm, kPropertyNameMode, PrivateSymbolMode::Exclude); if constexpr (checkPrototypes) { // node compares own enumerable properties only; getPropertyNames also // collects enumerable properties from the prototype chain. @@ -1096,7 +1224,9 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, const size_t propertyArrayLength1 = a1.size(); const size_t propertyArrayLength2 = a2.size(); - if constexpr (isStrict) { + // node requires the same number of own enumerable keys in both modes. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/comparisons.js#L487-L491 + if constexpr (isStrict || kNodeLoose) { if (propertyArrayLength1 != propertyArrayLength2) { return false; } @@ -1132,7 +1262,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, RETURN_IF_EXCEPTION(scope, false); } - if constexpr (!isStrict) { + if constexpr (!isStrict && !kNodeLoose) { if (prop1.isUndefined() && prop2.isEmpty()) { continue; } @@ -1462,6 +1592,27 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark return false; } + // `.errors` (AggregateError) is non-enumerable too. node compares it + // whenever it is not an own enumerable property of the right-hand + // side, in which case the enumerable walk below covers it. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/comparisons.js#L392-L400 + const PropertyName errorsName(vm.propertyNames->errors); + PropertySlot rightErrorsSlot(right, PropertySlot::InternalMethodType::GetOwnProperty); + bool rightOwnsEnumerableErrors = right->methodTable()->getOwnPropertySlot(right, globalObject, errorsName, rightErrorsSlot) + && !(rightErrorsSlot.attributes() & PropertyAttribute::DontEnum); + RETURN_IF_EXCEPTION(scope, {}); + if (!rightOwnsEnumerableErrors) { + auto leftErrors = left->get(globalObject, errorsName); + RETURN_IF_EXCEPTION(scope, {}); + auto rightErrors = right->get(globalObject, errorsName); + RETURN_IF_EXCEPTION(scope, {}); + bool errorsEqual = Bun__deepEquals(globalObject, leftErrors, rightErrors, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, {}); + if (!errorsEqual) { + return false; + } + } + // check arbitrary enumerable properties. `.stack` is not checked. left->materializeErrorInfoIfNeeded(vm); RETURN_IF_EXCEPTION(scope, {}); @@ -3006,6 +3157,15 @@ bool Bun__deepEqualsNodeStrictSkipProto(JSC::EncodedJSValue JSValue0, JSC::Encod return deepEqualsWrapperImpl(JSValue0, JSValue1, globalObject); } +// node:assert deepEqual / notDeepEqual: node's loose mode. Same rules as +// Bun__deepEqualsNodeStrict except that non-objects compare with `==`, +// [[Prototype]]s are not compared and symbol keys are ignored. This is a +// different relation from Bun.deepEquals(a, b, false), which expect() uses. +bool Bun__deepEqualsNodeLoose(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* globalObject) +{ + return deepEqualsWrapperImpl(JSValue0, JSValue1, globalObject); +} + #undef IMPL_DEEP_EQUALS_WRAPPER bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* globalObject, bool replacePropsWithAsymmetricMatchers) diff --git a/src/jsc/modules/NodeUtilTypesModule.cpp b/src/jsc/modules/NodeUtilTypesModule.cpp index 1fefd9eae275..fca14527330d 100644 --- a/src/jsc/modules/NodeUtilTypesModule.cpp +++ b/src/jsc/modules/NodeUtilTypesModule.cpp @@ -889,6 +889,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionPartialDeepStrictEqual, extern "C" bool Bun__deepEqualsNodeStrict(JSC::EncodedJSValue a, JSC::EncodedJSValue b, JSC::JSGlobalObject* globalObject); extern "C" bool Bun__deepEqualsNodeStrictSkipProto(JSC::EncodedJSValue a, JSC::EncodedJSValue b, JSC::JSGlobalObject* globalObject); +extern "C" bool Bun__deepEqualsNodeLoose(JSC::EncodedJSValue a, JSC::EncodedJSValue b, JSC::JSGlobalObject* globalObject); // util.isDeepStrictEqual / assert.deepStrictEqual: node semantics, including // the [[Prototype]] identity check that Bun.deepEquals(a, b, true) omits. @@ -908,6 +909,17 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionIsDeepStrictEqual, return JSValue::encode(jsBoolean(result)); } +// assert.deepEqual / assert.notDeepEqual: node's loose mode. +JSC_DEFINE_HOST_FUNCTION(jsFunctionIsDeepEqual, + (JSC::JSGlobalObject * globalObject, + JSC::CallFrame* callframe)) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + bool result = Bun__deepEqualsNodeLoose(JSValue::encode(callframe->argument(0)), JSValue::encode(callframe->argument(1)), globalObject); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsBoolean(result)); +} + JSC_DEFINE_HOST_FUNCTION(jsFunctionIsError, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) diff --git a/src/jsc/modules/NodeUtilTypesModule.h b/src/jsc/modules/NodeUtilTypesModule.h index 74d4359f158f..1c6d30e82edb 100644 --- a/src/jsc/modules/NodeUtilTypesModule.h +++ b/src/jsc/modules/NodeUtilTypesModule.h @@ -13,6 +13,10 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionIsDeepStrictEqual, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)); +JSC_DEFINE_HOST_FUNCTION(jsFunctionIsDeepEqual, + (JSC::JSGlobalObject * globalObject, + JSC::CallFrame* callframe)); + JSC_DEFINE_HOST_FUNCTION(jsFunctionPartialDeepStrictEqual, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)); From 280e3d69f257349227a602b65b172669d4efcff8 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 05:25:42 +0000 Subject: [PATCH 2/3] assert: pair set and map entries off instead of matching them 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 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. --- src/jsc/bindings/bindings.cpp | 132 +++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 24 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index e179fbadb827..68c7eba5b508 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -1313,33 +1313,83 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark return false; } - auto iter1 = JSSetIterator::create(vm, globalObject->setIteratorStructure(), set1, IterationKind::Keys); - RETURN_IF_EXCEPTION(scope, {}); - JSValue key1; - while (iter1->next(globalObject, key1)) { - bool has = set2->has(globalObject, key1); + // node's setEquiv pairs the two sets off: an element of set2 consumes + // the element of set1 it matched, so Set([{a:1},{a:1}]) is not equal to + // Set([{a:1},{a:2}]) even though every element of the first has some + // partner in the second. Only the node entry point pays for the + // matching pass; Bun.deepEquals keeps its "has a partner" rule. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/comparisons.js#L677-L719 + if constexpr (checkPrototypes) { + // Elements stay rooted by set1 for as long as it is alive, which it + // is: c1 is reachable from the caller's gcBuffer. + Vector unpaired; + auto iter1 = JSSetIterator::create(vm, globalObject->setIteratorStructure(), set1, IterationKind::Keys); RETURN_IF_EXCEPTION(scope, {}); - if (has) { - continue; + JSValue key1; + while (iter1->next(globalObject, key1)) { + if (!key1.isObject()) { + bool has = set2->has(globalObject, key1); + RETURN_IF_EXCEPTION(scope, {}); + if (has) continue; + if constexpr (isStrict) return false; + } + unpaired.append(key1); } - // We couldn't find the key in the second set. This may be a false positive due to how - // JSValues are represented in JSC, so we need to fall back to a linear search to be sure. - auto iter2 = JSSetIterator::create(vm, globalObject->setIteratorStructure(), set2, IterationKind::Keys); - RETURN_IF_EXCEPTION(scope, {}); - JSValue key2; - bool foundMatchingKey = false; - while (iter2->next(globalObject, key2)) { - bool equal = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); + if (!unpaired.isEmpty()) { + auto iter2 = JSSetIterator::create(vm, globalObject->setIteratorStructure(), set2, IterationKind::Keys); RETURN_IF_EXCEPTION(scope, {}); - if (equal) { - foundMatchingKey = true; - break; + JSValue key2; + while (iter2->next(globalObject, key2)) { + if (!key2.isObject()) { + if constexpr (isStrict) continue; + bool has = set1->has(globalObject, key2); + RETURN_IF_EXCEPTION(scope, {}); + if (has) continue; + } + bool paired = false; + for (size_t i = 0; i < unpaired.size(); i++) { + bool equal = Bun__deepEquals(globalObject, unpaired[i], key2, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, {}); + if (equal) { + unpaired.removeAt(i); + paired = true; + break; + } + } + if (!paired) return false; } + if (!unpaired.isEmpty()) return false; } + } else { + auto iter1 = JSSetIterator::create(vm, globalObject->setIteratorStructure(), set1, IterationKind::Keys); + RETURN_IF_EXCEPTION(scope, {}); + JSValue key1; + while (iter1->next(globalObject, key1)) { + bool has = set2->has(globalObject, key1); + RETURN_IF_EXCEPTION(scope, {}); + if (has) { + continue; + } - if (!foundMatchingKey) { - return false; + // We couldn't find the key in the second set. This may be a false positive due to how + // JSValues are represented in JSC, so we need to fall back to a linear search to be sure. + auto iter2 = JSSetIterator::create(vm, globalObject->setIteratorStructure(), set2, IterationKind::Keys); + RETURN_IF_EXCEPTION(scope, {}); + JSValue key2; + bool foundMatchingKey = false; + while (iter2->next(globalObject, key2)) { + bool equal = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); + RETURN_IF_EXCEPTION(scope, {}); + if (equal) { + foundMatchingKey = true; + break; + } + } + + if (!foundMatchingKey) { + return false; + } } } @@ -1362,6 +1412,44 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark return false; } + // As with sets, node pairs entries off: a key of map2 consumes the + // map1 entry it matched, so two entries with deep-equal-but-distinct + // object keys cannot both match the same partner. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/comparisons.js#L721-L787 + if constexpr (checkPrototypes) { + // Entries stay rooted by map1, which the caller's gcBuffer keeps alive. + Vector, 8> unpaired; + auto iterLeft = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), map1, IterationKind::Entries); + RETURN_IF_EXCEPTION(scope, {}); + JSValue leftKey, leftValue; + while (iterLeft->nextKeyValue(globalObject, leftKey, leftValue)) { + unpaired.append({ leftKey, leftValue }); + } + + auto iterRight = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), map2, IterationKind::Entries); + RETURN_IF_EXCEPTION(scope, {}); + JSValue rightKey, rightValue; + while (iterRight->nextKeyValue(globalObject, rightKey, rightValue)) { + bool paired = false; + for (size_t i = 0; i < unpaired.size(); i++) { + bool keysEqual = Bun__deepEquals(globalObject, unpaired[i].first, rightKey, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, {}); + if (!keysEqual) continue; + bool valuesEqual = Bun__deepEquals(globalObject, unpaired[i].second, rightValue, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, {}); + if (!valuesEqual) continue; + unpaired.removeAt(i); + paired = true; + break; + } + if (!paired) return false; + } + if (!unpaired.isEmpty()) return false; + + // node also compares own enumerable properties of Maps. + break; + } + auto iter1 = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), map1, IterationKind::Entries); RETURN_IF_EXCEPTION(scope, {}); JSValue key1, value1; @@ -1399,10 +1487,6 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark } } - if constexpr (checkPrototypes) { - // node also compares own enumerable properties of Maps. - break; - } return true; } case ArrayBufferType: { From d1e9ec58a57151774d24df41b7b9a129444ac274 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 24 Jul 2026 05:28:00 +0000 Subject: [PATCH 3/3] assert: drop stray blank line --- src/js/node/assert.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/js/node/assert.ts b/src/js/node/assert.ts index dc1527192c81..5cb3ec8c8f44 100644 --- a/src/js/node/assert.ts +++ b/src/js/node/assert.ts @@ -48,7 +48,6 @@ const kOptions = Symbol("options"); const { isDeepStrictEqual, isDeepEqual } = require("internal/util/comparisons"); - var _inspect; function lazyInspect() { if (_inspect === undefined) {