Skip to content

fix(node:util): correct numericSeparator for negative fractional numbers - #32460

Open
0xfandom wants to merge 3 commits into
oven-sh:mainfrom
0xfandom:claude/util-inspect-numeric-separator
Open

fix(node:util): correct numericSeparator for negative fractional numbers#32460
0xfandom wants to merge 3 commits into
oven-sh:mainfrom
0xfandom:claude/util-inspect-numeric-separator

Conversation

@0xfandom

Copy link
Copy Markdown

What does this PR do?

Fixes util.inspect(value, { numericSeparator: true }) for negative fractional numbers. Fixes #23098.

Before:

util.inspect([0.1234, -0.12, -0.123, -0.1234, -1.234], { numericSeparator: true })
// [ 0.123_4, 0..12, 0..12_3, 0..12_34, -1.234 ]   ← lost sign, doubled "."

After (matches Node):

// [ 0.123_4, -0.12, -0.123, -0.123_4, -1.234 ]

formatNumber derived the integer part from String(Math.trunc(n)). For -1 < n < 0, Math.trunc(-0.12) is -0 and String(-0) is "0", so the sign was dropped and the offset used to slice the fractional digits was off by one — producing 0..12. The fix splits the number's own string representation on the decimal point (matching Node's current formatNumber) and moves the -0 check ahead of the separator branch so -0 keeps its sign there too.

How did you verify your code works?

Added a numericSeparator describe block to test/js/node/util/util.test.js:

  • the issue repro array ([ 0.123_4, -0.12, -0.123, -0.123_4, -1.234 ])
  • each negative fraction individually, including a grouped one (-12345.6789-12_345.678_9)
  • -0 keeps its sign with the separator enabled
  • large-integer and fraction grouping

The new tests pass on the debug build and fail against the release bun (proving they exercise the change). Full util.test.js (196) and the Node inspect suite pass with no regressions.

`util.inspect(n, { numericSeparator: true })` mangled negative
fractional numbers: `-0.12` rendered as `0..12` (missing sign, doubled
decimal point). `formatNumber` derived the integer part from
`String(Math.trunc(n))`, but `Math.trunc(-0.12)` is `-0` and
`String(-0)` is `"0"` — dropping the sign and throwing off the offset
used to slice out the fractional digits.

Split the number's own string representation on the decimal point
instead, matching Node's current implementation, and move the `-0`
check ahead of the separator branch so `-0` keeps its sign there too.

Closes oven-sh#23098

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 82ad4371-8792-464e-8832-e1a1bdcaaa43

📥 Commits

Reviewing files that changed from the base of the PR and between 0397027 and 869e1c4.

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

Walkthrough

formatNumber() is refactored to correctly format negative fractions and -0 with numericSeparator enabled. Tests cover signed fractions, grouped values, exponent notation, and non-finite numbers.

Changes

numericSeparator formatting fix

Layer / File(s) Summary
formatNumber() refactor
src/js/internal/util/inspect.js
Handles -0 first, uses numberString for integer and exponent decisions, and splits decimals at "." before applying numeric separators.
numericSeparator regression coverage
test/js/node/util/util.test.js
Adds assertions for negative fractions, -0, grouped integers and fractions, exponent-form values, NaN, and infinities.

Possibly related PRs

  • oven-sh/bun#36501: Modifies formatNumber() with identical changes to handle -0 preservation, negative fractions, exponent-form numbers, and separate integer/fractional part formatting using numeric separators.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the numericSeparator bug in negative fractional numbers and matches the main code change.
Description check ✅ Passed The description covers the change, root cause, expected behavior, verification steps, and test results.
Linked Issues check ✅ Passed The implementation fixes issue #23098 by preserving negative signs and preventing duplicated decimal points in numericSeparator output.
Out of Scope Changes check ✅ Passed The code and test changes are limited to numericSeparator formatting and its regression coverage.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Came across this while chasing a fuzz finding that has the same root cause: exponent-form numbers are corrupted by the old String(Math.trunc(n)).length + 1 split.

util.inspect(1.23456e-7, { numericSeparator: true })
// before: '0.234_56e_-7'  (leading 1 replaced with 0)

util.inspect(1e-7, { numericSeparator: true })
// before: '0.-7'

util.inspect(-1.23456e-7, { numericSeparator: true })
// before: '0..23_456_e-7'

The fix in this PR already handles these (via the StringPrototypeIncludes(numberString, "e") guard in the non-integer branch). Might be worth adding a couple of these inputs to the test block so the exponent path is covered too, e.g.:

expect(sep(1.23456e-7)).toBe("1.23456e-7");
expect(sep(-1.23456e-7)).toBe("-1.23456e-7");
expect(sep(1e-7)).toBe("1e-7");
expect(sep(1.5e21)).toBe("1.5e+21");

Closed #32865 as a duplicate of this.

@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Came at this from fuzzing util.inspect against Node and landed on the same formatNumber change, so I'm not opening a second PR. A few things that should help whoever picks this up:

Still reproduces on main today (08c5bc9e3a):

util.inspect(-1e-7, { numericSeparator: true }); // "0.e-7"  sign gone
util.inspect(1e-7,  { numericSeparator: true }); // "0.-7"   two distinct
util.inspect(2e-7,  { numericSeparator: true }); // "0.-7"   numbers, one output
util.inspect(-0.5,  { numericSeparator: true }); // "0..5"

This diff, applied on top of current main, fixes all of them. It applies cleanly; I built it and ran test/js/node/util/util.test.js (196 pass) and test/js/node/util/node-inspect-tests/ (15 pass, 0 fail), plus the exponent cases below.

On matching Node, since that's the obvious objection to the e guard: Node does not get the exponent cases right either, so this PR intentionally diverges there. Node's formatNumber splits on indexOf('.') without guarding decimalIndex === -1, so for a number whose String() has no . it slices off the last character and then runs the separator pass over the exponent:

input node v26.3.0 this PR
1e-7 1e-.1e-_7 1e-7
-1e-7 -1e-.-1e_-7 -1e-7
5e-324 5e_-32.5e-_324 5e-324
1.2345678e-7 1.234_567_8e-_7 1.2345678e-7
-0.5 -0.5 -0.5
-123456789.12345678 -123_456_789.123_456_78 -123_456_789.123_456_78
1e+21 1e+21 1e+21

The e guard is the same rule Node already applies in the integer branch (1e+21 prints without separators), so this matches Node everywhere Node is correct, and prints the round-trippable value where Node isn't.

One divergence worth a line in the description: hoisting the -0 check above the numericSeparator branch also changes util.inspect(-0, { numericSeparator: true }) from 0 to -0. Node prints 0 there (its own -0 handling is inconsistent between the two branches). It looks deliberate and right, just shouldn't be a surprise to a reviewer.

Coverage for the exponent path, if you want to drop it into the inspect numericSeparator block. This passes against the fix as written:

it("does not insert separators into exponential notation", () => {
  expect(sep(1e-7)).toBe("1e-7");
  expect(sep(2e-7)).toBe("2e-7");
  expect(sep(-1e-7)).toBe("-1e-7");
  expect(sep(1.2345678e-7)).toBe("1.2345678e-7");
  expect(sep(5e-324)).toBe("5e-324");
  expect(sep(1e21)).toBe("1e+21");
});

it("is applied to typed array elements and format specifiers", () => {
  expect(sep(new Float64Array([1e-7, -1e-7]))).toBe("Float64Array(2) [ 1e-7, -1e-7 ]");
  expect(sep(new Float64Array([-0.5, 1234.5678]))).toBe("Float64Array(2) [ -0.5, 1_234.567_8 ]");
  expect(util.formatWithOptions({ numericSeparator: true }, "%d", -1e-7)).toBe("-1e-7");
});

The old integer-length split corrupted these: 1e-7 became "0.-7", -1e-7
lost its sign as "0.e-7", and 1.23456e-7 became "0.234_56e_-7".
@0xfandom

Copy link
Copy Markdown
Author

Thanks — added those as test cases in 0397027:

expect(sep(1e-7)).toBe("1e-7");                 // was "0.-7"
expect(sep(-1e-7)).toBe("-1e-7");               // was "0.e-7" (sign dropped)
expect(sep(2e-7)).toBe("2e-7");                 // was "0.-7" (collided with 1e-7)
expect(sep(1.23456e-7)).toBe("1.23456e-7");     // was "0.234_56e_-7"
expect(sep(-0.5)).toBe("-0.5");                 // was "0..5"
expect(sep(1234567891234567891234)).toBe("1.234567891234568e+21");

Also added NaN/±Infinity cases. Note the exponent forms come back unseparated rather than with separators spliced into the mantissa — Node has no defined behavior here (its own formatNumber corrupts them too, and there are no exponent cases in the vendored util-inspect suite), so the e guard just returns the raw string instead of producing something wrong.

util.test.js + the vendored node inspect suite: 203 pass / 0 fail.

@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 `@test/js/node/util/util.test.js`:
- Around line 462-463: Replace the explanatory comments on the affected
regression-test lines with exactly the issue URL comment specified in the
review, including the same URL and formatting; do not retain the existing
explanation.
🪄 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 Plus

Run ID: 081c2901-1ce6-4e27-a620-1398d07377e8

📥 Commits

Reviewing files that changed from the base of the PR and between 25dc85e and 0397027.

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

Comment thread test/js/node/util/util.test.js Outdated
Comment on lines +462 to +463
// The old integer-length split corrupted these: 1e-7 became "0.-7",
// -1e-7 lost its sign as "0.e-7", and 1.23456e-7 became "0.234_56e_-7".

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the issue URL as the regression-test comment.

Replace the explanatory comments on Lines 462-463 and 469 with exactly:

-      // The old integer-length split corrupted these: 1e-7 became "0.-7",
-      // -1e-7 lost its sign as "0.e-7", and 1.23456e-7 became "0.234_56e_-7".
+      // https://github.com/oven-sh/bun/issues/23098
...
-      // Integers large enough to stringify in exponent form take the same path.

As per coding guidelines, regression tests must use exactly the issue URL comment.

Also applies to: 469-469

🤖 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 `@test/js/node/util/util.test.js` around lines 462 - 463, Replace the
explanatory comments on the affected regression-test lines with exactly the
issue URL comment specified in the review, including the same URL and
formatting; do not retain the existing explanation.

Source: Coding guidelines

@0xfandom

Copy link
Copy Markdown
Author

Addressed in 869e1c4 — swapped the explanatory comments for the issue URL (https://github.com/oven-sh/bun/issues/23098) on the regression cases.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Triage note: #36501 was a second copy of this change (same formatNumber diff, same test block, adopted from this PR), so I closed it in favor of this one. This branch still merges cleanly onto current main, and main is still unfixed (util.inspect(-0.1234, { numericSeparator: true }) prints 0..12_34), so this is the PR to land for #23098.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants