Skip to content

util: node v26.3.0 compat — styleText hex/validateStream, getCallSites, regexp highlighting, vm namespace, tty.WriteStream + deepEquals fixes (test-util 70% → 83%) - #34434

Merged
dylan-conway merged 19 commits into
mainfrom
claude/node-util-v26-compat
Jul 24, 2026

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 17, 2026

Copy link
Copy Markdown
Member

What

Brings node:util closer to node v26.3.0 and fixes several Bun.deepEquals bugs the newly ported tests exposed.

Fixes #33074
Fixes #25736
Fixes #20129

test-util-* compatibility vs node v26.3.0

before after
passing 21 / 30 (70%) 25 / 30 (83%)

+3 tests added (verbatim from v26.3.0), +1 updated, plus new coverage in bun's own deep-equality matrix and util tests.

Beyond node:util, this also fixes two bugs the porting turned up: tty.WriteStream throwing on a read-only fd, and vm module namespaces having a non-null prototype.

Overlaps four open PRs (#30985, #33080, #32872, #29037) — see this comment. Happy to drop the overlapping areas or close in favour of them.

Fixes

util.styleText was still on the pre-v26 API. Ported node v26.3.0's version: hex/RGB colors (#RGB/#RRGGBB), the none format, nested close-code handling, and { validateStream, stream } — colour is suppressed when the target stream isn't a TTY.

New APIs

  • util.getCallSites(frameCount, { sourceMap }) — captures through a private prepareStackTrace, so a user-installed Error.prepareStackTrace is never invoked and Error.stackTraceLimit doesn't bound the result. CallSite gains getScriptId. Column numbers and the column alias verified against the node v26.3.0 binary.
  • util.convertProcessSignalToExitCode(signal)
  • util.isDeepStrictEqual(a, b, skipPrototype) — node v26's third argument, threaded through Bun__deepEquals as a template parameter.

util.inspect

  • Regexes are syntax-highlighted by group depth (ported v26's highlightRegExp), replacing the flat red style — removing the TODO(BridgeAR): Highlight regular expressions properly bun inherited from node.
  • hasBuiltInToString rejected any Symbol.toPrimitive, but Date.prototype[Symbol.toPrimitive] is built-in, so util.format('%s', date) printed the toString form where node prints inspect's ISO form. Ported v26's version, which distinguishes own from inherited.
  • extraKeys — ported v26's mechanism replacing unshift(keys, …). Those entries are getters, so node brackets them: ArrayBuffer { [Uint8Contents]: <..>, [byteLength]: 4 }, and showHidden on a typed array reports [BYTES_PER_ELEMENT], [length], [byteLength], [byteOffset], [buffer].
  • vm module namespaces get a null prototype. A Module Namespace Exotic Object is specified to have [[Prototype]] = null; bun points the shared structure at an object carrying an __esModule accessor for CJS interop. Nulled in NodeVMModule::namespaceObject only, so real ESM namespaces keep the accessor.

Bun.deepEquals correctness (strict mode)

  • boxed Strings ignored extra own properties
  • boxed Symbols/BigInts are plain ObjectType in JSC and never reached the type switch, so two different boxed Symbols compared equal
  • the fast path fetched the right-hand property without checking enumerability
  • typed arrays skipped own non-index properties (e.g. symbols)

Loose mode is not untouched (an earlier revision of this description wrongly said so). Three loose-visible cases moved onto node's behaviour, and one regression was caught in review and gated back:

loose case node bun before bun now
Object(1n) vs Object(2n) false true false
Object(Symbol()) vs {} false true false
Object(Symbol('a')) vs Object(Symbol('a')) false true false
enumerable sym vs non-enumerable true true true

Perf

Both strict fall-throughs are gated on the structure actually carrying named properties. Elements and characters are synthesized by getOwnPropertySlot rather than stored in the structure, so an unguarded fall-through lands in the index-enumerating slow path. Measured before the guard: new String('a'.repeat(100000)) took 2,004,127µs/op and a 1KB Uint8Array 4264µs/op, both linear. With the guard both are flat (~5µs).

Verification

  • A/B against a clean build of the same base commit, using the runner's own dispatch (bun test for files containing node:test, bun run otherwise).
  • Every expectation checked against a real node v26.3.0 binary, not read off the source. Two review findings (a TypeError from a primed style cache, and an empty regexp palette) reproduce identically on node and are deliberately left as faithful ports.
  • Regression sweep: test-assert-* / test-buffer-* / test-console-*, buffer.test.js (617), the 271-case deep-equality matrix, bun's expect suites, and 97/97 node vm tests. oxlint clean.

Notes for reviewers

test-util-styletext{,-hex}.js need the real environment. They assert styleText's own colour decisions against a TTY. Deleting the runner's forced FORCE_COLOR=0/NO_COLOR=1 wasn't enough — spawnBun sets FORCE_COLOR=1 in its own base env, which reached the child. The three colour variables are now set to undefined (which child_process drops) for those two files. With no colour variables set, bun matches node on all eight TTY cases.

Known gaps (deliberate)

  • test-util-inspect.js--expose-internals; needs internalBinding('js_stream').JSStream, whose _externalStream must be a real napi external. It gets ~145 assertions in before that line.
  • test-util-format.js — one assertion: [Foo: null prototype]. V8 keeps the constructor on the object's map; JSC has no equivalent. I tried recovering it from JSC's structure transition chain — previousID() yields nothing usable after a prototype change (verified across setPrototypeOf, __proto__ =, and Reflect.setPrototypeOf), so this needs a WebKit-side change. Everything else in the file passes.
  • test-util-isDeepStrictEqual.js — needs BufferUint8Array in strict mode. That's correct (node rejects them, and the matrix pins bun's answer as a known bug), but enabling it turns test-child-process-advanced-serialization.js red: bun's advanced IPC is backed by SerializedScriptValue, which downgrades Buffer to Uint8Array, so that test currently passes only because two bugs cancel out:
    bun:  received.buffer → Uint8Array, isBuffer: false
    node: received.buffer → Buffer,     isBuffer: true
    
    Preserving Buffer through structured clone belongs in its own change.
  • test-util-getcallsites.js — asserts getCallSites().length > 1 at module scope, which holds in node only because node wraps modules in JS functions (8 frames vs bun's 1; bun's loader is native). Not portable. getCallSites keeps real coverage in test-util-getcallsites-preparestacktrace.js.
  • getCallSites's sourceMap option is validated but not applied.

no test proof · iteration 11 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js

Brings node:util closer to node v26.3.0 and fixes several deepEquals bugs
found by the newly ported tests.

util.styleText was still on the pre-v26 API. Port node v26.3.0's version:
hex/RGB colors (#RGB and #RRGGBB), the "none" format, nested close-code
handling, and the { validateStream, stream } option, which suppresses color
when the target stream is not a TTY.

Add util.getCallSites (capturing through a private prepareStackTrace so a
user-installed Error.prepareStackTrace is never invoked and
Error.stackTraceLimit does not bound the result) and
util.convertProcessSignalToExitCode. CallSite gains getScriptId, backed by
the source ID the stack frame already carries.

Port node v26.3.0's highlightRegExp so util.inspect syntax-highlights
regular expressions by group depth, replacing the flat "red" style.

Bun.deepEquals fixes, all strict-mode only so loose comparison
(expect().toEqual) is unchanged:
- boxed Strings ignored extra own properties, so Object('test') with an
  added property compared equal to a plain Object('test')
- boxed Symbols and BigInts are plain ObjectType in JSC, so two different
  boxed Symbols compared equal on properties alone
- the fast path fetched properties from the right-hand side without
  checking enumerability, so a non-enumerable property satisfied an
  enumerable one
- typed arrays never reached the constructor check, so Buffer and
  Uint8Array with equal bytes compared equal
- typed arrays skipped own non-index properties (e.g. symbols)

The typed-array property walk is gated on the structure actually carrying
named properties: elements live in the vector, so an unconditional
fall-through would enumerate every index and make each comparison
O(elements).

util.isDeepStrictEqual takes node v26's third skipPrototype argument,
threaded through Bun__deepEquals as a template parameter.

Tests: add test-util-{convert-signal-to-exit-code,getcallsites,
getcallsites-preparestacktrace,inspect-regexp,isDeepStrictEqual,
styletext-hex} from v26.3.0 verbatim, and update test-util-styletext.js,
which was a stale pre-v26 copy asserting that styleText colors
unconditionally — v26 defaults validateStream to true.
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator
Updated 9:05 PM PT - Jul 23rd, 2026

@cirospaciari, your commit 7b1d0c7 has some failures in Build #79197 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 34434

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

bun-34434 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. isDeepStrictEqual from node:util evaluates wrong when specifying a third argument #33074 - PR adds the third skipPrototype argument to util.isDeepStrictEqual, which is exactly what this issue reports as broken
  2. util.styleText() doesn't respect isTTY property. #25736 - PR's styleText rewrite adds stream-aware colorization that respects the isTTY property on the { stream } option
  3. Bun does not respect NO_COLOR, etc. environment variables on node:util.styleText #20129 - PR's styleText rewrite adds respect for NO_COLOR, NODE_DISABLE_COLORS, and FORCE_COLOR environment variables

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

Fixes #33074
Fixes #25736
Fixes #20129

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. util: make styleText color-aware and validate options (Node parity) #30985 - Also implements styleText Node parity: { validateStream, stream } options, 'none' format, and nested-reset handling
  2. util: honor isDeepStrictEqual skipPrototype third argument #33080 - Also adds the third skipPrototype argument to util.isDeepStrictEqual, threading it through Bun__deepEquals
  3. Bun.deepEquals: fix four false-positive equality classes #32872 - Also fixes the same deepEquals false-positive classes: boxed String own properties, enumerability checks, typed array non-index properties
  4. Compare prototypes in deepStrictEqual #29037 - Also fixes prototype-identity comparison in deepStrictEqual

🤖 Generated with Claude Code

…tes columns

The strict boxed-String fall-through sent plain boxed strings into the
index-enumerating slow path: comparing two new String('a'.repeat(100000))
took 2.0s (1000 chars took 21ms, scaling linearly) where it had been a
single string compare. StringObject overrides getOwnPropertySlot, so
canPerformFastPropertyEnumeration() is false and the property walk
enumerates every character. Guard the fall-through on the structure
actually carrying named properties, the same way the typed-array case
does, and share the check as hasNamedOwnProperties(). Back to 7.6us.

getStylizeWithColor, the stylize handed to [util.inspect.custom]
implementations, still only resolved color names, so a custom inspector
asking for the 'regexp' style got plain text while the default formatter
highlighted it. Check for a function style there too, and check it before
the inspect.colors lookup in both stylizers: the lookup stringifies
highlightRegExp's source into a property key otherwise.

getCallSites reported columnNumber one less than node (bun's
CallSite#getColumnNumber() is 0-based, V8's is 1-based) and omitted node's
`column` alias. Verified against the node v26.3.0 binary.

Restore two primordials the port dropped: node guards the hex color regexp
with RegExpPrototypeExec and backs the hex style cache with a SafeMap, so
userland cannot break hex validation by patching RegExp.prototype.exec, or
misdirect the LRU eviction by patching Map.prototype.keys/size.

Also honor skipPrototype for boxed String subclasses (a String subclass is
DerivedStringObjectType, which the case rejected before comparing values;
node returns true there), add the missing RETURN_IF_EXCEPTION after the
swapped specialObjectsDequal call now that it can throw, pair
outOfLineSize() with inlineSize() as the existing idiom in this file does,
and move the typed-array constructor check after the memcmp so unequal
arrays don't pay two calculatedClassName allocations.
@cirospaciari
cirospaciari marked this pull request as ready for review July 17, 2026 01:45
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

The PR expands Node utility APIs, adds prototype-skipping deep equality, introduces token-level regexp inspection and shared metadata formatting, updates VM namespace behavior, and adds WriteStream fallback handling with corresponding tests.

Node utility APIs

Layer / File(s) Summary
Style text formatting and color configuration
src/js/node/util.ts, test/js/node/test/parallel/test-util-styletext.js, test/js/node/util/util.test.js, scripts/runner.node.mjs
styleText now supports stream options, named and hex colors, nested ANSI formatting, and expanded validation and TTY tests.
Call-site capture and script identifiers
src/js/node/util.ts, src/jsc/bindings/CallSite.*, src/jsc/bindings/CallSitePrototype.cpp, test/js/node/test/parallel/test-util-getcallsites-preparestacktrace.js
Call-site source IDs are stored and exposed through getScriptId, while getCallSites captures normalized frames without invoking Error.prepareStackTrace.
Process signal conversion
src/js/node/util.ts, test/js/node/test/parallel/test-util-convert-signal-to-exit-code.mjs
convertProcessSignalToExitCode is exported and tested for valid signals, invalid values, and terminated child processes.
VM module namespace prototype
src/jsc/bindings/NodeVMModule.cpp, test/js/node/test/parallel/test-util-inspect-namespace.js
VM module namespace objects use a null prototype, with inspection tested before and after module evaluation.
WriteStream fast-path fallback
src/js/internal/fs/streams.ts
Fast writer creation is caught so stream construction falls back when the fast path cannot be initialized.

Deep equality behavior

Layer / File(s) Summary
Prototype-skipping equality contract
src/js/node/util.ts, src/jsc/bindings/BunObject.cpp, src/jsc/bindings/headers-handwritten.h, src/jsc/bindings/bindings.cpp, test/js/node/assert/deep-equal.test.ts
isDeepStrictEqual accepts skipPrototype, forwarding it through the native template and suppressing strict prototype checks when enabled.
Recursive and special-value comparisons
src/jsc/bindings/bindings.cpp, test/js/node/assert/deep-equal.test.ts, test/js/node/buffer.test.js
Recursive comparisons propagate skipPrototype; typed arrays, boxed strings, symbols, BigInts, maps, sets, errors, enumerability, and buffer assertions receive updated behavior and coverage.

Regular expression and object inspection

Layer / File(s) Summary
Regexp highlighting and validation
src/js/internal/util/inspect.js, src/js/builtins/UtilInspect.ts, test/js/node/test/parallel/test-util-inspect-regexp.js
Regexp inspection parses groups, classes, escapes, delimiters, and flags into palette-driven ANSI output with configurable fallback colors.
Typed-array and buffer-view inspection
src/js/internal/util/inspect.js, test/js/node/util/node-inspect-tests/parallel/*
Typed-array and DataView metadata is formatted through shared extra-property handling, producing bracketed metadata keys and updated inspection expectations.
Built-in string detection
src/js/internal/util/inspect.js
hasBuiltInToString restructures ownership and prototype-chain checks using early false returns.

Possibly related PRs

  • oven-sh/bun#33080: Updates the same deep-equality forwarding path for the skipPrototype argument.

Suggested reviewers: dylan-conway, sosukesuzuki, alii, robobun

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main compatibility and util fixes in the change set.
Description check ✅ Passed The description covers the PR purpose and verification steps, though the headings don't exactly match the template.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/internal/util/inspect.js`:
- Around line 959-963: Cache regexpString.length in a local variable at the
start of highlightRegExp, then replace all repeated regexpString.length accesses
within the parser, including the checks near the referenced locations, with that
cached value.
- Around line 969-976: Update the palette construction and write() flow around
paletteNames and palette so palettes containing only unknown color names cannot
cause an undefined dereference. When no entries resolve through inspect.colors,
fall back to the valid default palette or return the uncolored string, while
preserving custom valid colors and existing colored output behavior.
- Line 880: Update the highlighter around highlightRegExpColors to use the
captured slice intrinsic instead of the palette’s .slice method, replace the
for...of palette construction with indexed traversal, and guard
inspect.colors[style] access by first checking typeof style === "function".
- Around line 873-878: Update the color stylizer around the inspect style
handling and its corresponding UtilInspect typing/imports to support
function-valued inspect.styles entries. Invoke a style function with the
inspected value, while preserving existing inspect.colors lookup for
string-valued styles, so inspect.styles.regexp uses highlightRegExp instead of
plain text.

In `@src/js/node/util.ts`:
- Around line 423-429: In the frameCount options handling and the corresponding
second branch, read options.sourceMap once into a local variable before
validation, then use that variable for the undefined check and validateBoolean
call. Preserve the existing validation behavior while avoiding repeated property
access and getter invocation.
- Around line 420-458: Update getCallSites to apply options.sourceMap when
preparing and returning call sites, rather than only validating it. Ensure true
and false explicitly override the global source-map setting, while an omitted
option preserves the existing global behavior; keep frameCount validation and
stack-trace restoration unchanged.
- Around line 266-276: The color conversion path uses mutable prototype methods
instead of primordial-safe helpers. Update hexToRgb and the related color
parsing logic to use captured $ string/regular-expression helpers for slicing,
index lookup, and RegExp execution, and replace cache.keys().next().value with
cache.$keys(). Preserve the existing color parsing and error behavior.
- Around line 405-418: Update the call-site capture flow around prepareCallSites
to use Bun’s private/native capture mechanism instead of modifying
Error.prepareStackTrace or Error.stackTraceLimit. Ensure capture occurs without
changing public Error state, including when stackTraceLimit access throws, while
preserving prepareCallSites’ conversion of captured frames into the existing
result shape.

In `@src/jsc/bindings/bindings.cpp`:
- Around line 734-738: After the second swapped-argument specialObjectsDequal
call in the equality flow, add the same RETURN_IF_EXCEPTION(scope, false) check
used after the first call before evaluating isSpecialEqual.has_value().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 252b0ae6-ab92-4787-9fe4-f4f19f5a486c

📥 Commits

Reviewing files that changed from the base of the PR and between 8adb8de and 05fc2be.

📒 Files selected for processing (15)
  • src/js/internal/util/inspect.js
  • src/js/node/util.ts
  • src/jsc/bindings/BunObject.cpp
  • src/jsc/bindings/CallSite.cpp
  • src/jsc/bindings/CallSite.h
  • src/jsc/bindings/CallSitePrototype.cpp
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/headers-handwritten.h
  • test/js/node/test/parallel/test-util-convert-signal-to-exit-code.mjs
  • test/js/node/test/parallel/test-util-getcallsites-preparestacktrace.js
  • test/js/node/test/parallel/test-util-getcallsites.js
  • test/js/node/test/parallel/test-util-inspect-regexp.js
  • test/js/node/test/parallel/test-util-isDeepStrictEqual.js
  • test/js/node/test/parallel/test-util-styletext-hex.js
  • test/js/node/test/parallel/test-util-styletext.js

Comment thread src/js/internal/util/inspect.js
Comment thread src/js/internal/util/inspect.js
Comment thread src/js/internal/util/inspect.js
Comment thread src/js/internal/util/inspect.js
Comment thread src/js/node/util.ts
Comment thread src/js/node/util.ts
Comment thread src/js/node/util.ts
Comment thread src/js/node/util.ts Outdated
Comment thread src/jsc/bindings/bindings.cpp
…TY env

oxlint's no-duplicate-conditional-property-access flagged five reads in the
new code: hoist regexpString.length once in highlightRegExp, and read
options.sourceMap through a small validateSourceMapOption helper.

Three suites asserted the pre-v26 styleText behaviour, where colour was
emitted unconditionally. styleText now defaults validateStream to true, so
util.test.js opts out with { validateStream: false } the way node's own
test does. The vendored util-inspect test drove testColorStyle('regexp'),
which assumes every style is a colour name; node v26.3.0 dropped that call
when regexp became a function, so drop it here too, with node's comment.

test-util-styletext{,-hex}.js assert styleText's own colour decisions
against a TTY, which the runner's forced FORCE_COLOR=0/NO_COLOR=1 makes
unreachable — node would fail them too. Let those two files see the real
environment, the way the runner already special-cases other files.

Revert the Buffer/Uint8Array distinction and restore its pin in the
deep-equality matrix. It is correct (node rejects them, and the matrix
marks bun's answer as a known bug), but it surfaces a separate defect:
bun's advanced child_process serialization is backed by
SerializedScriptValue, which downgrades a Buffer to a Uint8Array, so
test-child-process-advanced-serialization.js only passes today because two
bugs cancel out. Preserving Buffer through structured clone belongs in its
own change. test-util-isDeepStrictEqual.js depends on that distinction, so
it goes with it.

Cover the fixes that remain in the deep-equality matrix instead: distinct
boxed symbols, unequal boxed bigints, a boxed string with an extra own
property, a non-enumerable symbol key against an enumerable one, typed
arrays differing only in a symbol property, and a skipPrototype block.
Every expectation was taken from the node v26.3.0 binary, and the typed
array with an extra own property is no longer a known strict-mode bug.
@cirospaciari cirospaciari changed the title util: +6 node v26.3.0 tests, 5 deepEquals/styleText fixes — test-util compat 70% → 90% (goal: 100%) util: +5 node v26.3.0 tests, styleText hex/validateStream, getCallSites, regexp highlighting, 4 deepEquals fixes — test-util compat 70% → 87% (goal: 100%) Jul 17, 2026

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/jsc/bindings/bindings.cpp:1630-1648 — The Symbol/BigInt wrapper check here is not gated by if constexpr (isStrict), so loose Bun.deepEquals / expect().toEqual now returns false for Object(1n) vs Object(2n) and Object(Symbol()) vs {} where it previously returned true. The same is true of the enumerability filter at lines 905-913. Both changes are arguably improvements (they match the existing Number/Boolean wrapper handling and fix a pre-existing asymmetry), but they contradict the PR description's claim that loose comparison is "byte-for-byte unchanged" — worth either gating on isStrict or updating the description and adding loose-mode coverage.

    Extended reasoning...

    What changed in loose mode

    The PR description states under Bun.deepEquals correctness: "all strict-mode only, so loose comparison (expect().toEqual) is byte-for-byte unchanged". Two of the new checks are not actually gated on isStrict, so they run for Bun.deepEquals(a, b) (loose) and therefore for expect().toEqual():

    1. Symbol/BigInt wrapper check (bindings.cpp:1630-1648) — the new post-switch block that unboxes SymbolObject/BigIntObject and compares internal values via sameValue. The three other new checks in this function (StringObject fallthrough, typed-array class-name, typed-array own-property fallthrough) are all wrapped in if constexpr (isStrict); this one is not.
    2. Enumerability filter in the fast path (bindings.cpp:905-913) — the replacement of o2->getDirect(vm, PropertyName(entry.key())) with a structure lookup that skips DontEnum properties. Also un-gated.

    Step-by-step: Symbol/BigInt wrapper (loose mode)

    Take Bun.deepEquals(Object(1n), Object(2n)) (no third arg → isStrict = false).

    Before this PR: both operands are plain ObjectType in JSC, so the switch in specialObjectsDequal hits default: break, returns std::nullopt, and control falls through to the generic property walk. Both wrappers have zero enumerable own properties, so they compare equal → true.

    After this PR: the switch still hits default: break, but the new block at 1630 runs: obj1->inherits<BigIntObject>() is true, obj2->inherits<BigIntObject>() is true, sameValue(1n, 2n) is false → false.

    Similarly, Bun.deepEquals(Object(Symbol()), {}) previously fell through to the property walk (both empty → true), but now hits the else if (obj2->inherits<SymbolObject>() || obj2->inherits<BigIntObject>()) branch on the second specialObjectsDequal call (with args swapped) → false.

    Step-by-step: enumerability filter (loose mode)

    Take o1 = {x: 1} and o2 = Object.defineProperty({}, 'x', {value: 1, enumerable: false}), both plain objects taking the fast path with different structures (the else branch at line 896).

    Before: forEachProperty(o1) visits x, left = 1, right = o2->getDirect(vm, 'x') = 1 (getDirect ignores enumerability), values match, count = 1. Reverse loop over o2 skips x (DontEnum). Result → true.

    After: o2Structure->get(vm, 'x', o2Attributes) returns a valid offset with DontEnum set, so right stays empty. left (1) is not undefined, so the !isStrict early-continue at 915-919 doesn't fire. !right at 921 → false.

    Note also that the slow path at lines ~987-1016 still uses o2->getIfPropertyExists(), which finds non-enumerable properties, so for objects that miss the fast path (e.g. accessors present) loose mode would still return true for this same input — the fast and slow paths now disagree.

    Why this is a nit rather than a blocker

    The new loose-mode results are more correct, not less:

    • The pre-existing NumberObjectType/BooleanObjectType case at lines 1611-1622 already compares internalValue() without an isStrict gate, so treating Symbol/BigInt wrappers the same way is consistent, and matches Jest (which unboxes via Object.is).
    • The old loose-mode enumerability behavior was asymmetric: with the same o1/o2 above, deepEquals(o1, o2) returned true but deepEquals(o2, o1) returned false (forward loop skipped o2's DontEnum x so count = 0; reverse loop found o1's enumerable x with remain == 0 → false). The new code makes both directions agree on false, which is what Jest's toEqual does (non-enumerable properties are invisible).

    So the code is defensible; only the PR description's "byte-for-byte unchanged" claim is inaccurate.

    Suggested action

    Either wrap both blocks in if constexpr (isStrict) { ... } to make the description true, or (probably better) drop the "byte-for-byte unchanged" claim from the description, note that loose mode also picks up the wrapper/enumerability fixes, and add a couple of loose-mode assertions (expect(Object(1n)).not.toEqual(Object(2n)), and the enumerable/non-enumerable pair) so the delta is covered. If keeping the loose-mode change, the slow path's getIfPropertyExists at ~1000 should also be aligned so fast and slow paths agree.

Comment thread test/js/node/test/parallel/test-util-getcallsites.js Outdated
Comment thread src/js/node/util.ts Outdated
Comment thread src/js/internal/util/inspect.js
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/js/node/util.ts
Comment thread src/js/node/util.ts Outdated
A Module Namespace Exotic Object is specified to have a null [[Prototype]].
Bun points the shared namespace structure at an object carrying an
__esModule accessor for CJS interop, which is fine for real imports but
leaves vm module namespaces inspecting as
"Module <[Object: null prototype] {}> [Module] { ... }" instead of node's
"[Module: null prototype] { ... }". Null the prototype in
NodeVMModule::namespaceObject so only vm modules are affected; real ESM
namespaces keep the accessor. Adds test-util-inspect-namespace.js.

hasBuiltInToString rejected any Symbol.toPrimitive, but
Date.prototype[Symbol.toPrimitive] is built-in, so util.format('%s', date)
printed the toString form where node prints inspect's ISO form. Port node
v26.3.0's version, which distinguishes an own Symbol.toPrimitive from an
inherited one.

Port node v26.3.0's extraKeys mechanism, replacing unshift(keys, ...) for
typed arrays, ArrayBuffer/SharedArrayBuffer and DataView. These entries are
getters rather than own properties, so node brackets them: ArrayBuffer now
inspects as "{ [Uint8Contents]: <..>, [byteLength]: 4 }", and showHidden on
a typed array reports [BYTES_PER_ELEMENT], [length], [byteLength],
[byteOffset] and [buffer].

The styletext tests assert styleText's own color decisions, so the runner
must not force color for them. Deleting the forced FORCE_COLOR=0/NO_COLOR=1
was not enough: spawnBun sets FORCE_COLOR=1 in its own base env, which then
reached the child and colored output the test expects plain. Set the three
color variables to undefined instead, which child_process drops. With no
color variables set, bun matches node v26.3.0 on all eight TTY cases.

buffer.test.js redefines `length` as an own enumerable property and then
compares the buffer to a plain one. Node reports those as deep-unequal for
exactly that reason (verified against the v26.3.0 binary), which bun now
does too, so compare the bytes instead — the test is about fill() not
aborting.
… requires

The enumerability filter in the fast path was not gated on strict, so loose
comparison started rejecting an enumerable property matched against a
non-enumerable one. Node accepts that pair in loose mode (verified against
the v26.3.0 binary), so gate the check and let loose keep matching either.
The three other loose-visible changes — distinct boxed symbols, unequal
boxed bigints, a boxed symbol against {} — all now agree with node, so they
stay; the matrix covers loose for each.

internal/util/colors runs refresh() at load, touching process.stderr and
reading FORCE_COLOR, and internal/streams/utils is only needed for stream
validation. Requiring both at the top of node:util gave `require("util")` an
observable side effect it did not have before. Load them inside styleText's
validateStream branch instead.

Drop test-util-getcallsites.js. It opens with a `node_use_amaro` guard that
is undefined in bun, so common.skip() exited before a single assertion ran —
the file passed while testing nothing. Making it run shows it cannot pass:
it asserts getCallSites() sees more than one frame at module scope, which
holds in node only because node wraps modules in JS functions
(8 frames vs bun's 1, whose loader is native). getCallSites keeps its real
coverage in test-util-getcallsites-preparestacktrace.js.
@cirospaciari

Copy link
Copy Markdown
Member Author

Thanks — the loose-mode finding was right, and sharper than stated. I checked all four loose-visible cases against the real node v26.3.0 binary:

loose case node bun before bun after
Object(1n) vs Object(2n) false true false
Object(Symbol()) vs {} false true false
Object(Symbol('a')) vs Object(Symbol('a')) false true false
enumerable sym vs non-enumerable true true true (was false)

So three of the loose changes moved bun onto node's behaviour and are worth keeping — but the enumerability filter was a real regression: node accepts that pair in loose mode and bun had started rejecting it. Gated that one on isStrict in b63bf90add; all four now match node exactly, and the deep-equality matrix covers loose for each.

The PR description was simply wrong to say loose was unchanged — I've corrected it.

Also actioned from this review:

  • getCallSites test was vacuous — it exited at an node_use_amaro guard before any assertion. Dropped; it can't pass in bun regardless (it asserts >1 stack frame at module scope, which only holds because node wraps modules in JS functions: 8 frames vs bun's 1).
  • Top-level requires in node:util — moved into styleText's branch; internal/util/colors runs refresh() at load, so require("util") had gained a side effect.

Two findings I deliberately did not action (the empty regexp palette, and the style-cache TypeError): both reproduce identically on the real node v26.3.0 binary, so they're upstream bugs this port faithfully reproduces. Diverging would defeat the point of the port, but happy to fix if preferred.

Comment thread src/js/internal/util/inspect.js
Comment thread src/jsc/bindings/bindings.cpp Outdated
@cirospaciari

Copy link
Copy Markdown
Member Author

Flagging the overlap the duplicate-detector found, so maintainers can pick rather than discover it in review. This PR overlaps four open PRs:

I didn't check for existing PRs before building this, which is on me. Rather than unpick it, I've left this one as the superset — it additionally carries regexp syntax highlighting, getCallSites, convertProcessSignalToExitCode, the v26 hasBuiltInToString and extraKeys ports, and the vm module-namespace prototype fix, and it verifies every expectation against a real node v26.3.0 binary.

Happy to go either way: I can drop the overlapping areas and rebase on whichever of those lands first, or close this in favour of them. Just say which — no attachment to this particular diff.

@cirospaciari cirospaciari changed the title util: +5 node v26.3.0 tests, styleText hex/validateStream, getCallSites, regexp highlighting, 4 deepEquals fixes — test-util compat 70% → 87% (goal: 100%) util: +4 node v26.3.0 tests, styleText hex/validateStream, getCallSites, regexp highlighting, vm namespace + deepEquals fixes — test-util compat 70% → 87% (goal: 100%) Jul 17, 2026
…ters

Removing formatTypedArray's showHidden block left its last two parameters
unused, which the src/js lint rejects. They are positional — the formatter
is called through a bind — so prefix them rather than drop them, matching
node, which keeps the same signature for every formatter.

The vendored util-inspect test still carried the pre-v26 expectations for
ArrayBuffer, DataView and typed arrays: byteLength/byteOffset/buffer
unbracketed. Node v26 surfaces those getters through extraKeys, which
brackets them, so update the expectations to match — including the three
cases where the longer bracketed keys push the line past the default break
length and node's own test now passes breakLength: 82.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/internal/util/inspect.js`:
- Around line 1691-1695: Update the extra-property handling around
formatExtraProperties so the fallback catches only failures while reading the
metadata value, not exceptions thrown by recursive formatting. Restore
ctx.indentationLvl to its pre-call value before executing the fallback, then
retry formatting with the intended buffer-derived value while preserving normal
recursive errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 105041ce-6666-474a-ae20-6a48b3d7a356

📥 Commits

Reviewing files that changed from the base of the PR and between 05fc2be and 6dd7fa7.

📒 Files selected for processing (11)
  • scripts/runner.node.mjs
  • src/js/builtins/UtilInspect.ts
  • src/js/internal/util/inspect.js
  • src/js/node/util.ts
  • src/jsc/bindings/NodeVMModule.cpp
  • src/jsc/bindings/bindings.cpp
  • test/js/node/assert/deep-equal.test.ts
  • test/js/node/buffer.test.js
  • test/js/node/test/parallel/test-util-inspect-namespace.js
  • test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js
  • test/js/node/util/util.test.js

Comment thread src/js/internal/util/inspect.js
Comment thread src/js/internal/util/inspect.js
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/js/internal/util/inspect.js
The x64-asan shard sets CI but not BUILDKITE, and getColorDepth's CI branch
returns "no color" when it can't identify the vendor — so styleText
correctly emitted nothing where the test wanted color. Node's own
getColorDepth does the same, so this was the environment, not the runtime.
Reproduced it locally over a pty with CI=true and no vendor variable: case
"isTTY=true with no env vars" fails exactly as on CI, and passes with CI
dropped and TERM pinned. Do that for these two files so every agent agrees;
all eight TTY cases then match node v26.3.0.

Also bracket the SharedArrayBuffer byteLength in the vendored util-format
test, the last pre-v26 expectation left over from the extraKeys port.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
putComment timed out

Comment thread src/js/node/util.ts
Comment thread src/js/node/util.ts
test-util-styletext-hex.js can't pass here, and the runner can't fix it:
the file uses node:test, so it runs under `bun test`, and `bun test` puts
NO_COLOR=1 into process.env for its own output. styleText then correctly
reports no color, and the file's TTY block expects color. Reproduced over a
pty with every color variable unset and CI dropped -- the child still sees
NO_COLOR=1 and that one case fails, 30 pass 1 fail. That is bun test's
environment, not styleText.

The useful assertions in that file are the hex ones, which do not depend on
the environment at all, so carry them in bun's own util test rather than
lose the coverage: 6-digit, case-insensitivity, 3-digit expansion,
combining with named formats, nesting, `none`, and seven invalid spellings.
Output diffed against the node v26.3.0 binary -- identical on all 14 cases.

Also gate the Symbol/BigInt wrapper check on ObjectType, per review: it ran
for every pair reaching the end of the switch, costing four inherits<>()
walks on the recursive path for plain objects and arrays, which have their
own JSType. The else-if went with it -- the swapped second call already
covers the c2-is-wrapper direction.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/jsc/bindings/bindings.cpp (2)

1092-1150: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make Set/Map fallback matching one-to-one.

The fallback search can reuse the same entry for multiple distinct keys because it never records matched candidates. With skipPrototype, this can report unequal collections as equal—for example, two left-side keys matching the first right-side key while the second right-side key is different. Track consumed candidates; for Maps, match key/value pairs before marking an entry consumed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/bindings.cpp` around lines 1092 - 1150, The Set/Map fallback
matching in Bun__deepEquals must enforce one-to-one candidate usage. Track
consumed entries during fallback searches so each right-side Set value or Map
entry can match only once; for Maps, compare both key and value before marking a
candidate consumed, while preserving exception handling and normal fast-path
behavior.

1442-1454: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not bypass strict own-property checks on typed-array fast paths.

The new hasNamedOwnProperties fallback is unreachable when Lines 1387-1401 return early. Strict comparisons of empty or aliased typed arrays with differing named properties can therefore incorrectly return true. Move this guard before both fast returns and fall through to the property walk when named properties exist.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/bindings.cpp` around lines 1442 - 1454, Update the
typed-array comparison logic before the early returns around the fast-path
checks so that, when isStrict and either structure has named own properties,
execution falls through to the existing property walk. Ensure this guard applies
to empty and aliased arrays as well as the memcmp path, while preserving fast
returns when no named properties exist and loose-mode behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/bindings/bindings.cpp`:
- Around line 1092-1150: The Set/Map fallback matching in Bun__deepEquals must
enforce one-to-one candidate usage. Track consumed entries during fallback
searches so each right-side Set value or Map entry can match only once; for
Maps, compare both key and value before marking a candidate consumed, while
preserving exception handling and normal fast-path behavior.
- Around line 1442-1454: Update the typed-array comparison logic before the
early returns around the fast-path checks so that, when isStrict and either
structure has named own properties, execution falls through to the existing
property walk. Ensure this guard applies to empty and aliased arrays as well as
the memcmp path, while preserving fast returns when no named properties exist
and loose-mode behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8420cea9-4ada-4900-89a3-1a4f04741b47

📥 Commits

Reviewing files that changed from the base of the PR and between 8072986 and 7491ac7.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/node/util/util.test.js

Comment thread src/js/internal/util/inspect.js
Comment thread src/jsc/bindings/bindings.cpp
node's tty.WriteStream accepts a read-only descriptor -- common/index.js's
getTTYfd() hands it one from fs.openSync('/dev/tty') whenever stdout and
stderr are piped, which is how the macOS CI agents run. bun's fs.WriteStream
eagerly opens a fast writer for any fd it is given, and that fails on such a
descriptor with EINVAL from kqueue, so the constructor threw where node
returns a usable stream. Fall back to the general write path when the fast
writer can't be created; the failure then surfaces at write time, as it does
in node. Verified over a pty: `new tty.WriteStream(fs.openSync('/dev/tty'))`
now succeeds in bun as it does in node, and test-util-styletext.js passes.

Also extend the boxed-primitive guard to indexed storage, per review. A
boxed string carrying an out-of-range index (`new String('ab')[5] = 'x'`) has
an own property that the contents comparison misses, and the structure's
named-slot counts don't see it. node reports those as unequal; bun now does
too. Typed arrays don't carry that indexing type, so the fast path stays
flat -- Uint8Array(65536) still compares in ~3us.

Checked the surrounding fs suite for fallout: 572 pass / 5 fail both with
and without the WriteStream change, the same five pre-existing failures
(three of them 5s-90s timeouts).
Comment thread src/jsc/bindings/bindings.cpp
…o the slow path

The hoisted guard in be5cfc8 skipped byteLength/memcmp entirely when a typed
array had any extra own property, which (a) lost node's byte-level semantics
for the one case where sameValue and memcmp diverge the other way (distinct
NaN payloads now compared equal via the property walk) and (b) made a
byte-mismatch comparison O(elements) instead of O(bytes). Compute the extras
flag up front but only divert the equal-bytes exits to the property walk;
mismatches still return false from the byte checks. Matrix coverage added for
the NaN-payload case.

@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 new issues found in 2a1ad47 — the byte-checks-first reordering addresses the NaN-payload/perf concern from the last round. Deferring to a human for the scope call: this threads a new skipPrototype template parameter through Bun__deepEquals (backs every toStrictEqual/assert.deepStrictEqual), changes loose-mode answers for boxed Symbol/BigInt, adds three new util APIs, and the description flags overlap with four open PRs.

What was reviewed:

  • bindings.cpp typed-array path — byteLength/memcmp now run before the extra-own-properties divert; empty, shared-vector, and NaN-payload exits all covered in the matrix.
  • getCallSitesprepareStackTrace/stackTraceLimit restoration under a non-writable limit is inside try/finally and tested.
  • styleText skipColorize loop — validation still runs on every key when colorize is skipped; none and cached-hex paths don't bypass validateOneOf.
  • WriteStream fast-path fallback — fd != null handles fd 0; failed writer open now clears fastPath so kWriteStreamFastPath isn't left as undefined when fd is set.
Extended reasoning...

Overview

22 files spanning src/js/node/util.ts (styleText v26 rewrite, getCallSites, convertProcessSignalToExitCode, isDeepStrictEqual skipPrototype), src/js/internal/util/inspect.js (~250-line highlightRegExp port, extraKeys/formatExtraProperties, hasBuiltInToString rewrite), src/jsc/bindings/bindings.cpp (adds skipPrototype template param to Bun__deepEquals/specialObjectsDequal, boxed Symbol/BigInt handling, enumerability check on the fast property path, boxed-String and typed-array extra-own-property fall-through), CallSite.{h,cpp}/CallSitePrototype.cpp (getScriptId), NodeVMModule.cpp (null the vm namespace prototype), src/js/internal/fs/streams.ts (WriteStream fast-path try/catch), scripts/runner.node.mjs (env override for two styletext tests), plus ~1000 lines of ported/updated tests.

Security risks

None identified. No auth/crypto/permissions surface. styleText and highlightRegExp operate on caller-supplied strings and only emit ANSI sequences; the runner env change is scoped by filename to two util tests.

Level of scrutiny

High. Bun__deepEquals is the correctness primitive behind expect().toStrictEqual, assert.deepStrictEqual, and util.isDeepStrictEqual; the PR both adds a template dimension and changes strict- and loose-mode answers for several inputs (boxed Symbol/BigInt, enumerability, boxed String extras, typed-array extras). It also adds public API (getCallSites, convertProcessSignalToExitCode, styleText hex/validateStream) and overlaps four open PRs per the author — all things a maintainer should sign off on.

Other factors

The PR has been through several automated review rounds; every prior finding (loose-mode enumerability regression, non-writable stackTraceLimit leak, fd=0 truthiness, empty/shared-buffer typed-array early returns, NaN-payload semantics) has a corresponding fix commit and matrix case. CI on the previous build was 284/286 green with the two reds noted as unrelated. No outstanding unresolved review threads.

@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 new issues found on 2a1ad47. This is a large change to Bun__deepEquals (backs expect().toStrictEqual and assert.deepStrictEqual) plus new public node:util API surface, so leaving final sign-off to a human.

What was reviewed on this revision:

  • Verified the 2a1ad47 reorder in the typed-array case: byte mismatch now returns false before the property walk, and compareOwnProperties only diverts the three equal-bytes exits — the NaN-payload and O(bytes) concerns from the earlier thread are closed, with a matrix case pinning it.
  • Checked that the new loose-mode-visible paths (boxed Symbol/BigInt ObjectType block, strict-only enumerability check on the fast-property path) are gated so pre-existing loose behavior for plain objects is unchanged.
  • Checked getCallSites restores prepareStackTrace/stackTraceLimit on all exits including a non-writable stackTraceLimit, and that styleText's skipColorize branch still validates hex format before continue.
Extended reasoning...

Overview

Node v26.3.0 node:util compat: rewrites styleText (hex/RGB, validateStream, nested close-code handling), adds getCallSites/convertProcessSignalToExitCode/isDeepStrictEqual(…, skipPrototype), ports highlightRegExp and the extraKeys/hasBuiltInToString rewrites into inspect.js, nulls the vm module namespace prototype, and adds a tty.WriteStream read-only-fd fallback in streams.ts. On the native side it threads a third skipPrototype template parameter through Bun__deepEquals, adds hasExtraOwnProperties gating for boxed strings and typed arrays, adds a boxed-Symbol/BigInt ObjectType block, adds an enumerability check to the strict fast-property path, and adds CallSite::getScriptId. 22 files, ~1000 lines changed.

Security risks

None identified. No auth/crypto/permissions surface. styleText and highlightRegExp operate on caller-supplied strings and produce ANSI output; the hex cache is bounded at 256 entries. getCallSites mutates and restores Error.prepareStackTrace/stackTraceLimit inside a try/finally with a nested best-effort try around the non-writable case.

Level of scrutiny

High. Bun__deepEquals is the shared implementation behind expect().toEqual/toStrictEqual, Bun.deepEquals, assert.deep[Strict]Equal, and util.isDeepStrictEqual, so a subtle regression fans out across the entire test runner. The PR intentionally moves three loose-mode cases onto Node's answer (boxed BigInt/Symbol identity), which is a user-visible behavior change for Bun.deepEquals(a, b) and expect().toEqual — the description documents this table but it's a call a maintainer should confirm. The inspect.js changes alter default output formatting ([byteLength] bracketing, regexp highlighting as a function-valued style), which is technically a Bun-native output change driven by Node compat.

Other factors

This revision has been through several review rounds: a human maintainer left detailed feedback and later adopted the PR, and earlier automated findings (fd=0 truthiness, empty/shared-buffer typed-array early returns, the NaN-payload/perf regression from the initial hoist) were all fixed with test coverage added. All inline threads are resolved. CI on the adopted revision was reported 284/286 green with the two reds pre-existing on main. The remaining known gaps (sourceMap not applied, [Foo: null prototype], Buffer≠Uint8Array in strict) are documented in the description as deliberate follow-ups. Given the breadth — a template-parameter change to a core comparison function, new public API, and default inspect-output changes — this warrants a maintainer's final look rather than bot approval.

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun fix conflicts

Comment thread src/jsc/bindings/bindings.cpp
Comment thread src/jsc/bindings/bindings.cpp
@dylan-conway
dylan-conway merged commit 5461e9d into main Jul 24, 2026
47 of 51 checks passed
@dylan-conway
dylan-conway deleted the claude/node-util-v26-compat branch July 24, 2026 04:29
liooil pushed a commit to liooil/poly that referenced this pull request Aug 7, 2026
…s, regexp highlighting, vm namespace, tty.WriteStream + deepEquals fixes (test-util 70% → 83%) (#34434)

## What

Brings `node:util` closer to **node v26.3.0** and fixes several
`Bun.deepEquals` bugs the newly ported tests exposed.

Fixes #33074
Fixes #25736
Fixes #20129

### `test-util-*` compatibility vs node v26.3.0

| | before | after |
|---|---|---|
| passing | 21 / 30 (70%) | **25 / 30 (83%)** |

**+3 tests added** (verbatim from v26.3.0), **+1 updated**, plus new
coverage in bun's own deep-equality matrix and util tests.

Beyond `node:util`, this also fixes two bugs the porting turned up:
`tty.WriteStream` throwing on a read-only fd, and vm module namespaces
having a non-null prototype.

> Overlaps four open PRs (#30985, #33080, #32872, #29037) — see [this
comment](oven-sh/bun#34434 (comment)).
Happy to drop the overlapping areas or close in favour of them.

## Fixes

**`util.styleText` was still on the pre-v26 API.** Ported node v26.3.0's
version: hex/RGB colors (`#RGB`/`#RRGGBB`), the `none` format, nested
close-code handling, and `{ validateStream, stream }` — colour is
suppressed when the target stream isn't a TTY.

**New APIs**
- `util.getCallSites(frameCount, { sourceMap })` — captures through a
private `prepareStackTrace`, so a user-installed
`Error.prepareStackTrace` is never invoked and `Error.stackTraceLimit`
doesn't bound the result. `CallSite` gains `getScriptId`. Column numbers
and the `column` alias verified against the node v26.3.0 binary.
- `util.convertProcessSignalToExitCode(signal)`
- `util.isDeepStrictEqual(a, b, skipPrototype)` — node v26's third
argument, threaded through `Bun__deepEquals` as a template parameter.

**`util.inspect`**
- **Regexes are syntax-highlighted** by group depth (ported v26's
`highlightRegExp`), replacing the flat `red` style — removing the
`TODO(BridgeAR): Highlight regular expressions properly` bun inherited
from node.
- **`hasBuiltInToString`** rejected *any* `Symbol.toPrimitive`, but
`Date.prototype[Symbol.toPrimitive]` is built-in, so `util.format('%s',
date)` printed the `toString` form where node prints inspect's ISO form.
Ported v26's version, which distinguishes own from inherited.
- **`extraKeys`** — ported v26's mechanism replacing `unshift(keys, …)`.
Those entries are getters, so node brackets them: `ArrayBuffer {
[Uint8Contents]: <..>, [byteLength]: 4 }`, and `showHidden` on a typed
array reports `[BYTES_PER_ELEMENT]`, `[length]`, `[byteLength]`,
`[byteOffset]`, `[buffer]`.
- **vm module namespaces get a null prototype.** A Module Namespace
Exotic Object is specified to have `[[Prototype]] = null`; bun points
the shared structure at an object carrying an `__esModule` accessor for
CJS interop. Nulled in `NodeVMModule::namespaceObject` only, so real ESM
namespaces keep the accessor.

**`Bun.deepEquals` correctness (strict mode)**
- boxed **Strings** ignored extra own properties
- boxed **Symbols/BigInts** are plain `ObjectType` in JSC and never
reached the type switch, so two *different* boxed Symbols compared equal
- the fast path fetched the right-hand property without checking
**enumerability**
- typed arrays skipped own **non-index properties** (e.g. symbols)

**Loose mode** is *not* untouched (an earlier revision of this
description wrongly said so). Three loose-visible cases moved onto
node's behaviour, and one regression was caught in review and gated
back:

| loose case | node | bun before | bun now |
|---|---|---|---|
| `Object(1n)` vs `Object(2n)` | false | true | **false** |
| `Object(Symbol())` vs `{}` | false | true | **false** |
| `Object(Symbol('a'))` vs `Object(Symbol('a'))` | false | true |
**false** |
| enumerable sym vs non-enumerable | true | true | **true** |

## Perf

Both strict fall-throughs are gated on the structure actually carrying
named properties. Elements and characters are synthesized by
`getOwnPropertySlot` rather than stored in the structure, so an
unguarded fall-through lands in the index-enumerating slow path.
Measured before the guard: `new String('a'.repeat(100000))` took
**2,004,127µs/op** and a 1KB `Uint8Array` **4264µs/op**, both linear.
With the guard both are flat (~5µs).

## Verification

- A/B against a clean build of the same base commit, using the runner's
own dispatch (`bun test` for files containing `node:test`, `bun run`
otherwise).
- Every expectation checked against a **real node v26.3.0 binary**, not
read off the source. Two review findings (a `TypeError` from a primed
style cache, and an empty regexp palette) **reproduce identically on
node** and are deliberately left as faithful ports.
- Regression sweep: `test-assert-*` / `test-buffer-*` /
`test-console-*`, `buffer.test.js` (617), the 271-case deep-equality
matrix, bun's `expect` suites, and 97/97 node vm tests. `oxlint` clean.

## Notes for reviewers

**`test-util-styletext{,-hex}.js` need the real environment.** They
assert styleText's own colour decisions against a TTY. Deleting the
runner's forced `FORCE_COLOR=0`/`NO_COLOR=1` wasn't enough — `spawnBun`
sets `FORCE_COLOR=1` in its own base env, which reached the child. The
three colour variables are now set to `undefined` (which `child_process`
drops) for those two files. With no colour variables set, bun matches
node on all eight TTY cases.

## Known gaps (deliberate)

- **`test-util-inspect.js`** — `--expose-internals`; needs
`internalBinding('js_stream').JSStream`, whose `_externalStream` must be
a real napi external. It gets ~145 assertions in before that line.
- **`test-util-format.js`** — one assertion: `[Foo: null prototype]`. V8
keeps the constructor on the object's map; JSC has no equivalent. I
tried recovering it from JSC's structure transition chain —
`previousID()` yields nothing usable after a prototype change (verified
across `setPrototypeOf`, `__proto__ =`, and `Reflect.setPrototypeOf`),
so this needs a WebKit-side change. Everything else in the file passes.
- **`test-util-isDeepStrictEqual.js`** — needs `Buffer` ≠ `Uint8Array`
in strict mode. That's correct (node rejects them, and the matrix pins
bun's answer as a known bug), but enabling it turns
`test-child-process-advanced-serialization.js` red: bun's advanced IPC
is backed by `SerializedScriptValue`, which downgrades `Buffer` to
`Uint8Array`, so that test currently passes only because two bugs cancel
out:
  ```
  bun:  received.buffer → Uint8Array, isBuffer: false
  node: received.buffer → Buffer,     isBuffer: true
  ```
Preserving `Buffer` through structured clone belongs in its own change.
- **`test-util-getcallsites.js`** — asserts `getCallSites().length > 1`
at module scope, which holds in node only because node wraps modules in
JS functions (8 frames vs bun's 1; bun's loader is native). Not
portable. `getCallSites` keeps real coverage in
`test-util-getcallsites-preparestacktrace.js`.
- `getCallSites`'s `sourceMap` option is validated but not applied.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 11 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/node/util/node-inspect-tests/parallel/util-inspect.test.js

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants