Skip to content

fix(json): encode non-finite numbers as strings in stringify - #4212

Open
bobzhang wants to merge 1 commit into
mainfrom
hongbo/json-nonfinite-stringify
Open

fix(json): encode non-finite numbers as strings in stringify#4212
bobzhang wants to merge 1 commit into
mainfrom
hongbo/json-nonfinite-stringify

Conversation

@bobzhang

@bobzhang bobzhang commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Problem

Json::stringify wrote a Number holding NaN or an infinity as a bare token:

Json::number(@double.not_a_number).stringify()  // NaN
Json::number(@double.infinity).stringify()      // Infinity
Json::number(@double.neg_infinity).stringify()  // -Infinity

None of those are JSON — no RFC 8259 parser reads them back, @json.parse included.

Fix

Encode the three non-finite values the way Double::to_json already does — as the strings "NaN", "Infinity" and "-Infinity" — which is exactly the set Double::from_json accepts, so the value survives a round trip:

let inf = Json::number(@double.infinity)
inspect(inf.stringify(), content=(#|"Infinity"))
let back : Double = @json.from_json(@json.parse(inf.stringify()))
inspect(back == @double.infinity, content="true")

Only a directly constructed Number reaches this path. The parser records the source text in repr for every literal that overflows to an infinity (1e400Number(Infinity, repr=Some("1e400"))), and that branch writes repr verbatim without consulting the double — so parsed documents are unaffected, and repr still takes precedence where it is present.

The guard is a tiny #inline entry with the non-finite tail outlined, so inlining it into the stringify loop stays cheap for ordinary numbers.

Docs

The fix leans on the repr contract, which was undocumented (a bare // 1.0000000000000000000e100 comment on the constructor). Now stated on both pub enum Json and Json::number:

  • when present, repr must be valid JSON number syntax denoting the accompanying Doublestringify writes it in place of the double, so text that is not a JSON number makes the output invalid JSON or gives it a different JSON type, and text denoting another value silently changes what the document says;
  • which literals the parser attaches it to: every integer literal past the exact-integer range (whether or not the value lands on one — 9007199254740992 keeps its text too) plus everything overflowing to an infinity, and nothing else (0.1 and 1e-400 get None);
  • equality compares only the numeric value and ignores repr, so two equal Json values can stringify differently.

Verification

  • Mutation-checked: restoring None => buf.write_object(n) fails exactly the two tests added here — the Json::number doc test and stringify non-finite number (3227 passed / 2 failed).
  • moon test: 7529 passed, 0 failed.
  • moon test --target all -p moonbitlang/core/json -p moonbitlang/core/builtin: wasm 3229, wasm-gc 3229, js 3199, native 3188 — all passed.
  • moon fmt, moon check, moon info clean; no .mbti change (both new helpers are private).

Reviewed and signed off by Codex CLI (three rounds; it blocked twice on doc claims that overstated when repr is attached and what a bad repr produces).

Signed-off-by: Codex CLI codex@openai.com

🤖 Generated with Claude Code

https://claude.ai/code/session_01VubGDsJHzgC6ykiq7t4hrY

`Json::stringify` wrote a `Number` holding NaN or an infinity as a bare
`NaN` / `Infinity` / `-Infinity` literal, which is not JSON: no RFC 8259
parser reads it back. Encode those three the way `Double::to_json`
already does, as the strings "NaN", "Infinity" and "-Infinity" — exactly
what `Double::from_json` accepts — so the value survives a round trip.

Only a directly constructed `Number` reaches this path: the parser
records the source text in `repr` for every literal that overflows to an
infinity, and that branch writes `repr` verbatim without consulting the
double. The guard is a tiny entry with the non-finite tail outlined, so
inlining it into the `stringify` loop stays cheap for ordinary numbers.

Also document the `repr` contract this relies on: when present, `repr`
must be valid JSON number syntax denoting the accompanying `Double`,
since `stringify` writes it in place of the double; which literals the
parser attaches it to (every integer past the exact-integer range,
whether or not it lands on one, plus everything overflowing to an
infinity); and that equality compares only the numeric value, so two
equal `Json` values can stringify differently.

Reverting the `write_number` call fails exactly the two tests added here
(the `Json::number` doc test and `stringify non-finite number`); with it,
7529 pass on wasm-gc and json+builtin pass on all four backends.

Reviewed with Codex CLI: "No blocking issues in the two revised passages.
They correctly distinguish invalid JSON, a different JSON type, and a
mismatched numeric value."

Signed-off-by: Codex CLI <codex@openai.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VubGDsJHzgC6ykiq7t4hrY
Copilot AI lite review requested due to automatic review settings September 8, 2026 04:02

Copilot AI 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.

🟢 Approval recommended

The change is narrowly scoped, preserves existing repr behavior, and is backed by targeted tests validating both correctness and round-trip semantics.

Pull request overview

This PR fixes Json::stringify so that Json::number values holding non-finite Doubles (NaN / ±Infinity) are emitted as valid JSON by encoding them as strings ("NaN", "Infinity", "-Infinity"), matching the existing Double JSON round-trip behavior and preserving the existing repr precedence contract.

Changes:

  • Add a small write_number helper in json/json.mbt that routes non-finite values to a dedicated encoder while keeping the common finite-number path fast.
  • Add tests covering non-finite number stringification and round-tripping through @json.parse + @json.from_json.
  • Document the repr contract and clarify non-finite behavior in builtin/json.mbt (including a doc example).
File summaries
File Description
json/json.mbt Routes Number stringification through a non-finite guard to ensure valid JSON output when repr is absent.
json/json_test.mbt Adds coverage for NaN/±Infinity stringification and round-trip decoding; asserts repr still wins when present.
builtin/json.mbt Documents Number.repr semantics, parser attachment cases, and the non-finite stringify behavior for Json::number.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 6583

Coverage increased (+0.005%) to 89.275%

Details

  • Coverage increased (+0.005%) from the base build.
  • Patch coverage: 9 of 9 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 18340
Covered Lines: 16373
Line Coverage: 89.27%
Coverage Strength: 273780.49 hits per line

💛 - Coveralls

Comment thread json/json.mbt
}

///|
fn write_non_finite_number(buf : StringBuilder, number : Double) -> Unit {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inine this function?

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.

3 participants