Skip to content

fix(node:util): correct numericSeparator for negative fractions and exponent-form numbers - #36501

Closed
robobun wants to merge 3 commits into
mainfrom
farm/3226f333/util-inspect-numeric-separator
Closed

fix(node:util): correct numericSeparator for negative fractions and exponent-form numbers#36501
robobun wants to merge 3 commits into
mainfrom
farm/3226f333/util-inspect-numeric-separator

Conversation

@robobun

@robobun robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

util.inspect(n, { numericSeparator: true }) mangled negative fractional numbers and numbers that stringify in exponent form. Fixes #23098. Adopted from #32460 by @0xfandom.

Input Bun before Bun after Node main
-0.12 0..12 -0.12 -0.12
-0.1234 0..12_34 -0.123_4 -0.123_4
1e-7 0.-7 1e-7 1e-7
-1e-7 0.e-7 -1e-7 -1e-7
1.23456e-7 0.234_56e_-7 1.23456e-7 1.23456e-7
-0 0 -0 -0

formatNumber derived the fractional slice from String(Math.trunc(n)).length + 1. For -0.12, Math.trunc is -0 and String(-0) is "0", so the sign was lost and the slice offset into "-0.12" was off by one. For 1e-7, trunc is 0 so "0" bears no relation to the . position in "1e-7" and addNumericSeparatorEnd treated "-7" as a digit run.

The fix ports Node's current formatNumber verbatim:

  • hoist the -0 check above the numericSeparator branch (since String(-0) === "0")
  • split the number's own string representation on indexOf(".") rather than on String(Math.trunc(n)).length
  • pass exponent-form strings (containing e) through unchanged

Node v26.3.0 only had the indexOf change; the -0 hoist and exponent-form guard landed upstream after that, so the "Node main" column above is what Node nightly produces.

How did you verify your code works?

New describe("inspect numericSeparator") block in test/js/node/util/util.test.js covers the #23098 repro array, each negative fraction individually (including grouped -12_345.678_9), -0/0, large integer and fraction grouping, exponent-form inputs (1e-7, -1e-7, 2e-7, 1.23456e-7, 1.2345...e+21), and NaN/Infinity/-Infinity.

$ USE_SYSTEM_BUN=1 bun test test/js/node/util/util.test.js -t numericSeparator
 2 pass, 4 fail   # the 4 failing cases are the bugs above

$ bun bd test test/js/node/util/util.test.js -t numericSeparator
 6 pass, 0 fail

Also green: full util.test.js (214), node-inspect-tests/parallel/util-format.test.js, node-inspect-tests/internal-inspect.test.js, test/js/bun/util/inspect.test.js. The util-inspect.test.js "no assertion failures 2" debug-build timeout reproduces identically on unmodified main (see #36147).

Co-authored-by: 0xfandom kashyapshivank01@gmail.com


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/util/util.test.js
bun test v1.4.0 (9952f31d0)

test/js/node/util/util.test.js:
(pass) util > toUSVString [5.25ms]
(pass) util > inherits [5.75ms]
(pass) util > isArray > all cases [7.74ms]
(pass) util > isRegExp > all cases [4.50ms]
(pass) util > isDate > all cases [165.84ms]
(pass) util > isError > all cases [17.10ms]
(pass) util > isObject > all cases [3.69ms]
(pass) util > isPrimitive > all cases [9.23ms]
(pass) util > isBuffer > all cases [3.72ms]
(pass) util > _extend > all cases [8.11ms]
(pass) util > isBoolean > all cases [3.04ms]
(pass) util > isNull > all cases [2.97ms]
(pass) util > isUndefined > all cases [2.95ms]
(pass) util > isNullOrUndefined > all cases [2.94ms]
(pass) util > isNumber > all cases [2.65ms]
(pass) util > isString > all cases [2.96ms]
(pass) util > isSymbol > all cases [2.80ms]
(pass) util > isFunction > all cases [3.40ms]
(pass) util > types.isNativeError > all cases [4.52ms]
(pass) util > TextEncoder > is same as global TextEncoder [1.28ms]
(pass) util > TextDecoder > is same as global TextDe
... (truncated)

release without fix: 13 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/util/util.test.js:
(pass) util > toUSVString [0.13ms]
(pass) util > inherits [0.13ms]
(pass) util > isArray > all cases [0.14ms]
(pass) util > isRegExp > all cases [0.06ms]
(pass) util > isDate > all cases [0.15ms]
(pass) util > isError > all cases [0.27ms]
(pass) util > isObject > all cases [0.10ms]
(pass) util > isPrimitive > all cases [0.13ms]
(pass) util > isBuffer > all cases [0.06ms]
(pass) util > _extend > all cases [0.12ms]
(pass) util > isBoolean > all cases [0.03ms]
(pass) util > isNull > all cases [0.03ms]
(pass) util > isUndefined > all cases [0.03ms]
(pass) util > isNullOrUndefined > all cases [0.03ms]
(pass) util > isNumber > all cases [0.02ms]
(pass) util > isString > all cases [0.02ms]
(pass) util > isSymbol > all cases [0.02ms]
(pass) util > isFunction > all cases [0.03ms]
(pass) util > types.isNativeError > all cases [0.05ms]
(pass) util > TextEncoder > is same as global TextEncoder [0.02ms]
(pass) util > TextDecoder > is same as global TextDecoder [0.01ms]
(pass) util > format [0.26ms]
(pass) util > formatWithOptions [1.66ms]
345 |   // node v26.3.0 binary.
346 |   describe("styleText hex colors",
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/util/util.test.js
bun test v1.4.0 (9952f31d0)

test/js/node/util/util.test.js:
(pass) util > toUSVString [5.38ms]
(pass) util > inherits [6.27ms]
(pass) util > isArray > all cases [8.14ms]
(pass) util > isRegExp > all cases [4.72ms]
(pass) util > isDate > all cases [167.64ms]
(pass) util > isError > all cases [18.67ms]
(pass) util > isObject > all cases [3.93ms]
(pass) util > isPrimitive > all cases [9.59ms]
(pass) util > isBuffer > all cases [4.32ms]
(pass) util > _extend > all cases [8.31ms]
(pass) util > isBoolean > all cases [3.37ms]
(pass) util > isNull > all cases [3.50ms]
(pass) util > isUndefined > all cases [3.44ms]
(pass) util > isNullOrUndefined > all cases [3.08ms]
(pass) util > isNumber > all cases [3.20ms]
(pass) util > isString > all cases [2.79ms]
(pass) util > isSymbol > all cases [2.92ms]
(pass) util > isFunction > all cases [3.33ms]
(pass) util > types.isNativeError > all cases [4.66ms]
(pass) util > TextEncoder > is same as global TextEncoder [1.51ms]
(pass) util > TextDecoder > is same as global TextDe
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     9952f31d04
  features     baseline

22 deps, 108 codegen, 1171 objects in 1018ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [30.00ms]
[2/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [3.00ms]
[3/1234] gen ErrorCode+*.h
[4/1234] gen bindgenv2
[5/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [16.00ms]
[6/1234] fetch picohttpparser
[picohttpparser] up to date
[7/1234] fetch tinycc
[tinycc] up to date
[8/1234] gen .bind.ts → GeneratedBindings.cpp
[9/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[10/1234] fetch zlib
[zlib] up to date
[11/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindin
... (truncated)
diff hotspot
src/js/internal/util/inspect.js | 33 +++++++++++++++++------------
 test/js/node/util/util.test.js  | 47 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 66 insertions(+), 14 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                             reads  edits  tests
src/js/internal/util/inspect.js      1      1      0
test/js/node/util/util.test.js       2      3      0

…xponent-form numbers

util.inspect(n, { numericSeparator: true }) mangled negative fractional
numbers and numbers that stringify in exponent form:

  -0.12        -> "0..12"          (sign dropped, doubled decimal point)
  1e-7         -> "0.-7"
  1.23456e-7   -> "0.234_56e_-7"
  -0           -> "0"              (sign dropped)

formatNumber derived the fractional slice from String(Math.trunc(n)).length.
For -0.12, Math.trunc is -0 and String(-0) is "0", so the sign was lost and
the slice offset was wrong. For 1e-7, trunc is 0 so "0" bore no relation to
the actual "." position in "1e-7".

Port Node's current formatNumber: hoist the -0 check above the separator
branch, split the number's own string on indexOf("."), and pass exponent-form
strings through unchanged.

Closes #23098
Adopted from #32460.

Co-authored-by: 0xfandom <kashyapshivank01@gmail.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 19 minutes

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

How can I continue?

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

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

How do review limits work?

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

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

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0e0c8c2b-dc7f-474f-b171-7d1df2365cc1

📥 Commits

Reviewing files that changed from the base of the PR and between 9952f31 and 8100899.

📒 Files selected for processing (1)
  • test/js/node/util/util.test.js

Walkthrough

Changes

Numeric separator formatting

Layer / File(s) Summary
Format numbers and validate output
src/js/internal/util/inspect.js, test/js/node/util/util.test.js
formatNumber preserves -0, retains exponent and non-finite representations, and applies separators to integer and fractional parts independently. Tests cover fractions, signed zero, large numbers, exponent notation, NaN, and infinity.

Suggested reviewers: cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation fixes issue #23098 by preserving negative signs and removing duplicate decimal points in numericSeparator output.
Out of Scope Changes check ✅ Passed The code and tests remain within scope, covering the reported bug and closely related numericSeparator cases.
Title check ✅ Passed The title clearly and concisely describes the fix for numericSeparator handling of negative fractions and exponent-form numbers.
Description check ✅ Passed The description includes both required sections and provides clear change details, regression cases, and verification results.

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

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with util.inspect(-0.1234, {numericSeparator: true})"0..12_34" on main; fixed build → "-0.123_4". Port of Node's current formatNumber. PR #36501 (adopted from #32460 with credit). Waiting on CI.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(node:util): correct numericSeparator for negative fractional numbers #32460 - Also fixes numericSeparator for negative fractional numbers in util.inspect's formatNumber (same bug util.inspect incorrectly formats negative fractional numbers with numericSeparator: true #23098); this PR was adopted from fix(node:util): correct numericSeparator for negative fractional numbers #32460

🤖 Generated with Claude Code

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

LGTM — straightforward port of Node's current formatNumber to fix numericSeparator output for negative fractions, -0, and exponent-form numbers.

What was reviewed:

  • Traced the new control flow for each input class (-0, integers, NaN/±Infinity, exponent-form integers and fractions, negative/positive decimals) — all reach the correct branch and decimalIndex is never -1 in the final path.
  • Confirmed StringPrototypeIndexOf and the other primordials used are already declared in this file.
  • Checked addNumericSeparator already handles a leading - in the integer part, so -12_345.678_9 groups correctly.
  • Tests cover the #23098 repro plus every branch touched; assertions are exact toBe strings verified against Node.
Extended reasoning...

Overview

This PR fixes util.inspect(n, { numericSeparator: true }) for three input classes that were previously mangled: negative fractional numbers (-0.120..12), exponent-form numbers (1e-70.-7), and -0 under the numericSeparator branch (-00). The root cause was deriving the fractional slice offset from String(Math.trunc(n)).length, which is wrong when Math.trunc returns -0 (stringifies without the sign) or when the number's own string representation is in exponent form and unrelated to the trunc result. The fix hoists the -0 check above all branches, splits on the actual . position via indexOf, and passes any string containing e through unchanged — matching Node's current lib/internal/util/inspect.js.

Two files touched: ~25 lines rewritten in formatNumber (src/js/internal/util/inspect.js) and a new 47-line describe block in test/js/node/util/util.test.js.

Security risks

None. Pure string-formatting logic on a primitive number in a debug-inspection code path. No I/O, no allocation beyond short strings, no user-controllable resource sizing, and the primordial-captured helpers (StringPrototypeIndexOf, StringPrototypeSlice, ObjectIs, etc.) are already declared at the top of the file, so there's no new prototype-pollution surface.

Level of scrutiny

Low. This is a small, self-contained Node-compat bugfix in built-in JS that ports upstream Node's implementation verbatim. I walked through each branch: -0 returns early; !numericSeparator returns the raw string; integer-valued numbers either pass through (non-finite / e-form) or go to addNumericSeparator; NaN and fractional e-form pass through; and the remaining finite fractional case is guaranteed to contain . (any finite non-integer stringifies with either a . or an e, and e was already handled), so decimalIndex can't be -1. addNumericSeparator already special-cases a leading -, so the negative integer part groups correctly.

Other factors

Test coverage is thorough and follows repo conventions: exact toBe assertions (no snapshots needed for these one-liners), the original issue's repro array, per-value negative-fraction cases including a grouped one, -0/0, positive/negative large-integer grouping, both integer and fractional exponent-form inputs, and NaN/±Infinity. The PR description shows USE_SYSTEM_BUN=1 fails 4/6 and the debug build passes 6/6, satisfying the fails-for-the-right-reason requirement. No prior human review comments to address.

Comment thread src/js/internal/util/inspect.js

@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: 2

🤖 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`:
- Line 2217: Update the numeric conversion in the inspection formatting path
around numberString to avoid the mutable global String constructor; use
primitive interpolation consistent with the existing non-separator branch,
preserving the current output while remaining safe if globalThis.String is
replaced.

In `@test/js/node/util/util.test.js`:
- Line 469: Replace the explanatory regression-test comments around the affected
tests with only the relevant issue URL, and remove the standalone comment at
line 501. Keep the existing test names and test behavior unchanged.
🪄 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: d48a4ee6-20a3-4c61-a2ff-c1c920141a03

📥 Commits

Reviewing files that changed from the base of the PR and between 468dac3 and 9952f31.

📒 Files selected for processing (2)
  • src/js/internal/util/inspect.js
  • test/js/node/util/util.test.js

Comment thread src/js/internal/util/inspect.js
Comment thread test/js/node/util/util.test.js Outdated

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

LGTM — straightforward port of Node's formatNumber that fixes the String(Math.trunc(n)).length slice bug. The inline nit about test comments is optional cleanup.

What was reviewed:

  • Traced every branch of the new formatNumber (−0, non-separator, integer, ±Infinity, NaN, exponent-form both integer and fractional, negative fractions) — decimalIndex is always ≥ 0 when reached, and addNumericSeparator already handles the leading -.
  • Confirmed StringPrototypeIndexOf is already defined in the primordials block.
  • The comment-cop bot flag on line 2209 is a false positive — that two-line comment explains a real JS quirk (String(-0) === "0"), not a workaround.
Extended reasoning...

Overview

Two-file change: src/js/internal/util/inspect.js rewrites formatNumber (~25 lines) to match Node's current implementation, and test/js/node/util/util.test.js adds a 47-line describe block covering the fixed cases. The bug was that the fractional part was sliced at String(Math.trunc(n)).length + 1, which is wrong when Math.trunc yields -0 (stringifies as "0", dropping the sign and shifting the offset) or when the number stringifies in exponent form (trunc's string bears no relation to the . position).

Security risks

None. Pure display formatting of already-typed number values in util.inspect; no parsing of untrusted input, no allocation sizing, no native code.

Level of scrutiny

Low. This is a Node-compat fix in a built-in JS module where the reference implementation (Node's lib/internal/util/inspect.js) is the spec per REVIEW.md, and the PR ports it verbatim. I traced each code path against the test inputs: -0 short-circuits before String(); NaN is caught before the indexOf(".") split (which would otherwise return −1); exponent-form fractionals like 1e-7 (where trunc(n) !== n) are now guarded by the new includes("e") check on the fractional branch; and addNumericSeparator at inspect.js:2189 already handles a leading - in the integer part, so -12_345.678_9 groups correctly.

Other factors

  • Tests are exact-value assertions verified against Node, added to the existing test file (correct location per CLAUDE.md), and the PR body shows they fail under USE_SYSTEM_BUN=1.
  • The only bug-hunter finding is a style nit about bug-history comments in the test — valid per REVIEW.md but not merge-blocking.
  • The github-actions comment-cop inline comment on inspect.js:2209 misfires: the flagged comment is two short lines documenting why ObjectIs(number, -0) must precede String() conversion (a durable non-obvious invariant, not a workaround justification), and the first of those lines already existed before this PR.

Comment thread test/js/node/util/util.test.js Outdated

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

LGTM — thanks for dropping the bug-history comments in 8100899.

What was reviewed:

  • Traced formatNumber through -0, negative fractions, exponent-form (both integer === number and fractional branches), NaN/±Infinity, and large-integer-as-exponent — all reach the correct return.
  • Confirmed addNumericSeparator already handles the leading - in the new integerPart slice (start = integerString[0] === "-" ? 1 : 0).
  • Confirmed StringPrototypeIndexOf is already defined in this file (line 110).
  • On the open CodeRabbit note re String(number): the file already calls String(...) ~32 times and the replaced code did too — not a regression introduced here, and hardening that is a separate file-wide change.
Extended reasoning...

Overview

This PR rewrites formatNumber in src/js/internal/util/inspect.js to fix util.inspect(n, { numericSeparator: true }) for negative fractional numbers (e.g. -0.12 previously rendered as "0..12"), exponent-form numbers (1e-7"0.-7"), and -0 under the separator option. The fix is a near-verbatim port of Node's current formatNumber: hoist the Object.is(-0) check above the separator branch, stringify the number once and split on indexOf(".") instead of String(Math.trunc(n)).length, and pass through any string containing e. A new describe("inspect numericSeparator") block in test/js/node/util/util.test.js adds 6 tests covering the #23098 repro array, individual negative fractions, ±0, integer/fraction grouping, exponent-form inputs, and non-finite values.

Security risks

None. This is pure output-formatting logic over primitive numbers with no I/O, no allocation, and no user-controlled parsing. The one primordial-safety observation from CodeRabbit (String(number) reads the global String) is a pre-existing pattern used ~32 times throughout this file — the diff actually reduces the call count relative to the old code (String(integer) + String(number) → one String(number)), so it is not a regression introduced by this PR.

Level of scrutiny

Low-to-moderate. This is a small (~20-line) Node-compat bug fix in a well-isolated helper, ported directly from the upstream reference implementation. I traced every branch of the new formatNumber against representative inputs and confirmed addNumericSeparator already handles a leading - in the integer-part slice via its start offset, so -12_345.678_9 groups correctly. StringPrototypeIndexOf (the only newly-referenced primordial) is already defined at line 110.

Other factors

  • My earlier nit about bug-history comments in the test file was addressed in commit 8100899 — the current diff carries no explanatory comments, and the test names reference #23098 directly.
  • The PR body demonstrates the tests fail on USE_SYSTEM_BUN=1 (4 fail) and pass on the debug build (6 pass), satisfying the fails-for-the-right-reason requirement.
  • Test coverage spans the full input matrix (negative/positive, integer/fractional, exponent-form via both the integer === number and fractional paths, NaN, ±Infinity, ±0), with exact-value .toBe assertions.
  • The remaining unresolved CodeRabbit inline comment is not actionable within this PR's scope for the reason above.

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:29 PM PT - Jul 30th, 2026

@robobun, your commit 810089971de5e35d19e02eacf413885c780c6a20 passed in Build #86046! 🎉


🧪   To try this PR locally:

bunx bun-pr 36501

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

bun-36501 --bun

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #32460, which this PR adopted: the formatNumber change and the tests are the same, and #32460 was opened first and still merges cleanly onto current main. The fix for #23098 should land through #32460.

@robobun robobun closed this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

util.inspect incorrectly formats negative fractional numbers with numericSeparator: true

1 participant