From 60d1fd3e0f5cdd0ff57a564036d564f91726c0df Mon Sep 17 00:00:00 2001 From: max Date: Wed, 1 Jul 2026 09:59:38 +0200 Subject: [PATCH 1/5] fix: keep the source of numbers that overflow to Infinity --- src/stringify/stringifyNumber.ts | 10 +++++++++- tests/doc/types.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/stringify/stringifyNumber.ts b/src/stringify/stringifyNumber.ts index 6b9b7dcd..7bd37d05 100644 --- a/src/stringify/stringifyNumber.ts +++ b/src/stringify/stringifyNumber.ts @@ -3,12 +3,20 @@ import type { Scalar } from '../nodes/Scalar.ts' export function stringifyNumber({ format, minFractionDigits, + source, tag, value }: Scalar): string { if (typeof value === 'bigint') return String(value) const num = typeof value === 'number' ? value : Number(value) - if (!isFinite(num)) return isNaN(num) ? '.nan' : num < 0 ? '-.inf' : '.inf' + if (!isFinite(num)) { + // A number whose magnitude overflows the JS Number range (e.g. a git sha like + // `61e9540`) resolves to Infinity but keeps its original source. Emit that + // source rather than losing the value to `.inf`, as long as it still round-trips + // to the current value (so a later programmatic edit is not masked by stale source). + if (source && Number(source) === num) return source + return isNaN(num) ? '.nan' : num < 0 ? '-.inf' : '.inf' + } let n = Object.is(value, -0) ? '-0' : JSON.stringify(value) if ( !format && diff --git a/tests/doc/types.ts b/tests/doc/types.ts index ddda9ff0..d610414c 100644 --- a/tests/doc/types.ts +++ b/tests/doc/types.ts @@ -14,7 +14,7 @@ import { YAMLMap, YAMLSeq } from 'yaml' -import { seqTag, stringifyString, stringTag } from 'yaml/util' +import { seqTag, stringifyNumber, stringifyString, stringTag } from 'yaml/util' import { source } from '../_utils.ts' const parseDocument = ( @@ -262,6 +262,36 @@ describe('number types', () => { expect(doc.value[5]).not.toHaveProperty('minFractionDigits') }) }) + + describe('overflowing values keep their source (#660)', () => { + test('float overflowing to Infinity round-trips via its source', () => { + expect(String(parseDocument('gitsha: 61e9540'))).toBe('gitsha: 61e9540\n') + }) + + test('negative overflow keeps its source', () => { + expect(String(parseDocument('n: -61e9540'))).toBe('n: -61e9540\n') + }) + + test('literal .inf / .nan are still emitted as such', () => { + expect(String(parseDocument('n: .inf'))).toBe('n: .inf\n') + expect(String(parseDocument('n: -.inf'))).toBe('n: -.inf\n') + expect(String(parseDocument('n: .nan'))).toBe('n: .nan\n') + }) + + test('a programmatic Infinity with no source is emitted as .inf', () => { + const doc = parseDocument('n: 1') + doc.set('n', Infinity) + expect(String(doc)).toBe('n: .inf\n') + }) + + test('the source is used only while it still resolves to the value', () => { + const node = new Scalar(Infinity) + node.source = '61e9540' // overflows to Infinity → matches, so it is used + expect(stringifyNumber(node)).toBe('61e9540') + node.source = '5' // stale after a value edit: resolves to 5, not Infinity + expect(stringifyNumber(node)).toBe('.inf') + }) + }) }) test('Indented sequence with sequence values (#2)', () => { From c6b2d01b6a6d82c502739f55ba690bd4cf48d8d7 Mon Sep 17 00:00:00 2001 From: max Date: Thu, 9 Jul 2026 17:15:32 +0200 Subject: [PATCH 2/5] fix: make Scalar.source round-trip version-aware and value-gated Address review on #701: - gate source reuse on `typeof value === "number"` so a later value edit is not masked by stale source - parse with `parseFloat` (matching the resolvers) instead of `Number` - add optional `version` arg to `stringifyNumber` (default 1.2); YAML 1.1 callers pass "1.1" so `_` digit separators are stripped before comparison - trim the code comment --- src/schema/core/float.ts | 4 ++-- src/schema/core/int.ts | 2 +- src/schema/yaml-1.1/float.ts | 6 +++--- src/schema/yaml-1.1/int.ts | 2 +- src/stringify/stringifyNumber.ts | 24 ++++++++++++------------ tests/doc/types.ts | 12 ++++++++++++ 6 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/schema/core/float.ts b/src/schema/core/float.ts index 56146a48..59312b11 100644 --- a/src/schema/core/float.ts +++ b/src/schema/core/float.ts @@ -13,7 +13,7 @@ export const floatNaN: ScalarTag = { : str[0] === '-' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY, - stringify: stringifyNumber + stringify: node => stringifyNumber(node) } export const floatExp: ScalarTag = { @@ -41,5 +41,5 @@ export const float: ScalarTag = { node.minFractionDigits = str.length - dot - 1 return node }, - stringify: stringifyNumber + stringify: node => stringifyNumber(node) } diff --git a/src/schema/core/int.ts b/src/schema/core/int.ts index a5bcd2ce..6ecaf325 100644 --- a/src/schema/core/int.ts +++ b/src/schema/core/int.ts @@ -35,7 +35,7 @@ export const int: ScalarTag = { tag: 'tag:yaml.org,2002:int', test: /^[-+]?[0-9]+$/, resolve: (str, _onError, opt) => intResolve(str, 0, 10, opt), - stringify: stringifyNumber + stringify: node => stringifyNumber(node) } export const intHex: ScalarTag = { diff --git a/src/schema/yaml-1.1/float.ts b/src/schema/yaml-1.1/float.ts index 88ea3baf..cf814d68 100644 --- a/src/schema/yaml-1.1/float.ts +++ b/src/schema/yaml-1.1/float.ts @@ -13,7 +13,7 @@ export const floatNaN: ScalarTag = { : str[0] === '-' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY, - stringify: stringifyNumber + stringify: node => stringifyNumber(node, '1.1') } export const floatExp: ScalarTag = { @@ -25,7 +25,7 @@ export const floatExp: ScalarTag = { resolve: (str: string) => parseFloat(str.replace(/_/g, '')), stringify(node) { const num = Number(node.value) - return isFinite(num) ? num.toExponential() : stringifyNumber(node) + return isFinite(num) ? num.toExponential() : stringifyNumber(node, '1.1') } } @@ -43,5 +43,5 @@ export const float: ScalarTag = { } return node }, - stringify: stringifyNumber + stringify: node => stringifyNumber(node, '1.1') } diff --git a/src/schema/yaml-1.1/int.ts b/src/schema/yaml-1.1/int.ts index 68b6450a..68a85053 100644 --- a/src/schema/yaml-1.1/int.ts +++ b/src/schema/yaml-1.1/int.ts @@ -72,7 +72,7 @@ export const int: ScalarTag = { test: /^[-+]?[0-9][0-9_]*$/, resolve: (str: string, _onError: unknown, opt: ParseOptions) => intResolve(str, 0, 10, opt), - stringify: stringifyNumber + stringify: node => stringifyNumber(node, '1.1') } export const intHex: ScalarTag = { diff --git a/src/stringify/stringifyNumber.ts b/src/stringify/stringifyNumber.ts index 7bd37d05..f58b166b 100644 --- a/src/stringify/stringifyNumber.ts +++ b/src/stringify/stringifyNumber.ts @@ -1,20 +1,20 @@ import type { Scalar } from '../nodes/Scalar.ts' -export function stringifyNumber({ - format, - minFractionDigits, - source, - tag, - value -}: Scalar): string { +export function stringifyNumber( + { format, minFractionDigits, source, tag, value }: Scalar, + version: '1.1' | '1.2' = '1.2' +): string { if (typeof value === 'bigint') return String(value) const num = typeof value === 'number' ? value : Number(value) if (!isFinite(num)) { - // A number whose magnitude overflows the JS Number range (e.g. a git sha like - // `61e9540`) resolves to Infinity but keeps its original source. Emit that - // source rather than losing the value to `.inf`, as long as it still round-trips - // to the current value (so a later programmatic edit is not masked by stale source). - if (source && Number(source) === num) return source + // Preserve the original source of a number that overflows to Infinity (e.g. + // `61e9540`) as long as it still parses back to the current value. + if ( + typeof value === 'number' && + source && + parseFloat(version === '1.1' ? source.replace(/_/g, '') : source) === num + ) + return source return isNaN(num) ? '.nan' : num < 0 ? '-.inf' : '.inf' } let n = Object.is(value, -0) ? '-0' : JSON.stringify(value) diff --git a/tests/doc/types.ts b/tests/doc/types.ts index d610414c..77350973 100644 --- a/tests/doc/types.ts +++ b/tests/doc/types.ts @@ -284,6 +284,13 @@ describe('number types', () => { expect(String(doc)).toBe('n: .inf\n') }) + test('editing the value of a node parsed from a non-finite source is reflected', () => { + const doc = parseDocument('n: 61e9540') // value Infinity, source '61e9540' + const node = doc.get('n', true) as Scalar + node.value = -Infinity // stale source no longer resolves to the value + expect(String(doc)).toBe('n: -.inf\n') + }) + test('the source is used only while it still resolves to the value', () => { const node = new Scalar(Infinity) node.source = '61e9540' // overflows to Infinity → matches, so it is used @@ -291,6 +298,11 @@ describe('number types', () => { node.source = '5' // stale after a value edit: resolves to 5, not Infinity expect(stringifyNumber(node)).toBe('.inf') }) + + test('a YAML 1.1 float with underscores that overflows keeps its source', () => { + const doc = parseDocument('n: 6_1e9540', { version: '1.1' }) + expect(String(doc)).toBe('n: 6_1e9540\n') + }) }) }) From c3951aaab85233963bdf908513cf435c7b39d761 Mon Sep 17 00:00:00 2001 From: max Date: Fri, 10 Jul 2026 00:47:39 +0200 Subject: [PATCH 3/5] refactor: simplify overflow-source guard; use v3 doc.get in test Address review: extract the version-normalised source into a local so the non-finite guard reads more clearly, and drop the removed second argument of Document.get() in the new test (v3 returns the value node directly). --- src/stringify/stringifyNumber.ts | 10 ++++------ tests/doc/types.ts | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/stringify/stringifyNumber.ts b/src/stringify/stringifyNumber.ts index f58b166b..3b2d19a6 100644 --- a/src/stringify/stringifyNumber.ts +++ b/src/stringify/stringifyNumber.ts @@ -9,12 +9,10 @@ export function stringifyNumber( if (!isFinite(num)) { // Preserve the original source of a number that overflows to Infinity (e.g. // `61e9540`) as long as it still parses back to the current value. - if ( - typeof value === 'number' && - source && - parseFloat(version === '1.1' ? source.replace(/_/g, '') : source) === num - ) - return source + if (typeof value === 'number' && source) { + const source_ = version === '1.1' ? source.replace(/_/g, '') : source + if (parseFloat(source_) === num) return source + } return isNaN(num) ? '.nan' : num < 0 ? '-.inf' : '.inf' } let n = Object.is(value, -0) ? '-0' : JSON.stringify(value) diff --git a/tests/doc/types.ts b/tests/doc/types.ts index 77350973..3c29da6c 100644 --- a/tests/doc/types.ts +++ b/tests/doc/types.ts @@ -286,7 +286,7 @@ describe('number types', () => { test('editing the value of a node parsed from a non-finite source is reflected', () => { const doc = parseDocument('n: 61e9540') // value Infinity, source '61e9540' - const node = doc.get('n', true) as Scalar + const node = doc.get('n') as Scalar node.value = -Infinity // stale source no longer resolves to the value expect(String(doc)).toBe('n: -.inf\n') }) From 287938bfd0ab51a95c3ca33049dd8b9aef9f25e3 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 02:46:47 +0200 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?trim=20comments,=20clarify=20overflow-source=20test=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop the comment above the non-finite guard (it also covers NaN, so it was misleading) - rename the "no source" test to "finite source": doc.set() keeps the parsed source, so the node's source is finite ('1'), not absent - drop the inline comments in the source-still-resolves test --- src/stringify/stringifyNumber.ts | 2 -- tests/doc/types.ts | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/stringify/stringifyNumber.ts b/src/stringify/stringifyNumber.ts index 3b2d19a6..66dfde04 100644 --- a/src/stringify/stringifyNumber.ts +++ b/src/stringify/stringifyNumber.ts @@ -7,8 +7,6 @@ export function stringifyNumber( if (typeof value === 'bigint') return String(value) const num = typeof value === 'number' ? value : Number(value) if (!isFinite(num)) { - // Preserve the original source of a number that overflows to Infinity (e.g. - // `61e9540`) as long as it still parses back to the current value. if (typeof value === 'number' && source) { const source_ = version === '1.1' ? source.replace(/_/g, '') : source if (parseFloat(source_) === num) return source diff --git a/tests/doc/types.ts b/tests/doc/types.ts index 3c29da6c..49eea369 100644 --- a/tests/doc/types.ts +++ b/tests/doc/types.ts @@ -278,7 +278,7 @@ describe('number types', () => { expect(String(parseDocument('n: .nan'))).toBe('n: .nan\n') }) - test('a programmatic Infinity with no source is emitted as .inf', () => { + test('a programmatic Infinity with finite source is emitted as .inf', () => { const doc = parseDocument('n: 1') doc.set('n', Infinity) expect(String(doc)).toBe('n: .inf\n') @@ -293,9 +293,9 @@ describe('number types', () => { test('the source is used only while it still resolves to the value', () => { const node = new Scalar(Infinity) - node.source = '61e9540' // overflows to Infinity → matches, so it is used + node.source = '61e9540' expect(stringifyNumber(node)).toBe('61e9540') - node.source = '5' // stale after a value edit: resolves to 5, not Infinity + node.source = '5' expect(stringifyNumber(node)).toBe('.inf') }) From fea6c0c15fcda2e4962a425d56f09c22b90aeb04 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Mon, 13 Jul 2026 13:52:08 +0300 Subject: [PATCH 5/5] Fix the comment fix your LLM missed --- tests/doc/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/doc/types.ts b/tests/doc/types.ts index 49eea369..89416a82 100644 --- a/tests/doc/types.ts +++ b/tests/doc/types.ts @@ -285,9 +285,9 @@ describe('number types', () => { }) test('editing the value of a node parsed from a non-finite source is reflected', () => { - const doc = parseDocument('n: 61e9540') // value Infinity, source '61e9540' + const doc = parseDocument('n: 61e9540') const node = doc.get('n') as Scalar - node.value = -Infinity // stale source no longer resolves to the value + node.value = -Infinity expect(String(doc)).toBe('n: -.inf\n') })