From eb5cb5b846446b7bd2d416f36ffdd5824da81cad Mon Sep 17 00:00:00 2001 From: spokodev Date: Fri, 26 Jun 2026 16:30:53 +0100 Subject: [PATCH 1/2] fix: round-trip integers that stringify in exponential notation (#771) Integers at or above 1e21 (and Number.MAX_VALUE) stringify via Number.prototype.toString(10) in exponential notation, e.g. 1e21 -> "1e+21". The int tags identified these numbers (1e21 % 1 === 0) and represented them with that exponential text, producing output like `!!int '1e+21'`. That text matches no `!!int` resolution pattern, so loading the library's own dump output threw "cannot resolve a node with ! explicit tag". Per YAML 1.2.2 the Core (10.3) and JSON (10.2) `!!int` resolution accepts only [-+]?[0-9]+ (plus 0o/0x forms); exponential text is a float, not an int. Make the int tags reject numbers whose decimal stringification contains an exponent, and let the float tags claim them instead. The float `represent` already emits a valid float form (1.e+21) for such values, so they now round-trip. Restores the "numbers survive round-trip" invariant (#737) for large integers across the default, Core, JSON and YAML 1.1 schemas. --- src/tag/scalar/float_core.ts | 5 ++++- src/tag/scalar/float_json.ts | 5 ++++- src/tag/scalar/float_yaml11.ts | 5 ++++- src/tag/scalar/int_core.ts | 4 +++- src/tag/scalar/int_json.ts | 4 +++- src/tag/scalar/int_yaml11.ts | 4 +++- test/core/tags/int.test.mjs | 11 +++++++++++ 7 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/tag/scalar/float_core.ts b/src/tag/scalar/float_core.ts index 9293717b..ff58deda 100644 --- a/src/tag/scalar/float_core.ts +++ b/src/tag/scalar/float_core.ts @@ -51,7 +51,10 @@ const floatCoreTag = defineScalarTag('tag:yaml.org,2002:float', { implicitFirstChars: ['-', '+', '.', ...'0123456789'], resolve: resolveYamlFloat, identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - (object % 1 !== 0 || Object.is(object, -0)), + // Also claim integer-valued numbers that stringify in exponential notation + // (>= 1e21), since their `!!int` text would be invalid; `represent` emits a + // valid float form for them. + (object % 1 !== 0 || Object.is(object, -0) || /e/i.test(object.toString(10))), represent: representYamlFloat }) diff --git a/src/tag/scalar/float_json.ts b/src/tag/scalar/float_json.ts index 8c28e557..bba17355 100644 --- a/src/tag/scalar/float_json.ts +++ b/src/tag/scalar/float_json.ts @@ -57,7 +57,10 @@ const floatJsonTag = defineScalarTag('tag:yaml.org,2002:float', { implicitFirstChars: ['-', ...'0123456789'], resolve: resolveYamlFloat, identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - (object % 1 !== 0 || Object.is(object, -0)), + // Also claim integer-valued numbers that stringify in exponential notation + // (>= 1e21), since their `!!int` text would be invalid; `represent` emits a + // valid float form for them. + (object % 1 !== 0 || Object.is(object, -0) || /e/i.test(object.toString(10))), represent: representYamlFloat }) diff --git a/src/tag/scalar/float_yaml11.ts b/src/tag/scalar/float_yaml11.ts index ec4b616b..7ce7bbd9 100644 --- a/src/tag/scalar/float_yaml11.ts +++ b/src/tag/scalar/float_yaml11.ts @@ -58,7 +58,10 @@ const floatYaml11Tag = defineScalarTag('tag:yaml.org,2002:float', { implicitFirstChars: ['-', '+', '.', ...'0123456789'], resolve: resolveYamlFloat, identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - (object % 1 !== 0 || Object.is(object, -0)), + // Also claim integer-valued numbers that stringify in exponential notation + // (>= 1e21), since their `!!int` text would be invalid; `represent` emits a + // valid float form for them. + (object % 1 !== 0 || Object.is(object, -0) || /e/i.test(object.toString(10))), represent: representYamlFloat }) diff --git a/src/tag/scalar/int_core.ts b/src/tag/scalar/int_core.ts index 573091c9..8783622a 100644 --- a/src/tag/scalar/int_core.ts +++ b/src/tag/scalar/int_core.ts @@ -54,7 +54,9 @@ const intCoreTag = defineScalarTag('tag:yaml.org,2002:int', { implicitFirstChars: ['-', '+', ...'0123456789'], resolve: resolveYamlInteger, identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - (object % 1 === 0 && !Object.is(object, -0)), + // Large integers (>= 1e21) stringify in exponential notation, which is not + // valid `!!int` text. Reject them here so they fall through to the float tag. + (object % 1 === 0 && !Object.is(object, -0) && !/e/i.test(object.toString(10))), represent: (object: number) => object.toString(10) }) diff --git a/src/tag/scalar/int_json.ts b/src/tag/scalar/int_json.ts index c4d9eddf..1f90ed64 100644 --- a/src/tag/scalar/int_json.ts +++ b/src/tag/scalar/int_json.ts @@ -49,7 +49,9 @@ const intJsonTag = defineScalarTag('tag:yaml.org,2002:int', { implicitFirstChars: ['-', ...'0123456789'], resolve: resolveYamlInteger, identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - (object % 1 === 0 && !Object.is(object, -0)), + // Large integers (>= 1e21) stringify in exponential notation, which is not + // valid `!!int` text. Reject them here so they fall through to the float tag. + (object % 1 === 0 && !Object.is(object, -0) && !/e/i.test(object.toString(10))), represent: (object: number) => object.toString(10) }) diff --git a/src/tag/scalar/int_yaml11.ts b/src/tag/scalar/int_yaml11.ts index 84263338..34488243 100644 --- a/src/tag/scalar/int_yaml11.ts +++ b/src/tag/scalar/int_yaml11.ts @@ -48,7 +48,9 @@ const intYaml11Tag = defineScalarTag('tag:yaml.org,2002:int', { implicitFirstChars: ['-', '+', ...'0123456789'], resolve: resolveYamlInteger, identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - (object % 1 === 0 && !Object.is(object, -0)), + // Large integers (>= 1e21) stringify in exponential notation, which is not + // valid `!!int` text. Reject them here so they fall through to the float tag. + (object % 1 === 0 && !Object.is(object, -0) && !/e/i.test(object.toString(10))), represent: (object: number) => object.toString(10) }) diff --git a/test/core/tags/int.test.mjs b/test/core/tags/int.test.mjs index 5a4247fe..6052960a 100644 --- a/test/core/tags/int.test.mjs +++ b/test/core/tags/int.test.mjs @@ -39,6 +39,17 @@ describe('tags/int', () => { assert.deepStrictEqual(load(dump(expected, { schema }), { schema }), expected) }) + it(`${name} round-trip of large integers`, () => { + // Integers at or above 1e21 stringify in exponential notation + // ('1e+21'), which is not valid `!!int` text. They must round-trip + // through the float tag rather than being dumped as `!!int '1e+21'`. + const large = [1e21, 1.5e21, -1e21, 1e100, Number.MAX_VALUE] + + for (const value of large) { + assert.strictEqual(load(dump(value, { schema }), { schema }), value) + } + }) + it(`${name} fail explicit tag`, () => { assert.throws(() => load('!!int 1.5', { schema }), /cannot resolve/) }) From 39d00d65eb6b88362a5c806cea57541e687aaccb Mon Sep 17 00:00:00 2001 From: Vitaly Puzrin Date: Fri, 26 Jun 2026 19:52:24 +0300 Subject: [PATCH 2/2] numbers: Drop boxed numbers support, simplify .identify() checks, clarify round-trip test (ref #771) --- src/tag/scalar/float_core.ts | 19 ++++++++++++++----- src/tag/scalar/float_json.ts | 19 ++++++++++++++----- src/tag/scalar/float_yaml11.ts | 19 ++++++++++++++----- src/tag/scalar/int_core.ts | 11 +++++++---- src/tag/scalar/int_json.ts | 11 +++++++---- src/tag/scalar/int_yaml11.ts | 11 +++++++---- test/core/tags/int.test.mjs | 18 ++++++++++++++---- 7 files changed, 77 insertions(+), 31 deletions(-) diff --git a/src/tag/scalar/float_core.ts b/src/tag/scalar/float_core.ts index ff58deda..b7a18312 100644 --- a/src/tag/scalar/float_core.ts +++ b/src/tag/scalar/float_core.ts @@ -50,11 +50,20 @@ const floatCoreTag = defineScalarTag('tag:yaml.org,2002:float', { // ('.inf'/'.nan' start with '.'). implicitFirstChars: ['-', '+', '.', ...'0123456789'], resolve: resolveYamlFloat, - identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - // Also claim integer-valued numbers that stringify in exponential notation - // (>= 1e21), since their `!!int` text would be invalid; `represent` emits a - // valid float form for them. - (object % 1 !== 0 || Object.is(object, -0) || /e/i.test(object.toString(10))), + identify: (object) => + // No ancient boxed numbers support + typeof object === 'number' && + ( + // We land here all numbers, not handled (declined) by !!int `.identify` + // The same condition as for !!int, but reversed. + + // Filter out integers... + !Number.isInteger(object) || + // but allow negative zero + Object.is(object, -0) || + // and integers with exponential form + object.toString(10).indexOf('e') >= 0 + ), represent: representYamlFloat }) diff --git a/src/tag/scalar/float_json.ts b/src/tag/scalar/float_json.ts index bba17355..ad222630 100644 --- a/src/tag/scalar/float_json.ts +++ b/src/tag/scalar/float_json.ts @@ -56,11 +56,20 @@ const floatJsonTag = defineScalarTag('tag:yaml.org,2002:float', { // Superset of source.charAt(0) over all matched inputs: optional '-' or digit. implicitFirstChars: ['-', ...'0123456789'], resolve: resolveYamlFloat, - identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - // Also claim integer-valued numbers that stringify in exponential notation - // (>= 1e21), since their `!!int` text would be invalid; `represent` emits a - // valid float form for them. - (object % 1 !== 0 || Object.is(object, -0) || /e/i.test(object.toString(10))), + identify: (object) => + // No ancient boxed numbers support + typeof object === 'number' && + ( + // We land here all numbers, not handled (declined) by !!int `.identify` + // The same condition as for !!int, but reversed. + + // Filter out integers... + !Number.isInteger(object) || + // but allow negative zero + Object.is(object, -0) || + // and integers with exponential form + object.toString(10).indexOf('e') >= 0 + ), represent: representYamlFloat }) diff --git a/src/tag/scalar/float_yaml11.ts b/src/tag/scalar/float_yaml11.ts index 7ce7bbd9..6fd27e64 100644 --- a/src/tag/scalar/float_yaml11.ts +++ b/src/tag/scalar/float_yaml11.ts @@ -57,11 +57,20 @@ const floatYaml11Tag = defineScalarTag('tag:yaml.org,2002:float', { // ('.inf'/'.nan' start with '.'). implicitFirstChars: ['-', '+', '.', ...'0123456789'], resolve: resolveYamlFloat, - identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - // Also claim integer-valued numbers that stringify in exponential notation - // (>= 1e21), since their `!!int` text would be invalid; `represent` emits a - // valid float form for them. - (object % 1 !== 0 || Object.is(object, -0) || /e/i.test(object.toString(10))), + identify: (object) => + // No ancient boxed numbers support + typeof object === 'number' && + ( + // We land here all numbers, not handled (declined) by !!int `.identify` + // The same condition as for !!int, but reversed. + + // Filter out integers... + !Number.isInteger(object) || + // but allow negative zero + Object.is(object, -0) || + // and integers with exponential form + object.toString(10).indexOf('e') >= 0 + ), represent: representYamlFloat }) diff --git a/src/tag/scalar/int_core.ts b/src/tag/scalar/int_core.ts index 8783622a..e097e8bf 100644 --- a/src/tag/scalar/int_core.ts +++ b/src/tag/scalar/int_core.ts @@ -53,10 +53,13 @@ const intCoreTag = defineScalarTag('tag:yaml.org,2002:int', { // Superset of source.charAt(0) over all matched inputs: optional sign + decimal digit. implicitFirstChars: ['-', '+', ...'0123456789'], resolve: resolveYamlInteger, - identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - // Large integers (>= 1e21) stringify in exponential notation, which is not - // valid `!!int` text. Reject them here so they fall through to the float tag. - (object % 1 === 0 && !Object.is(object, -0) && !/e/i.test(object.toString(10))), + identify: (object) => + // No ancient boxed numbers support + Number.isInteger(object) && + // Negative zero => !!float + !Object.is(object, -0) && + // Exponential form => !!float, round-trip for !!int 1e21 will be broken + object.toString(10).indexOf('e') < 0, represent: (object: number) => object.toString(10) }) diff --git a/src/tag/scalar/int_json.ts b/src/tag/scalar/int_json.ts index 1f90ed64..393292c9 100644 --- a/src/tag/scalar/int_json.ts +++ b/src/tag/scalar/int_json.ts @@ -48,10 +48,13 @@ const intJsonTag = defineScalarTag('tag:yaml.org,2002:int', { // Superset of source.charAt(0) over all matched inputs: optional '-' or digit. implicitFirstChars: ['-', ...'0123456789'], resolve: resolveYamlInteger, - identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - // Large integers (>= 1e21) stringify in exponential notation, which is not - // valid `!!int` text. Reject them here so they fall through to the float tag. - (object % 1 === 0 && !Object.is(object, -0) && !/e/i.test(object.toString(10))), + identify: (object) => + // No ancient boxed numbers support + Number.isInteger(object) && + // Negative zero => !!float + !Object.is(object, -0) && + // Exponential form => !!float, round-trip for !!int 1e21 will be broken + object.toString(10).indexOf('e') < 0, represent: (object: number) => object.toString(10) }) diff --git a/src/tag/scalar/int_yaml11.ts b/src/tag/scalar/int_yaml11.ts index 34488243..a4c30326 100644 --- a/src/tag/scalar/int_yaml11.ts +++ b/src/tag/scalar/int_yaml11.ts @@ -47,10 +47,13 @@ const intYaml11Tag = defineScalarTag('tag:yaml.org,2002:int', { // Superset of source.charAt(0) over all matched inputs: optional sign + decimal digit. implicitFirstChars: ['-', '+', ...'0123456789'], resolve: resolveYamlInteger, - identify: (object) => Object.prototype.toString.call(object) === '[object Number]' && - // Large integers (>= 1e21) stringify in exponential notation, which is not - // valid `!!int` text. Reject them here so they fall through to the float tag. - (object % 1 === 0 && !Object.is(object, -0) && !/e/i.test(object.toString(10))), + identify: (object) => + // No ancient boxed numbers support + Number.isInteger(object) && + // Negative zero => !!float + !Object.is(object, -0) && + // Exponential form => !!float, round-trip for !!int 1e21 will be broken + object.toString(10).indexOf('e') < 0, represent: (object: number) => object.toString(10) }) diff --git a/test/core/tags/int.test.mjs b/test/core/tags/int.test.mjs index 6052960a..68c26487 100644 --- a/test/core/tags/int.test.mjs +++ b/test/core/tags/int.test.mjs @@ -43,11 +43,21 @@ describe('tags/int', () => { // Integers at or above 1e21 stringify in exponential notation // ('1e+21'), which is not valid `!!int` text. They must round-trip // through the float tag rather than being dumped as `!!int '1e+21'`. - const large = [1e21, 1.5e21, -1e21, 1e100, Number.MAX_VALUE] - for (const value of large) { - assert.strictEqual(load(dump(value, { schema }), { schema }), value) - } + // Should round-trip as !!int + assert.strictEqual(dump(1e20, { schema }), '100000000000000000000\n') + assert.strictEqual(load(dump(1e20, { schema }), { schema }), 1e20) + assert.strictEqual(dump(-1e20, { schema }), '-100000000000000000000\n') + assert.strictEqual(load(dump(-1e20, { schema }), { schema }), -1e20) + + // Should round-trip as !!float + assert.strictEqual(dump(1e21, { schema }), '1.e+21\n') + assert.strictEqual(load(dump(1e21, { schema }), { schema }), 1e21) + assert.strictEqual(dump(-1e21, { schema }), '-1.e+21\n') + assert.strictEqual(load(dump(-1e21, { schema }), { schema }), -1e21) + + assert.strictEqual(load(dump(Number.MAX_VALUE, { schema }), { schema }), Number.MAX_VALUE) + assert.strictEqual(load(dump(-Number.MAX_VALUE, { schema }), { schema }), -Number.MAX_VALUE) }) it(`${name} fail explicit tag`, () => {