From 8c07262f5eec61fbd8a40f63ac0b6b9f99a0be92 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:23:11 -0400 Subject: [PATCH 01/36] test: add TODO for preserving original formatting of inline table in patch test --- src/__tests__/patch.fuzz.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/__tests__/patch.fuzz.test.ts b/src/__tests__/patch.fuzz.test.ts index 47abe7c8..b64d528d 100644 --- a/src/__tests__/patch.fuzz.test.ts +++ b/src/__tests__/patch.fuzz.test.ts @@ -32,6 +32,8 @@ test('replacing an object in a nested multiline array preserves trailing sibling details: { values: [-2765, new Date(Date.UTC(2016, 6, 12))] } }; + //TODO: Decide if we should consider making the inline table multiline to preserve the + //original formatting of the array. Currently, it is being converted to an single line inline table. const result = patch(src, obj); expect(parse(result)).toEqual(obj); expect(result).toEqual(dedent` From 2237459a6f37ff02b7e4169e7729fe8288b5734a Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:39:37 -0400 Subject: [PATCH 02/36] docs: add fuzz error seeds report for rerun results --- .../fuzz-error-seeds-0-3000000-rerun.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/bug-notes/fuzz-error-seeds-0-3000000-rerun.md diff --git a/docs/bug-notes/fuzz-error-seeds-0-3000000-rerun.md b/docs/bug-notes/fuzz-error-seeds-0-3000000-rerun.md new file mode 100644 index 00000000..9ade56c3 --- /dev/null +++ b/docs/bug-notes/fuzz-error-seeds-0-3000000-rerun.md @@ -0,0 +1,34 @@ +# Fuzz Error Seeds: 0..2,999,999 Rerun + +Source log: [fuzz-sweep-0-3000000-RERUN.md](fuzz-sweep-0-3000000-RERUN.md) + +The rerun reported 16 failures across three one-million-seed ranges. The +`updateOrder` warnings in the source log are not included because they are +expected unsupported-location warnings rather than harness failures. + +## Seeds + +| Seed | Range | Failure | Detail | +| ---: | :--- | :--- | :--- | +| 175924 | 0..999999 | `roundtrip-mismatch` | Re-parse failed at `(79, 1)` | +| 377453 | 0..999999 | `roundtrip-mismatch` | Re-parse failed at `(23, 5)` | +| 771152 | 0..999999 | `roundtrip-mismatch` | Re-parse failed at `(7, 29)` | +| 863664 | 0..999999 | `roundtrip-mismatch` | Re-parse failed at `(51, 5)` | +| 1112646 | 1000000..1999999 | `roundtrip-mismatch` | Re-parse failed at `(122, 22)` | +| 1286183 | 1000000..1999999 | `roundtrip-mismatch` | Re-parse failed at `(42, 274)` | +| 1383962 | 1000000..1999999 | `roundtrip-mismatch` | Re-parse failed at `(20, 3)` | +| 1693919 | 1000000..1999999 | `roundtrip-mismatch` | Re-parse failed at `(15, 101)` | +| 1896226 | 1000000..1999999 | `roundtrip-mismatch` | Re-parse failed at `(64, 1)` | +| 2185943 | 2000000..2999999 | `roundtrip-mismatch` | Re-parse failed at `(118, 16)` | +| 2497422 | 2000000..2999999 | `roundtrip-mismatch` | Re-parse failed at `(9, 92)` | +| 2531104 | 2000000..2999999 | `roundtrip-mismatch` | Re-parse failed at `(17, 5)` | +| 2591153 | 2000000..2999999 | `patch-fail` | `patch()` threw: Node not found at `AKy:}nV@.p8.(J Date: Sat, 22 Aug 2026 01:45:58 -0400 Subject: [PATCH 03/36] test: add fuzz regression for seed 2591153 --- src/__tests__/patch.fuzz.test.ts | 129 +++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/src/__tests__/patch.fuzz.test.ts b/src/__tests__/patch.fuzz.test.ts index b64d528d..817ff6ef 100644 --- a/src/__tests__/patch.fuzz.test.ts +++ b/src/__tests__/patch.fuzz.test.ts @@ -3369,3 +3369,132 @@ test('converting an AOT into a scalar-first mixed array (seed 136865 alt.2)', () `); }); + +test.fails('regression for fuzz seed 2591153', () => { + const src = dedent` + gas = true + sz = 178262 + "v^.aq/(w" = 2058-10-24T21:21:43 + mt5434cc."Cx[+"."q(?TYenJR" = """ + &m}Y""" + ix4t8ryk8."hBTJ&l&3bl".yt3z6_ = true + w2 = 2049-04-03T21:46:36.579301 + "R " = 0o150531 + clkd176.gs6 = -579731 + "FkeQ?W".u5oxixqh = "w4*5](T\`Wgjlbi+@wYY2o~" + # yg=|nPERhmd_2Qj;Qu$O^|T2*u + ryhwr.m5tn.gsg1h = 207_795 + p6jzbb677n.uud.fyoqco70y = 90023.48570 + k0cxt83.iwobqz4vi.NQSltlW = 513513 + # nx%JXe@oDPCx$/Iz-dV%:EI)/ + [["".lcmm1m0.vfkm5_p]] + "Wg[" = 0b0011011 + "m4}cz".jnyu = 2080-08-12T23:09:05Z + # |J^H\`E{/un~cEYJoJb + # 7ZBeQqq/ot:" = 258084 + vhashp_7d-."zD_>~7Lf3" = -464765 + "q=y".ojj = """ + iB""" + x7co_.u.AtJ = 754_620 + + [q2c."o!AxxvqZj"] + g9p75i5s_ = ''' + !esM3g7w + }I2t-:Y-|#MD + E] + ,^vU + oEA + w + 0 + ! + 7nGBp + + ''' + cmzsfzz.f3utn728zu = true + "^]jfx9Hz_" = 2053-10-21T07:50:22Z + f6ziybf.egyieof = false + "B3:c%G9".sf_90m7q.ncide1 = 1981-12-01T06:47:08Z + t = 986e-61 + ez."yTxL(kP[j0"."SPCIi(YbYe" = 53780.13362 + cpqdsgprv."%-+WE".x9vi = 2091-11-01T14:04:57 + s-yvw-4f.jes8rouk = 82_668.64849 + + [gspp.e.vg_08p5dgs] + eegf54qq.b3ff_g = true + bttb8h.vk5c."" = [] + dx2feuc.3 = """ + -z + =t9EF) + 6+""" + # ~xsBrSdMAd;mr\`JPYi + yh3l3-g2x.gmw3_.ppq_ep3k3 = """ + = VD95 + v?{gW + j + 1,]OS5""" + f6qrvqct.ny0fj-3 = 367707 + fx = nan + # oO!S;$e^N>g{%,+eJzcq(}kE,@J=je+M + [[qqqk."C<4M\`%W2L"]] + + [[y9wecl5]] + rw3l2q8mk.d-ps = ']zgL)[hSjD.R+' + # i(M^R&yAn3x3pj]ayUh -^ *xi\`1,cQ(~ + qe = 995783 + vag."[Q&%][ 6ap" = 559418 + aam48 = true + + ["AKy:}nV@"] + kl7u = true + # Vu_I\`CdTJh>9oacHXu\`o>|Nt\`=pHltLN + "hG b+T".oyyuk3 = 937467 + r46f8rhrh.rprqr8g = -566314 + p8 = { + uyvk4h."m}5?" = "Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w Date: Sat, 22 Aug 2026 01:59:24 -0400 Subject: [PATCH 04/36] fix: harden multiline patch planning --- src/__tests__/patch.fuzz.test.ts | 2 +- src/diff.ts | 37 +++++---- src/patch.ts | 138 ++++++++++++++++++++++++++++++- 3 files changed, 158 insertions(+), 19 deletions(-) diff --git a/src/__tests__/patch.fuzz.test.ts b/src/__tests__/patch.fuzz.test.ts index 817ff6ef..3c25949d 100644 --- a/src/__tests__/patch.fuzz.test.ts +++ b/src/__tests__/patch.fuzz.test.ts @@ -3370,7 +3370,7 @@ test('converting an AOT into a scalar-first mixed array (seed 136865 alt.2)', () }); -test.fails('regression for fuzz seed 2591153', () => { +test('regression for fuzz seed 2591153', () => { const src = dedent` gas = true sz = 178262 diff --git a/src/diff.ts b/src/diff.ts index dc4629e7..6a8e23f6 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -30,6 +30,8 @@ export function isEdit(change: Change): change is Edit { export interface Remove { type: ChangeType.Remove; path: Path; + /** Internal marker for array removes whose index refers to the original array. */ + coordinate?: 'source'; } export function isRemove(change: Change): change is Remove { return change.type === ChangeType.Remove; @@ -327,6 +329,11 @@ function compareArrays(before: any[], after: any[], path: Path = [], options: Di return false; }; const multilineArray = before.some(hasMultilineValue) || after.some(hasMultilineValue); + const hasInlineObject = (value: any) => + value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); + const hasMixedStringAndObject = (values: any[]) => + values.length > 4 && values.some(value => typeof value === 'string') && values.some(hasInlineObject); + const layoutSensitiveArray = multilineArray || hasMixedStringAndObject(before) || hasMixedStringAndObject(after); // Simulation of the actual VALUES, mutated in lockstep with before_stable. // The "removed -> edited in place" branch below must diff the element the @@ -345,6 +352,15 @@ function compareArrays(before: any[], after: any[], path: Path = [], options: Di // descending index order (see reorder() in patch.ts), so each emitted // index must stay valid against the un-shifted array. let removedBefore = 0; + let sameArrayAddEmitted = false; + + const removeChange = (index: number): Remove => { + const change: Remove = { type: ChangeType.Remove, path: path.concat(index) }; + if (!sameArrayAddEmitted || removedBefore > 0) { + Object.defineProperty(change, 'coordinate', { value: 'source' }); + } + return change; + }; // 2. Step through after array making changes to before array as-needed for (let index = 0; index < after_stable.length; index++) { @@ -383,11 +399,8 @@ function compareArrays(before: any[], after: any[], path: Path = [], options: Di // from a multiline inline array dropped a line of a multiline string; // fuzz seed 35943: removing one of several duplicate scalars above a // nested multiline array chain-moved the array and corrupted its tail). - if (multilineArray && (after_stable.indexOf(before_stable[index]) === -1 || surplusDuplicate)) { - changes.push({ - type: ChangeType.Remove, - path: path.concat(index + removedBefore) - }); + if (layoutSensitiveArray && (after_stable.indexOf(before_stable[index]) === -1 || surplusDuplicate)) { + changes.push(removeChange(index + removedBefore)); before_stable.splice(index, 1); before_sim.splice(index, 1); removedBefore++; @@ -407,10 +420,7 @@ function compareArrays(before: any[], after: any[], path: Path = [], options: Di // `multilineArray` is set, but kept for the non-multiline case where a // prior splice already shifted indices and the surplus is unambiguous.) if (removedBefore > 0 && surplusDuplicate) { - changes.push({ - type: ChangeType.Remove, - path: path.concat(index + removedBefore) - }); + changes.push(removeChange(index + removedBefore)); before_stable.splice(index, 1); before_sim.splice(index, 1); removedBefore++; @@ -425,11 +435,12 @@ function compareArrays(before: any[], after: any[], path: Path = [], options: Di // anyway, and the writer corrupts their content (fuzz seed 62263: a // nested array above a multiline inline table replaced by a duplicate // scalar emitted Remove + Move + Add and mangled the table). - if (multilineArray && removedBefore > 0) { + if (layoutSensitiveArray && removedBefore > 0) { changes.push({ type: ChangeType.Add, path: path.concat(index) }); + sameArrayAddEmitted = true; before_stable.splice(index, 0, value); before_sim.splice(index, 0, after[index]); // The fresh copy is spliced in BEFORE the original (at `from`), so it @@ -498,6 +509,7 @@ function compareArrays(before: any[], after: any[], path: Path = [], options: Di type: ChangeType.Add, path: path.concat(index) }); + sameArrayAddEmitted = true; before_stable.splice(index, 0, value); before_sim.splice(index, 0, after[index]); // Same source-index bookkeeping as the refused-move Add above: splicing a @@ -512,10 +524,7 @@ function compareArrays(before: any[], after: any[], path: Path = [], options: Di // 3. Remove any remaining overflow items for (let i = after_stable.length; i < before_stable.length; i++) { - changes.push({ - type: ChangeType.Remove, - path: path.concat(i + removedBefore) - }); + changes.push(removeChange(i + removedBefore)); } return changes; diff --git a/src/patch.ts b/src/patch.ts index 66d1ca9f..09efc34a 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -366,6 +366,28 @@ function reorder(changes: Change[]): Change[] { if (isAdd(next_change)) { const bIdx = last(next_change.path); if (typeof bIdx === 'number' && arraysEqual(aPrefix, next_change.path.slice(0, -1))) { + // A source-coordinate remove remains valid against the original array and + // therefore has to run before a same-array Add, even when the diff emitted the + // remove after that Add (seed 2591153). Post-shift removes deliberately stay on + // the far side of the Add (seed 1137525). + if (change.coordinate === 'source') { + let sourceRemove = -1; + for (let k = j + 1; k < changes.length; k++) { + const candidate = changes[k]; + if (isRemove(candidate) + && candidate.coordinate === 'source' + && arraysEqual(aPrefix, candidate.path.slice(0, -1))) { + sourceRemove = k; + break; + } + } + if (sourceRemove !== -1) { + const sourceChange = changes.splice(sourceRemove, 1)[0]; + changes.splice(i, 0, sourceChange); + i = -1; + break; + } + } break; } j++; @@ -437,7 +459,6 @@ function reorder(changes: Change[]): Change[] { function coalesceStructuralReplacements(original: Document, updated_js: any, changes: Change[]): Change[] { const consumed = new Set(); const coalescedEdits: Change[] = []; - // Strategy 1: Remove(prefix.oldKey) + Add(prefix.newKey) sharing an // implicit parent with no literal node of its own. const groups = new Map(); @@ -445,7 +466,7 @@ function coalesceStructuralReplacements(original: Document, updated_js: any, cha if (!isRemove(change) && !isAdd(change)) continue; const parentPath = change.path.slice(0, -1); - if (parentPath.length === 0) continue; // never coalesce at the document root itself + if (parentPath.length === 0) continue; const key = JSON.stringify(parentPath); let group = groups.get(key); @@ -457,7 +478,7 @@ function coalesceStructuralReplacements(original: Document, updated_js: any, cha } for (const group of groups.values()) { if (group.removes.length === 0 || group.adds.length === 0) continue; - if (tryFindByPath(original, group.path)) continue; // parent still exists literally + if (tryFindByPath(original, group.path)) continue; group.removes.forEach(change => consumed.add(change)); group.adds.forEach(change => consumed.add(change)); @@ -517,7 +538,6 @@ function coalesceStructuralReplacements(original: Document, updated_js: any, cha const firstEntry = tryFindByPath(original, prefix.concat(0)); if (!firstEntry || !isTableArray(firstEntry)) continue; - arrayPrefixes.set(JSON.stringify(prefix), prefix); } for (const prefix of arrayPrefixes.values()) { @@ -1025,6 +1045,116 @@ function applyChanges(original: Document, updated: Document, changes: Change[], // never call insert()/remove(), which would re-dirty offsets nothing downstream flushes). const objectMoves: Move[] = []; + // Several writes into one multiline inline container can overlap through the container's + // stale end position even when each write is flushed individually. Treat only those + // multi-change, comment-free containers transactionally; a single edit still uses the + // formatting-preserving fine-grained path and comments retain their ownership handling. + const multilineChangeCounts = new Map(); + const multilineChangePaths = new Map(); + const multilineChanges = new Map(); + const multilineHasUnresolvedChange = new Set(); + const multilineAncestors = new WeakMap(); + const indexMultilineAncestors = (node: TreeNode, ancestors: TreeNode[] = []) => { + const nextAncestors = (isInlineTable(node) || isInlineArray(node)) && + node.loc.end.line > node.loc.start.line + ? [...ancestors, node] + : ancestors; + multilineAncestors.set(node, nextAncestors); + if (isKeyValue(node)) { + indexMultilineAncestors(node.value, nextAncestors); + } else if (isInlineItem(node)) { + indexMultilineAncestors(node.item, nextAncestors); + } else if (hasItems(node)) { + for (const item of node.items as TreeNode[]) indexMultilineAncestors(item, nextAncestors); + } + }; + indexMultilineAncestors(original); + const commentCache = new WeakMap(); + const multilineStringCountCache = new WeakMap(); + function containsComment(node: TreeNode): boolean { + const cached = commentCache.get(node); + if (cached !== undefined) return cached; + let result: boolean; + if (isComment(node)) result = true; + else if (isKeyValue(node)) result = containsComment(node.value); + else if (isInlineItem(node)) result = containsComment(node.item); + else result = hasItems(node) && (node.items as TreeNode[]).some(containsComment); + commentCache.set(node, result); + return result; + } + function multilineStringCount(node: TreeNode): number { + const cached = multilineStringCountCache.get(node); + if (cached !== undefined) return cached; + let result: number; + if (isString(node)) result = node.loc.end.line > node.loc.start.line ? 1 : 0; + else if (isKeyValue(node)) result = multilineStringCount(node.value); + else if (isInlineItem(node)) result = multilineStringCount(node.item); + else result = hasItems(node) + ? (node.items as TreeNode[]).reduce((count, item) => count + multilineStringCount(item), 0) + : 0; + multilineStringCountCache.set(node, result); + return result; + } + for (const change of changes) { + let target = tryFindByPath(original, change.path); + const unresolved = !target; + if (!target) { + // An Add earlier in the same array can make a later Remove path refer to + // post-mutation coordinates. Resolve its nearest existing ancestor so + // both changes still contribute to the same transaction (seed 175924). + for (let length = change.path.length - 1; length >= 0 && !target; length--) { + target = tryFindByPath(original, change.path.slice(0, length)); + } + } + if (!target) continue; + const ancestors = multilineAncestors.get(target) ?? []; + const targetIsMultiline = (isInlineTable(target) || isInlineArray(target)) && + target.loc.end.line > target.loc.start.line; + + for (const container of targetIsMultiline ? [...ancestors, target] : ancestors) { + if (containsComment(container) || multilineStringCount(container) < 1) continue; + multilineChangeCounts.set(container, (multilineChangeCounts.get(container) ?? 0) + 1); + const containerChanges = multilineChanges.get(container); + if (containerChanges) containerChanges.push(change); + else multilineChanges.set(container, [change]); + if (unresolved) multilineHasUnresolvedChange.add(container); + const path = absolutePathOf(container); + if (path !== undefined) multilineChangePaths.set(container, path); + } + } + const transactionalPaths = [...multilineChangeCounts] + .filter(([container, count]) => { + if (!multilineChangePaths.has(container)) return false; + const containerChanges = multilineChanges.get(container) ?? []; + const hasMoveOrAdd = containerChanges.some(change => isMove(change) || isAdd(change)); + const strings = multilineStringCount(container); + const hasEnoughStrings = strings >= 2 || + (strings >= 1 && multilineHasUnresolvedChange.has(container)); + return hasEnoughStrings && + (count >= 3 || (count === 2 && (!hasMoveOrAdd || multilineHasUnresolvedChange.has(container))) || + (isInlineArray(container) && count === 1 && strings >= 2)); + }) + .map(([container]) => multilineChangePaths.get(container)!) + .filter((path, index, paths) => !paths.some((other, otherIndex) => + otherIndex !== index && other.length > path.length && + arraysEqual(other.slice(0, path.length), path) + )); + if (process.env.TOML_PATCH_DEBUG_TRANSACTION) { + console.warn([...multilineChangeCounts].map(([container, count]) => ({ + type: container.type, + path: multilineChangePaths.get(container), + count, + strings: multilineStringCount(container), + changes: (multilineChanges.get(container) ?? []).map(change => change.type) + }))); + } + if (transactionalPaths.length > 0) { + changes = changes.filter(change => !transactionalPaths.some(path => + change.path.length >= path.length && arraysEqual(change.path.slice(0, path.length), path) + )); + changes.push(...transactionalPaths.map(path => ({ type: ChangeType.Edit as const, path }))); + } + // Potential Changes: // // Add: Add key-value to object, add item to array From 2050cb377f6baa81ec3f48b3c69b44a717f9ace7 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:30:00 -0400 Subject: [PATCH 05/36] test: add distilled fuzz regressions --- scripts/check-259-case.ts | 32 +++ scripts/distill-seed.ts | 248 +++++++++++++++++++++ scripts/distilled-1112646.ts | 21 ++ scripts/distilled-1286183.ts | 22 ++ scripts/distilled-1383962.ts | 17 ++ scripts/distilled-1693919.ts | 19 ++ scripts/distilled-175924.ts | 21 ++ scripts/distilled-1896226.ts | 21 ++ scripts/distilled-2185943.ts | 25 +++ scripts/distilled-2497422.ts | 16 ++ scripts/distilled-2531104.ts | 19 ++ scripts/distilled-2591153.ts | 124 +++++++++++ scripts/distilled-2667551.ts | 18 ++ scripts/distilled-2824408.ts | 23 ++ scripts/distilled-2858114.ts | 18 ++ scripts/distilled-377453.ts | 18 ++ scripts/distilled-771152.ts | 15 ++ scripts/distilled-863664.ts | 20 ++ src/__tests__/patch.distilled.fuzz.test.ts | 226 +++++++++++++++++++ 19 files changed, 923 insertions(+) create mode 100644 scripts/check-259-case.ts create mode 100644 scripts/distill-seed.ts create mode 100644 scripts/distilled-1112646.ts create mode 100644 scripts/distilled-1286183.ts create mode 100644 scripts/distilled-1383962.ts create mode 100644 scripts/distilled-1693919.ts create mode 100644 scripts/distilled-175924.ts create mode 100644 scripts/distilled-1896226.ts create mode 100644 scripts/distilled-2185943.ts create mode 100644 scripts/distilled-2497422.ts create mode 100644 scripts/distilled-2531104.ts create mode 100644 scripts/distilled-2591153.ts create mode 100644 scripts/distilled-2667551.ts create mode 100644 scripts/distilled-2824408.ts create mode 100644 scripts/distilled-2858114.ts create mode 100644 scripts/distilled-377453.ts create mode 100644 scripts/distilled-771152.ts create mode 100644 scripts/distilled-863664.ts create mode 100644 src/__tests__/patch.distilled.fuzz.test.ts diff --git a/scripts/check-259-case.ts b/scripts/check-259-case.ts new file mode 100644 index 00000000..876bdd90 --- /dev/null +++ b/scripts/check-259-case.ts @@ -0,0 +1,32 @@ +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const target = resolve(process.argv[2] ?? process.cwd()); +const api = await import(pathToFileURL(resolve(target, 'src/index.ts')).href); +const { parse, patch } = api; + +const source = `w2 = 2049-04-03T21:46:36.579301 +["AKy:}nV@"] +p8 = { + uyvk4h."m}5?" = "Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w= 0 ? process.argv[index + 1] : fallback; +} + +const seed = Number(arg('--seed')); +const target = resolve(arg('--target', process.cwd())!); +const output = arg('--out'); +const maxPasses = Number(arg('--passes', '4')); +if (!Number.isInteger(seed) || !output) { + throw new Error('Usage: npx -y tsx scripts/distill-seed.ts --seed N --out path [--target path]'); +} + +const importFromTarget = async (relativePath: string) => { + const url = pathToFileURL(resolve(target, relativePath)).href; + return import(url); +}; +const randomizer = await importFromTarget('src/__tests__/randomizer.ts'); +const fuzz = await importFromTarget('src/__tests__/fuzz-patch.ts'); +const api = await importFromTarget('src/index.ts'); +const { randomToml, SeededRandom } = randomizer; +const { parse, patch } = api; +const { generateMutation, applyMutation, deepClone, randomTomlFormat } = fuzz; + +const generated = randomToml({ seed }); +const originalObject = deepClone(parse(generated.toml)); +const mutationCount = 3; +const mutationRng = new SeededRandom(seed + mutationCount * 1_000_000); +const mutations: Mutation[] = []; +const mutationObject = deepClone(originalObject) as any; +const aotKeyPaths = new Set(); +const collectAotKeys = (node: any) => { + if (!node || typeof node !== 'object') return; + if (node.type === 'TableArray' && node.key?.item) { + aotKeyPaths.add((node.key.item.value as string[]).join('.')); + } + if (Array.isArray(node.items)) for (const item of node.items) collectAotKeys(item); + if (node.value) collectAotKeys(node.value); + if (node.item) collectAotKeys(node.item); +}; +collectAotKeys(generated.document); +const isTableLike = (value: unknown) => + value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); +let attempts = 0; +while (mutations.length < mutationCount && attempts < mutationCount * 5) { + attempts++; + const mutation = generateMutation(mutationObject, mutationRng); + if (!mutation) break; + if (mutation.newValue !== undefined && !isTableLike(mutation.newValue)) { + const last = mutation.path.at(-1); + const parentPath = mutation.path.slice(0, -1); + const stringPath = parentPath.filter(segment => typeof segment === 'string').join('.'); + if (typeof last === 'number' && aotKeyPaths.has(stringPath)) continue; + } + applyMutation(mutationObject, mutation); + mutations.push(mutation); +} +const format = randomTomlFormat(new SeededRandom(seed + 500_000)); + +function normalize(value: unknown): unknown { + if (typeof value === 'bigint') return `${value}n`; + if (typeof value === 'number') { + if (Number.isNaN(value)) return 'NaN'; + if (value === Infinity) return 'Infinity'; + if (value === -Infinity) return '-Infinity'; + return value; + } + if (value instanceof Date) return `Date:${value.constructor.name}:${value.getTime()}:${value.toISOString()}`; + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === 'object') { + const result: Record = {}; + for (const key of Object.keys(value)) result[key] = normalize((value as any)[key]); + return result; + } + return value; +} + +function getAt(object: any, path: (string | number)[]): unknown { + let current = object; + for (const segment of path) { + if (current == null) return undefined; + current = current[segment as any]; + } + return current; +} + +function canReplay(object: any, mutation: Mutation): boolean { + const parent = getAt(object, mutation.path.slice(0, -1)); + if (parent == null || typeof parent !== 'object') return false; + const last = mutation.path[mutation.path.length - 1]; + if (mutation.kind === 'delete-key' || mutation.kind === 'remove-array-item') { + return getAt(object, mutation.path) !== undefined; + } + if (mutation.kind === 'add-array-item') { + return Array.isArray(parent) && typeof last === 'number' && last <= parent.length; + } + return getAt(object, mutation.path) !== undefined; +} + +function isFailure(source: string, testMutations: Mutation[] = mutations): boolean { + let object: any; + try { + object = deepClone(parse(source)); + } catch { + return false; + } + try { + for (const mutation of testMutations) { + if (!canReplay(object, mutation)) return false; + applyMutation(object, mutation); + } + const result = patch(source, object, format); + const reparsed = parse(result); + return JSON.stringify(normalize(object)) !== JSON.stringify(normalize(reparsed)); + } catch { + return true; + } +} + +function removeRange(lines: string[], start: number, end: number): string { + return lines.slice(0, start).concat(lines.slice(end)).join('\n'); +} + +let lines = generated.toml.split(/\r?\n/); +if (lines.at(-1) === '') lines.pop(); +if (!isFailure(lines.join('\n'))) { + throw new Error(`Seed ${seed} is not a failure under target ${target}`); +} + +for (let pass = 0; pass < maxPasses; pass++) { + let granularity = 2; + let changed = false; + while (granularity <= lines.length) { + const chunkSize = Math.ceil(lines.length / granularity); + let removed = false; + for (let start = 0; start < lines.length; start += chunkSize) { + const end = Math.min(lines.length, start + chunkSize); + const candidate = removeRange(lines, start, end); + if (!candidate || isFailure(candidate)) { + lines.splice(start, end - start); + removed = true; + changed = true; + break; + } + } + if (removed) { + granularity = Math.max(2, granularity - 1); + } else if (granularity < lines.length) { + granularity = Math.min(lines.length, granularity * 2); + } else { + break; + } + } + if (!changed) break; +} + +// Remove mutations that do not contribute to the failure, then run the line reducer +// again because a shorter mutation list often makes unrelated source structure removable. +for (let index = mutations.length - 1; index >= 0; index--) { + const candidateMutations = mutations.slice(0, index).concat(mutations.slice(index + 1)); + if (candidateMutations.length > 0 && isFailure(lines.join('\n'), candidateMutations)) { + mutations.splice(index, 1); + } +} +for (let pass = 0; pass < maxPasses; pass++) { + let changed = false; + for (let index = lines.length - 1; index >= 0; index--) { + const candidate = lines.slice(0, index).concat(lines.slice(index + 1)).join('\n'); + if (candidate && isFailure(candidate)) { + lines.splice(index, 1); + changed = true; + } + } + if (!changed) break; +} + +function valueToSource(value: unknown): string { + if (value === null) return 'null'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number') { + if (Number.isNaN(value)) return 'NaN'; + if (value === Infinity) return 'Infinity'; + if (value === -Infinity) return '-Infinity'; + return String(value); + } + if (typeof value === 'boolean') return String(value); + if (typeof value === 'bigint') return `${value}n`; + if (value instanceof Date) { + return `new Date(Date.UTC(${value.getUTCFullYear()}, ${value.getUTCMonth()}, ${value.getUTCDate()}))`; + } + if (Array.isArray(value)) return `[${value.map(valueToSource).join(', ')}]`; + if (value && typeof value === 'object') { + return `{ ${Object.entries(value as Record) + .map(([key, item]) => `${JSON.stringify(key)}: ${valueToSource(item)}`).join(', ')} }`; + } + return JSON.stringify(value); +} + +function accessor(path: (string | number)[]): string { + return 'obj' + path.map(segment => typeof segment === 'number' + ? `[${segment}]` + : /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment) + ? `.${segment}` + : `[${JSON.stringify(segment)}]`).join(''); +} + +function mutationSource(mutation: Mutation): string { + const last = mutation.path.at(-1); + if (mutation.kind === 'delete-key') return `delete ${accessor(mutation.path)};`; + if (mutation.kind === 'remove-array-item') return `${accessor(mutation.path.slice(0, -1))}.splice(${last}, 1);`; + if (mutation.kind === 'add-array-item') { + return `${accessor(mutation.path.slice(0, -1))}.splice(${last}, 0, ${valueToSource(mutation.newValue)});`; + } + return `${accessor(mutation.path)} = ${valueToSource(mutation.newValue)};`; +} + +const source = lines.join('\n'); +const postFixObject: any = deepClone(parse(source)); +for (const mutation of mutations) applyMutation(postFixObject, mutation); +const expected = patch(source, postFixObject, format); +const body = [ + `test.fails('distilled regression for fuzz seed ${seed}', () => {`, + ' const src = dedent`', + ...source.split('\n').map(line => ` ${line.replaceAll('`', '\\`').replaceAll('${', '\\${')}`), + ' `;', + '', + ' const obj = parse(src) as any;', + ...mutations.map(mutation => ` ${mutationSource(mutation)}`), + '', + ' const result = patch(src, obj);', + ' expect(parse(result)).toEqual(obj);', + ' // TODO: assert exact output after the implementation fix.', + ` // expect(result).toEqual(${JSON.stringify(expected)});`, + '});', + '' +].join('\n'); +writeFileSync(resolve(output), body); +console.log(JSON.stringify({ seed, target, lines: lines.length, mutations: mutations.map(mutation => `${mutation.kind} ${mutation.path.join('.')}`) })); diff --git a/scripts/distilled-1112646.ts b/scripts/distilled-1112646.ts new file mode 100644 index 00000000..a7de80b7 --- /dev/null +++ b/scripts/distilled-1112646.ts @@ -0,0 +1,21 @@ +test.fails('distilled regression for fuzz seed 1112646', () => { + const src = dedent` + [["2S38X#"]] + "dj/{gXY".sp_ya888x.mx7h8hlr = ''' + D<''' + ln_n = [ + ''' + ,''', + [{ "t&R/jR" = ''' + TPV!''' }, false, 287_173, "NAzv Be{}8z]C2QzJpyz}_]idd", 1984-04-16T12:37:13Z], + ] + `; + + const obj = parse(src) as any; + obj["2S38X#"][0]["dj/{gXY"].sp_ya888x = [new Date(Date.UTC(2018, 5, 14)), -2069, 784]; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + // TODO: assert exact output after the implementation fix. + // expect(result).toEqual("[[\"2S38X#\"]]\n\"dj/{gXY\".sp_ya888x = [ 2018-06-14T00:00:00.000Z, -2069, 784, ]\nln_n = [\n '''\n,''',\n [{ \"t&R/jR\" = '''\nTPV!''' }, false, 287_173, \"NAzv Be{}8z]C2QzJpyz}_]idd\", 1984-04-16T12:37:13Z],\n]"); +}); diff --git a/scripts/distilled-1286183.ts b/scripts/distilled-1286183.ts new file mode 100644 index 00000000..d1ec1f1f --- /dev/null +++ b/scripts/distilled-1286183.ts @@ -0,0 +1,22 @@ +test.fails('distilled regression for fuzz seed 1286183', () => { + const src = dedent` + i5qpju2q = """ + 8=""" + [p7jqr__s] + j1."d1bJi gG""", i-43c9963p.bre5k8g28.guv2pn = 'l0-Y?;FYxwz', su.p = 36740, "Pv3!oN4".ub_k79_ = false, dnc3w6 = "", "ag/MY[2".ud = 527558, "Y][2vRao-".ekiao1h.t88fjn = 2080-12-04, "@" = 31256.58522, kqk32ihb.xcd01 = 819597, xf79wkh3q = 0x08, i = -284092, ZZ = 0o64, jbr.r0acvtrwd = true }, "&f".ck.pe = 2081-06-28T00:40:23Z, "5BN)X3P5:".b6dgagg = """ + DU)$R8E>JL*""", do8b6z.f4f-0l = {}, zhd31f5qk.h862qtmhq = 90692.79376, hc."+ZlY;4|" = true }, + } + `; + + const obj = parse(src) as any; + delete obj.p7jqr__s.j1["d1bJi gG\"\"\", i-43c9963p.bre5k8g28.guv2pn = 'l0-Y?;FYxwz', su.p = 36740, \"Pv3!oN4\".ub_k79_ = false, dnc3w6 = \"\", \"ag/MY[2\".ud = 527558, \"Y][2vRao-\".ekiao1h.t88fjn = 2080-12-04, \"@\" = 31256.58522, kqk32ihb.xcd01 = 819597, xf79wkh3q = 0x08,,i = -284092, ZZ = 0o64, jbr.r0acvtrwd = true \"&f\".ck.pe = 2081-06-28T00:40:23Z, \"5BN)X3P5:\".b6dgagg = \"\"\"\r\nDU)$R8E>JL*\"\"\", do8b6z.f4f-0l = {}, zhd31f5qk,h862qtmhq = 90692.79376, hc.\"+ZlY;4|\" = true\r\n }\r\n"); +}); diff --git a/scripts/distilled-1383962.ts b/scripts/distilled-1383962.ts new file mode 100644 index 00000000..d7015e20 --- /dev/null +++ b/scripts/distilled-1383962.ts @@ -0,0 +1,17 @@ +test.fails('distilled regression for fuzz seed 1383962', () => { + const src = dedent` + "1vn[1flkZ".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, """ + s~""", 76968.6746, 'Qr+qJ|jGKM n=c1oEx^\`p2;L0H_Nyg*3K$:A{?jJg:I', 2069-08-08T04:34:33, "A3!)hHn6}I"] }, 574310, 28569, -63757100000000418476, """ + 8=Qed7]-^jftM""", -50759.88688, -inf, true] + [[kmza]] + ptvl.s2fdfonrz9 = 'vQb/mr3cU )zJScrEv2bQ-$}kK1U&o,5hpf9@N|Ke[' + `; + + const obj = parse(src) as any; + obj.kmza[0].ptvl = new Date(Date.UTC(2006, 1, 14)); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + // TODO: assert exact output after the implementation fix. + // expect(result).toEqual("\"1vn[1flkZ\".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, \"\"\"\ns~\"\"\", 76968.6746, 'Qr+qJ|jGKM n=c1oEx^`p2;L0H_Nyg*3K$:A{?jJg:I', 2069-08-08T04:34:33, \"A3!)hHn6}I\"] }, 574310, 28569, -63757100000000418476, \"\"\"\n8=Qed7]-^jftM\"\"\", -50759.88688, -inf, true]\n[[kmza]]\nptvl = 2006-02-14T00:00:00.000Z"); +}); diff --git a/scripts/distilled-1693919.ts b/scripts/distilled-1693919.ts new file mode 100644 index 00000000..e7700f2c --- /dev/null +++ b/scripts/distilled-1693919.ts @@ -0,0 +1,19 @@ +test.fails('distilled regression for fuzz seed 1693919', () => { + const src = dedent` + [g3s_y5jv_d.mfdfdc] + "".c4esqy.t79d__y = { + bnJF1EQ = { jg08iizbia = """ + fP=SCd-/e]!;bs(AR*w3L""" }, + "#?VRmg"." { + const src = dedent` + [[l."=".p]] + m = { + } + qsof = ["--2#k+z+M,Et1[EnDbG0_Ykh7^ ", 1988-12-04T04:19:06, [ + 0b000000110001011, + { t7a5uwjr2 = """ + ]UjKeB""" }, + -907693, + ], 65260.050825] + `; + + const obj = parse(src) as any; + delete obj.l["="].p[0].qsof[2][1].t7a5uwjr2; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + // TODO: assert exact output after the implementation fix. + // expect(result).toEqual("[[l.\"=\".p]]\r\nm = {\r\n}\r\nqsof = [\"--2#k+z+M,Et1[EnDbG0_Ykh7^ \", 1988-12-04T04:19:06, [\r\n 0b000000110001011,\r\n {},\r\n-90769,\r\n,] ]\r\n 65260.050825\r\n"); +}); diff --git a/scripts/distilled-1896226.ts b/scripts/distilled-1896226.ts new file mode 100644 index 00000000..ea4d8ed5 --- /dev/null +++ b/scripts/distilled-1896226.ts @@ -0,0 +1,21 @@ +test.fails('distilled regression for fuzz seed 1896226', () => { + const src = dedent` + y0pgo = ''' + zw$#+{rD] Q:|WI_5xV''' + [syn2x.b.o6y2i] + xx1dnmb-lu.i-b5qipqve = { kh5rr3tnn.tfv.acbd8jed_ = true, am7fwt.jri8xuo = "drC+m!9n vxA(?w8N+)9]jF1 /", eu0e.ygeqkl.rv5zela02z = -792779, c.teb.pe = 0xd45, fk-no = false, wvddr.gmv.d8iwt = "]49F#3@Gi^y{@4y(eYalh%@", piif.qwkfgiyp = false, "kNg6#mA".mmu1i.u1aubp = true, hk3wa8v.">uk|E\`!8a" = 2055-10-17T23:43:59.550334Z, "|EnD &e~>\`".npriy.OU = inf, m3y3lj.viyt2w.c = "U/qmv0wx=W/ib/M^tuk|E`!8a\" = 2055-10-17T23:43:59.550334Z, \"|EnD &e~>`\".npriy.OU = inf, m3y3lj.viyt2w.c = \"U/qmv0wx=W/ib/M^t { + const src = dedent` + ei.Nj = """ + >!~Wg-y)~""" + [[megac68k-]] + z = [ + ] + kw = [46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275_068, { + cnoff = { l7."UMN$eUS9="."" = ''' + C/,9''' }, + aujqbb = ''' + P/*''', + }, 22787.072880, 79051.79185, true] + "1x(4u.WK2x".v4r4evh1.c3pw90 = """ + m@7""" + `; + + const obj = parse(src) as any; + delete obj["megac68k-"][0].kw[5].cnoff.l7["UMN$eUS9="]; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + // TODO: assert exact output after the implementation fix. + // expect(result).toEqual("ei.Nj = \"\"\"\n>!~Wg-y)~\"\"\"\n[[megac68k-]]\nz = [\n]\nkw = [46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275_068, {\n cnoff = { l7 = {} },\n aujqbb = '''\nP/*''',\n }, ]\n 22787.072880, 79051.79185, true\n\"1x(4u.WK2x\".v4r4evh1.c3pw90 = \"\"\"\nm@7\"\"\""); +}); diff --git a/scripts/distilled-2497422.ts b/scripts/distilled-2497422.ts new file mode 100644 index 00000000..5e76b432 --- /dev/null +++ b/scripts/distilled-2497422.ts @@ -0,0 +1,16 @@ +test.fails('distilled regression for fuzz seed 2497422', () => { + const src = dedent` + "x^@zw" = [' ', -78860.81892, { ik4o2d6z4j = """ + DM}T+uCZVx*t+qMtQtbPrFiI""" }, "\`kO^E(R8324tzB;#iBmn9aX!9KUL*N0eWvf/J:R", 36709.83314, [true, "ZrS|K#MaiwaYr4UOIP<$=8clcC*+Px{^#v@>G|(@>;a>w&X&Jb", 945e+85, -30134.83738, { + }, """ + HLzq$8|?""", "G|(@>;a>w&X&Jb\", 945e+85, -30134.83738, {\n}, \"\"\"\nHLzq$8|?\"\"\", \" { + const src = dedent` + mmx9rwvk7k.lqmp_-1cgl = ''' + 5>ZvY=\`r~ha''' + wcyw = { + tf."O\`aB" = [true, { "%#FUrN".cvmmswb6.">&K?" = 115668, p2ohe0.sqh9uj."$Qk^~(" = -92131.88369, kvy9 = false, kvy9h1bo.fe7b1px4bb = """ + :-i1""" }, 745102, 96226.27680, 0o21016, false, 802344, "#vH#E:o>J[jB||M"], + l."~u)HFjuZ" = 00:16:17, + } + `; + + const obj = parse(src) as any; + obj.wcyw.tf["O`aB"][1] = { "k96": 1640, "k18": true }; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + // TODO: assert exact output after the implementation fix. + // expect(result).toEqual("mmx9rwvk7k.lqmp_-1cgl = '''\n 5>ZvY=`r~ha'''\nwcyw = {\n tf.\"O`aB\" = [true, {k96 = 1640.0, k18 = true}, 745102, 96226.27680, 0o21016, false, 802344, \"#vH#E:o>J[jB||M\"],\n\n} l.\"~u)HFjuZ\" = 00:16:17,\n"); +}); diff --git a/scripts/distilled-2591153.ts b/scripts/distilled-2591153.ts new file mode 100644 index 00000000..988e7db5 --- /dev/null +++ b/scripts/distilled-2591153.ts @@ -0,0 +1,124 @@ +test.fails('distilled regression for fuzz seed 2591153', () => { + const src = dedent` + gas = true + sz = 178262 + "v^.aq/(w" = 2058-10-24T21:21:43 + mt5434cc."Cx[+"."q(?TYenJR" = """ + &m}Y""" + ix4t8ryk8."hBTJ&l&3bl".yt3z6_ = true + w2 = 2049-04-03T21:46:36.579301 + "R " = 0o150531 + clkd176.gs6 = -579731 + "FkeQ?W".u5oxixqh = "w4*5](T\`Wgjlbi+@wYY2o~" + # yg=|nPERhmd_2Qj;Qu$O^|T2*u + ryhwr.m5tn.gsg1h = 207_795 + p6jzbb677n.uud.fyoqco70y = 90023.48570 + k0cxt83.iwobqz4vi.NQSltlW = 513513 + # nx%JXe@oDPCx$/Iz-dV%:EI)/ + [["".lcmm1m0.vfkm5_p]] + "Wg[" = 0b0011011 + "m4}cz".jnyu = 2080-08-12T23:09:05Z + # |J^H\`E{/un~cEYJoJb + # 7ZBeQqq/ot:" = 258084 + vhashp_7d-."zD_>~7Lf3" = -464765 + "q=y".ojj = """ + iB""" + x7co_.u.AtJ = 754_620 + + [q2c."o!AxxvqZj"] + g9p75i5s_ = ''' + !esM3g7w + }I2t-:Y-|#MD + E] + ,^vU + oEA + w + 0 + ! + 7nGBp + + ''' + cmzsfzz.f3utn728zu = true + "^]jfx9Hz_" = 2053-10-21T07:50:22Z + f6ziybf.egyieof = false + "B3:c%G9".sf_90m7q.ncide1 = 1981-12-01T06:47:08Z + t = 986e-61 + ez."yTxL(kP[j0"."SPCIi(YbYe" = 53780.13362 + cpqdsgprv."%-+WE".x9vi = 2091-11-01T14:04:57 + s-yvw-4f.jes8rouk = 82_668.64849 + + [gspp.e.vg_08p5dgs] + eegf54qq.b3ff_g = true + bttb8h.vk5c."" = [] + dx2feuc.3 = """ + -z + =t9EF) + 6+""" + # ~xsBrSdMAd;mr\`JPYi + yh3l3-g2x.gmw3_.ppq_ep3k3 = """ + = VD95 + v?{gW + j + 1,]OS5""" + f6qrvqct.ny0fj-3 = 367707 + fx = nan + # oO!S;$e^N>g{%,+eJzcq(}kE,@J=je+M + [[qqqk."C<4M\`%W2L"]] + + [[y9wecl5]] + rw3l2q8mk.d-ps = ']zgL)[hSjD.R+' + # i(M^R&yAn3x3pj]ayUh -^ *xi\`1,cQ(~ + qe = 995783 + vag."[Q&%][ 6ap" = 559418 + aam48 = true + + ["AKy:}nV@"] + kl7u = true + # Vu_I\`CdTJh>9oacHXu\`o>|Nt\`=pHltLN + "hG b+T".oyyuk3 = 937467 + r46f8rhrh.rprqr8g = -566314 + p8 = { + uyvk4h."m}5?" = "Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w:\" = 258084\nvhashp_7d-.\"zD_>~7Lf3\" = -464765\n\"q=y\".ojj = \"\"\"\niB\"\"\"\nx7co_.u.AtJ = 754_620\n\n[q2c.\"o!AxxvqZj\"]\ng9p75i5s_ = '''\n!esM3g7w\n}I2t-:Y-|#MD\nE]\n,^vU\noEA\nw\n0\n!\n7nGBp\n\n'''\ncmzsfzz.f3utn728zu = true\n\"^]jfx9Hz_\" = 2053-10-21T07:50:22Z\nf6ziybf.egyieof = false\n\"B3:c%G9\".sf_90m7q.ncide1 = 1981-12-01T06:47:08Z\nt = 986e-61\nez.\"yTxL(kP[j0\".\"SPCIi(YbYe\" = 53780.13362\ncpqdsgprv.\"%-+WE\".x9vi = 2091-11-01T14:04:57\ns-yvw-4f.jes8rouk = 82_668.64849\n\n[gspp.e.vg_08p5dgs]\neegf54qq.b3ff_g = true\nbttb8h.vk5c.\"\" = []\ndx2feuc.3 = \"\"\"\n-z\n=t9EF)\n6+\"\"\"\n# ~xsBrSdMAd;mr`JPYi\nyh3l3-g2x.gmw3_.ppq_ep3k3 = \"\"\"\n= VD95\n v?{gW\nj\n1,]OS5\"\"\"\nf6qrvqct.ny0fj-3 = 367707\nfx = nan\n# oO!S;$e^N>g{%,+eJzcq(}kE,@J=je+M\n[[qqqk.\"C<4M`%W2L\"]]\n\n[[y9wecl5]]\nrw3l2q8mk.d-ps = ']zgL)[hSjD.R+'\n# i(M^R&yAn3x3pj]ayUh -^ *xi`1,cQ(~\nqe = 995783\nvag.\"[Q&%][ 6ap\" = 559418\naam48 = true\n\n[\"AKy:}nV@\"]\nkl7u = true\n# Vu_I`CdTJh>9oacHXu`o>|Nt`=pHltLN\n\"hG b+T\".oyyuk3 = 937467\nr46f8rhrh.rprqr8g = -566314\np8 = {\n uyvk4h.\"m}5?\" = \"Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w { + const src = dedent` + [[xwed7dv]] + w9ri7t6az = { + } + ax.dkibhxt = ['', """ + >X+YrY\`N""", 2088-10-24T00:54:56, """ + $e"""] + `; + + const obj = parse(src) as any; + obj.xwed7dv[0].ax.dkibhxt.splice(2, 1); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + // TODO: assert exact output after the implementation fix. + // expect(result).toEqual("[[xwed7dv]]\nw9ri7t6az = {\n}\nax.dkibhxt = ['', \"\"\"\n>X+YrY`N\"\"\", \"\"\"\n$e\"\"\"\n"); +}); diff --git a/scripts/distilled-2824408.ts b/scripts/distilled-2824408.ts new file mode 100644 index 00000000..c1aed716 --- /dev/null +++ b/scripts/distilled-2824408.ts @@ -0,0 +1,23 @@ +test.fails('distilled regression for fuzz seed 2824408', () => { + const src = dedent` + [ivk.nuan749c7] + pJ.kd7jct7_o = { + xwjbnijuw = { k-l259ef = "E2 U!/a", vi.dg5 = true, f4wkz1o_j.cgog.gw8umgy = ''' + t5B02omW_Qc5gU}''', qcc3pxkelq._G = """ + \`]cA6:81:-L__K?yaccQdE{zs{v$8?CN?be""" }, + "\`pD".r = "U9SE]2S+ { + const src = dedent` + sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, "qEZv", 0x703714, "p_\`:SMIvsp(<$", "m|2Z@v-c},BQ~+EDUZQr(lxlbX^iy0Mi>{G%c", """ + ;(d7x""", { + e_8y4s = 59108.35246, + }] + xe_p.w5yln8z5 = ''' + xo5o''' + `; + + const obj = parse(src) as any; + obj.sw2.rfc4998bx.splice(5, 1); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + // TODO: assert exact output after the implementation fix. + // expect(result).toEqual("sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, \"qEZv\", \"p_`:SMIvsp(<$\", \"m|2Z@v-c},BQ~+EDUZQr(lxlbX^iy0Mi>{G%c\", \"\"\"\n;(d7x\"\"\", {\n e_8y4s = 59108.35246,\n ]\nxe_p.w5yln8z5 = '''\nxo5o'''"); +}); diff --git a/scripts/distilled-377453.ts b/scripts/distilled-377453.ts new file mode 100644 index 00000000..72de4051 --- /dev/null +++ b/scripts/distilled-377453.ts @@ -0,0 +1,18 @@ +test.fails('distilled regression for fuzz seed 377453', () => { + const src = dedent` + eqki- = { + v1xubvr5b2 = [false, true, ''' + <6@Qd}q =\`q]B>FSx''', -49_687.079945, { czt."@C~NuX" = "o5!A|8C-ri)@-U{I", t7.V.v = 0x5c5fa0, ")iE=18F=" = "h#\`^&yX^7wqnyK}(2j?~uEXfHhW1VmI;7)zeF54:Z&+whORWa", zC."&E".p2-fnhimsk = true, zo.HK.o_etoxccp6 = 0o26771, y95lhy8.x.ibl4bqh4di = 0xcfe, hmmu35k7g.iit__292.zjjfy = 15:08:47, c4u9he.";[c76C".bkkhwx2_5w = """ + o0q*""" }, "I3q/hH", 753862, 'z', -64864.2541], + gmpmfyn4.y1np1 = 0o4755211, + } + `; + + const obj = parse(src) as any; + obj["eqki-"].v1xubvr5b2[4] = { "k94": -4472 }; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + // TODO: assert exact output after the implementation fix. + // expect(result).toEqual("eqki- = {\n v1xubvr5b2 = [false, true, '''\n<6@Qd}q =`q]B>FSx''', -49_687.079945, { k94 = -4472.0 }, \"I3q/hH\", 753862, 'z', -64864.2541],\n\n} gmpmfyn4.y1np1 = 0o4755211,\n"); +}); diff --git a/scripts/distilled-771152.ts b/scripts/distilled-771152.ts new file mode 100644 index 00000000..1f9f3190 --- /dev/null +++ b/scripts/distilled-771152.ts @@ -0,0 +1,15 @@ +test.fails('distilled regression for fuzz seed 771152', () => { + const src = dedent` + b_3cmsbhh.al1erl4-9 = [236463, ''' + -xl6''', 0o34, 90356.53508, true, [{ us.xl."/" = """ + fr;,Iq*!9""" }, 1986-03-02]] + `; + + const obj = parse(src) as any; + obj.b_3cmsbhh["al1erl4-9"][5][0].us.xl = { "k51": -3337.0673237368464, "k49": "QhvX_vl aKj9dsQ0r7" }; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + // TODO: assert exact output after the implementation fix. + // expect(result).toEqual("b_3cmsbhh.al1erl4-9 = [236463, '''\n-xl6''', 0o34, 90356.53508,]true, [{us.xl.k51 = -3337.0673237368464, us.xl.k49 = \"QhvX_vl aKj9dsQ0r7\"}, 1986-03-02]"); +}); diff --git a/scripts/distilled-863664.ts b/scripts/distilled-863664.ts new file mode 100644 index 00000000..93fd8f16 --- /dev/null +++ b/scripts/distilled-863664.ts @@ -0,0 +1,20 @@ +test.fails('distilled regression for fuzz seed 863664', () => { + const src = dedent` + [pm."+w_y2".nlgwd4] + k86xv_q."]7]P2g".xq99y = [ + { chm72ws.si3 = 0xd5c289d, x = 2075-01-15T23:10:01.402080, s9gatl14xa.f6fbq_msjy = { f.nl = 597050, k.z30i = 939925, i_6d2w."g3}7A0" = 785203, 5C = 465053, e3tzu = 822110, "_hGRMk\`Qg~".ku = 412e-33, fvemmt = false, wrs2ylbfaf."AQ#B,[LL=c".ep = -282456, bs3o4.knr3jqyf.h = 70302.033641, h.z3ohf3j16 = -75001.099256, nju0p.tipdjb3pfk = 54219.46102 }, "w@LY+a#1<".z.")e$TtimN7" = 482015, "fa}@[\`"."ae,>ImvDxv".49e = true, "~/yZqL.$" = "gze@7dH;YExh", by1 = '|Xd', "".nytor2grmi."" = { h53y.uujhq.hxk5o = """ + 2^t""" }, axau5."NQ6U?Fo{" = false, ubqaz = 0b111111110111000 }, + ''' + ''', + ] + [d5.dlf.n] + `; + + const obj = parse(src) as any; + obj.d5 = "P- L9H"; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + // TODO: assert exact output after the implementation fix. + // expect(result).toEqual("d5 = \"P- L9H\"\n\n[pm.\"+w_y2\".nlgwd4]\nk86xv_q.\"]7]P2g\".xq99y = [\n { chm72ws.si3 = 0xd5c289d, x = 2075-01-15T23:10:01.402080, s9gatl14xa.f6fbq_msjy = { f.nl = 597050, k.z30i = 939925, i_6d2w.\"g3}7A0\" = 785203, 5C = 465053, e3tzu = 822110, \"_hGRMk`Qg~\".ku = 412e-33, fvemmt = false, wrs2ylbfaf.\"AQ#B,[LL=c\".ep = -282456, bs3o4.knr3jqyf.h = 70302.033641, h.z3ohf3j16 = -75001.099256, nju0p.tipdjb3pfk = 54219.46102 }, \"w@LY+a#1<\".z.\")e$TtimN7\" = 482015, \"fa}@[`\".\"ae,>ImvDxv\".49e = true, \"~/yZqL.$\" = \"gze@7dH;YExh\", by1 = '|Xd', \"\".nytor2grmi.\"\" = { h53y.uujhq.hxk5o = \"\"\"\n2^t\"\"\" }, axau5.\"NQ6U?Fo{\" = false, ubqaz = 0b111111110111000 },\n '''\n''',\n]"); +}); diff --git a/src/__tests__/patch.distilled.fuzz.test.ts b/src/__tests__/patch.distilled.fuzz.test.ts new file mode 100644 index 00000000..b28bce91 --- /dev/null +++ b/src/__tests__/patch.distilled.fuzz.test.ts @@ -0,0 +1,226 @@ +import dedent from 'dedent'; +import { parse, patch } from '../'; + +test.fails('distilled fuzz seed 175924: delete a multiline string field in a nested array object', () => { + const src = dedent` + [[l."=".p]] + qsof = [0b000000110001011, { t7a5uwjr2 = """ + ]UjKeB""" }, -907693] + `; + const obj = parse(src) as any; + delete obj.l['='].p[0].qsof[1].t7a5uwjr2; + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 377453: edit an item in a multiline nested array', () => { + const src = dedent` + eqki- = { v1xubvr5b2 = [false, true, ''' + <6@Qd}q =\`q]B>FSx''', -49_687.079945, { czt."@C~NuX" = "x", t7.V.v = 0x5c5fa0, c4u9he = """ + o0q*""" }, "I3q/hH"] } + `; + const obj = parse(src) as any; + obj['eqki-'].v1xubvr5b2[4] = { k94: -4472 }; + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 771152: edit a dotted key in an inline object', () => { + const src = dedent` + b_3cmsbhh.al1erl4-9 = [236463, ''' + -xl6''', 0o34, 90356.53508, true, [{ us.xl."/" = """ + fr;,Iq*!9""" }, 1986-03-02]] + `; + const obj = parse(src) as any; + obj.b_3cmsbhh['al1erl4-9'][5][0].us.xl = { k51: -3337.0673237368464, k49: 'QhvX_vl aKj9dsQ0r7' }; + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 863664: delete the only key of a multiline inline object', () => { + const src = dedent` + [pm."+w_y2".nlgwd4] + k86xv_q."]7]P2g".xq99y = [{ "".nytor2grmi."" = { h53y.uujhq.hxk5o = """ + 2^t""" }, axau5 = false }, ''' + '''] + `; + const obj = parse(src) as any; + delete obj.pm['+w_y2'].nlgwd4.k86xv_q[']7]P2g'].xq99y[0][''].nytor2grmi[''].h53y; + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 1112646: replace a multiline AOT value with an array', () => { + const src = dedent` + [["2S38X#"]] + "dj/{gXY".sp_ya888x.mx7h8hlr = ''' + D<''' + ln_n = [ + ''' + ,''', + [{ "t&R/jR" = ''' + TPV!''' }, false, 287_173, "NAzv", 1984-04-16T12:37:13Z], + ] + `; + const obj = parse(src) as any; + obj['2S38X#'][0]['dj/{gXY'].sp_ya888x = [new Date(Date.UTC(2018, 5, 14)), -2069, 784]; + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 1286183: delete a multiline nested dotted value', () => { + const src = dedent` + [p7jqr__s] + j1."d1bJi gG""", ZZ = 0o64 } }, + } + `; + const obj = parse(src) as any; + delete obj.p7jqr__s.j1['d { + const src = dedent` + "1vn[1flkZ".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, """ + s~""", 76968.6746] }, 574310, 28569, -63757100000000418476, """ + 8=Qed7]-^jftM""", -50759.88688, -inf, true] + [[kmza]] + ptvl.s2fdfonrz9 = 'vQb/mr3cU' + `; + const obj = parse(src) as any; + obj.kmza[0].ptvl = new Date(Date.UTC(2006, 1, 14)); + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 1693919: replace a nested multiline object', () => { + const src = dedent` + [g3s_y5jv_d.mfdfdc] + "".c4esqy.t79d__y = { + bnJF1EQ = { jg08iizbia = """ + fP=SCd-/e]!;bs(AR*w3L""" }, + "#?VRmg"." { + const src = dedent` + [syn2x.b.o6y2i] + yutxpp.uzz."/S|" = ["WM^ZLQ}e", -836_768, { c19.hv = { "Z\`xk2<4{" = "08RXrX/B]Wjo}v77}^Rx", be9z3h.gaxei2pmox = """ + """ } }, { }, 2004-04-25T17:21:11] + `; + const obj = parse(src) as any; + delete obj.syn2x.b.o6y2i.yutxpp.uzz['/S|'][2].c19.hv; + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 2185943: delete a leaf in a nested multiline table', () => { + const src = dedent` + [[megac68k-]] + kw = [46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275_068, { + cnoff = { l7."UMN$eUS9="."" = ''' + C/,9''' }, + aujqbb = ''' + P/*''', + }, 22787.072880, true] + `; + const obj = parse(src) as any; + delete obj['megac68k-'][0].kw[5].cnoff.l7['UMN$eUS9=']; + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 2497422: delete a multiline inline-table member', () => { + const src = dedent` + "x^@zw" = [' ', -78860.81892, { ik4o2d6z4j = """ + DM}T+uCZVx*t+qMtQtbPrFiI""" }, "tail", 36709.83314, [true, false]] + `; + const obj = parse(src) as any; + delete obj['x^@zw'][2].ik4o2d6z4j; + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 2531104: replace an inline-table array object', () => { + const src = dedent` + wcyw = { tf."O\`aB" = [true, { "%#FUrN".cvmmswb.">&K?" = 115668, kvy9 = false, kvy9h1bo.fe7b1px4bb = """ + :-i1""" }, 745102, 96226.27680, 0o21016, false] } + `; + const obj = parse(src) as any; + obj.wcyw.tf['O`aB'][1] = { k96: 1640, k18: true }; + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 2667551: remove an array item before multiline strings', () => { + const src = dedent` + [[xwed7dv]] + ax.dkibhxt = ['', """ + >X+YrY\`N""", 2088-10-24, """ + $e""] + `; + const obj = parse(src) as any; + obj.xwed7dv[0].ax.dkibhxt.splice(2, 1); + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 2824408: replace a nested object beside multiline values', () => { + const src = dedent` + [ivk.nuan749c7] + pJ.kd7jct7_o = { xwjbnijuw = { k-l259ef = "E2 U!/a", vi.dg5 = true, f4wkz1o_j.cgog.gw8umgy = ''' + t5B02omW_Qc5gU}''', qcc3pxkelq._G = """ + \`]cA6:81:-L__K?yaccQdE{zs{v$8?CN?be""" }, "\`pD".r = "U9SE]2S+ { + const src = dedent` + sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, "qEZv", 0x703714, """ + ;(d7x""", { e_8y4s = 59108.35246 }] + `; + const obj = parse(src) as any; + obj.sw2.rfc4998bx.splice(5, 1); + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); +}); + +test.fails('distilled fuzz seed 2591153: preserve nested array coordinates across edits', () => { + const src = dedent` + w2 = 2049-04-03T21:46:36.579301 + ["AKy:}nV@"] + p8 = { + uyvk4h."m}5?" = "Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w Date: Sat, 22 Aug 2026 02:37:10 -0400 Subject: [PATCH 06/36] fix: validate distilled fuzz regressions --- scripts/distill-seed.ts | 10 +- scripts/distilled-1112646.ts | 7 +- scripts/distilled-1286183.ts | 17 +- scripts/distilled-1383962.ts | 7 +- scripts/distilled-1693919.ts | 17 +- scripts/distilled-175924.ts | 16 +- scripts/distilled-1896226.ts | 7 +- scripts/distilled-2185943.ts | 7 +- scripts/distilled-2497422.ts | 7 +- scripts/distilled-2531104.ts | 18 +- scripts/distilled-2667551.ts | 17 +- scripts/distilled-2824408.ts | 17 +- scripts/distilled-2858114.ts | 7 +- scripts/distilled-377453.ts | 17 +- scripts/distilled-771152.ts | 7 +- scripts/distilled-863664.ts | 7 +- src/__tests__/patch.distilled.fuzz.test.ts | 218 ++------------------- src/patch.ts | 4 +- 18 files changed, 173 insertions(+), 234 deletions(-) diff --git a/scripts/distill-seed.ts b/scripts/distill-seed.ts index 26904512..babb3d1e 100644 --- a/scripts/distill-seed.ts +++ b/scripts/distill-seed.ts @@ -224,6 +224,14 @@ function mutationSource(mutation: Mutation): string { return `${accessor(mutation.path)} = ${valueToSource(mutation.newValue)};`; } +function formatSource(value: unknown): string { + if (value === undefined) return 'undefined'; + return JSON.stringify(value, null, 2) + .replace(/"([A-Za-z_$][A-Za-z0-9_$]*)":/g, '$1:') + .replace(/"\\r\\n"/g, "'\\r\\n'") + .replace(/"\\n"/g, "'\\n'"); +} + const source = lines.join('\n'); const postFixObject: any = deepClone(parse(source)); for (const mutation of mutations) applyMutation(postFixObject, mutation); @@ -237,7 +245,7 @@ const body = [ ' const obj = parse(src) as any;', ...mutations.map(mutation => ` ${mutationSource(mutation)}`), '', - ' const result = patch(src, obj);', + ` const result = patch(src, obj, ${formatSource(format)});`, ' expect(parse(result)).toEqual(obj);', ' // TODO: assert exact output after the implementation fix.', ` // expect(result).toEqual(${JSON.stringify(expected)});`, diff --git a/scripts/distilled-1112646.ts b/scripts/distilled-1112646.ts index a7de80b7..312748f3 100644 --- a/scripts/distilled-1112646.ts +++ b/scripts/distilled-1112646.ts @@ -1,4 +1,7 @@ -test.fails('distilled regression for fuzz seed 1112646', () => { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 1112646', () => { const src = dedent` [["2S38X#"]] "dj/{gXY".sp_ya888x.mx7h8hlr = ''' @@ -14,7 +17,7 @@ test.fails('distilled regression for fuzz seed 1112646', () => { const obj = parse(src) as any; obj["2S38X#"][0]["dj/{gXY"].sp_ya888x = [new Date(Date.UTC(2018, 5, 14)), -2069, 784]; - const result = patch(src, obj); + const result = patch(src, obj, undefined); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("[[\"2S38X#\"]]\n\"dj/{gXY\".sp_ya888x = [ 2018-06-14T00:00:00.000Z, -2069, 784, ]\nln_n = [\n '''\n,''',\n [{ \"t&R/jR\" = '''\nTPV!''' }, false, 287_173, \"NAzv Be{}8z]C2QzJpyz}_]idd\", 1984-04-16T12:37:13Z],\n]"); diff --git a/scripts/distilled-1286183.ts b/scripts/distilled-1286183.ts index d1ec1f1f..c5d5747e 100644 --- a/scripts/distilled-1286183.ts +++ b/scripts/distilled-1286183.ts @@ -1,4 +1,7 @@ -test.fails('distilled regression for fuzz seed 1286183', () => { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 1286183', () => { const src = dedent` i5qpju2q = """ 8=""" @@ -15,7 +18,17 @@ test.fails('distilled regression for fuzz seed 1286183', () => { const obj = parse(src) as any; delete obj.p7jqr__s.j1["d1bJi gG\"\"\", i-43c9963p.bre5k8g28.guv2pn = 'l0-Y?;FYxwz', su.p = 36740, \"Pv3!oN4\".ub_k79_ = false, dnc3w6 = \"\", \"ag/MY[2\".ud = 527558, \"Y][2vRao-\".ekiao1h.t88fjn = 2080-12-04, \"@\" = 31256.58522, kqk32ihb.xcd01 = 819597, xf79wkh3q = 0x08,,i = -284092, ZZ = 0o64, jbr.r0acvtrwd = true \"&f\".ck.pe = 2081-06-28T00:40:23Z, \"5BN)X3P5:\".b6dgagg = \"\"\"\r\nDU)$R8E>JL*\"\"\", do8b6z.f4f-0l = {}, zhd31f5qk,h862qtmhq = 90692.79376, hc.\"+ZlY;4|\" = true\r\n }\r\n"); diff --git a/scripts/distilled-1383962.ts b/scripts/distilled-1383962.ts index d7015e20..49db10d6 100644 --- a/scripts/distilled-1383962.ts +++ b/scripts/distilled-1383962.ts @@ -1,4 +1,7 @@ -test.fails('distilled regression for fuzz seed 1383962', () => { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 1383962', () => { const src = dedent` "1vn[1flkZ".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, """ s~""", 76968.6746, 'Qr+qJ|jGKM n=c1oEx^\`p2;L0H_Nyg*3K$:A{?jJg:I', 2069-08-08T04:34:33, "A3!)hHn6}I"] }, 574310, 28569, -63757100000000418476, """ @@ -10,7 +13,7 @@ test.fails('distilled regression for fuzz seed 1383962', () => { const obj = parse(src) as any; obj.kmza[0].ptvl = new Date(Date.UTC(2006, 1, 14)); - const result = patch(src, obj); + const result = patch(src, obj, undefined); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("\"1vn[1flkZ\".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, \"\"\"\ns~\"\"\", 76968.6746, 'Qr+qJ|jGKM n=c1oEx^`p2;L0H_Nyg*3K$:A{?jJg:I', 2069-08-08T04:34:33, \"A3!)hHn6}I\"] }, 574310, 28569, -63757100000000418476, \"\"\"\n8=Qed7]-^jftM\"\"\", -50759.88688, -inf, true]\n[[kmza]]\nptvl = 2006-02-14T00:00:00.000Z"); diff --git a/scripts/distilled-1693919.ts b/scripts/distilled-1693919.ts index e7700f2c..ca5a0f52 100644 --- a/scripts/distilled-1693919.ts +++ b/scripts/distilled-1693919.ts @@ -1,4 +1,7 @@ -test.fails('distilled regression for fuzz seed 1693919', () => { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 1693919', () => { const src = dedent` [g3s_y5jv_d.mfdfdc] "".c4esqy.t79d__y = { @@ -12,7 +15,17 @@ test.fails('distilled regression for fuzz seed 1693919', () => { const obj = parse(src) as any; obj.g3s_y5jv_d.mfdfdc[""].c4esqy.t79d__y.bnJF1EQ = { "k27": { "k57": false, "k17": false, "k67": [new Date(Date.UTC(2036, 8, 23)), true, -1287.6224634237587] } }; - const result = patch(src, obj); + const result = patch(src, obj, { + inlineTableStart: 1, + trailingComma: true, + bracketSpacing: true, + updateOrder: false, + trailingNewline: 1, + newLine: '\r\n', + leadingBom: false, + truncateZeroTimeInDates: false, + useTabsForIndentation: true +}); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("[g3s_y5jv_d.mfdfdc]\r\n\"\".c4esqy.t79d__y = {\r\n\t\t\t\tbnJF1EQ = { k27 = { k57 = false, k17 = false, k67 = [ 2036-09-23T00:00:00.000Z, true, -1287.6224634237587, ], } },\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"#?VRmg\".\" { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 175924', () => { const src = dedent` [[l."=".p]] m = { @@ -14,7 +17,16 @@ test.fails('distilled regression for fuzz seed 175924', () => { const obj = parse(src) as any; delete obj.l["="].p[0].qsof[2][1].t7a5uwjr2; - const result = patch(src, obj); + const result = patch(src, obj, { + trailingComma: false, + bracketSpacing: true, + updateOrder: false, + trailingNewline: 1, + newLine: '\r\n', + leadingBom: true, + truncateZeroTimeInDates: false, + useTabsForIndentation: false +}); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("[[l.\"=\".p]]\r\nm = {\r\n}\r\nqsof = [\"--2#k+z+M,Et1[EnDbG0_Ykh7^ \", 1988-12-04T04:19:06, [\r\n 0b000000110001011,\r\n {},\r\n-90769,\r\n,] ]\r\n 65260.050825\r\n"); diff --git a/scripts/distilled-1896226.ts b/scripts/distilled-1896226.ts index ea4d8ed5..25672d35 100644 --- a/scripts/distilled-1896226.ts +++ b/scripts/distilled-1896226.ts @@ -1,4 +1,7 @@ -test.fails('distilled regression for fuzz seed 1896226', () => { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 1896226', () => { const src = dedent` y0pgo = ''' zw$#+{rD] Q:|WI_5xV''' @@ -14,7 +17,7 @@ test.fails('distilled regression for fuzz seed 1896226', () => { const obj = parse(src) as any; delete obj.syn2x.b.o6y2i.yutxpp.uzz["/S|"][2].c19.hv; - const result = patch(src, obj); + const result = patch(src, obj, undefined); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("y0pgo = '''\nzw$#+{rD] Q:|WI_5xV'''\n[syn2x.b.o6y2i]\nxx1dnmb-lu.i-b5qipqve = { kh5rr3tnn.tfv.acbd8jed_ = true, am7fwt.jri8xuo = \"drC+m!9n vxA(?w8N+)9]jF1 /\", eu0e.ygeqkl.rv5zela02z = -792779, c.teb.pe = 0xd45, fk-no = false, wvddr.gmv.d8iwt = \"]49F#3@Gi^y{@4y(eYalh%@\", piif.qwkfgiyp = false, \"kNg6#mA\".mmu1i.u1aubp = true, hk3wa8v.\">uk|E`!8a\" = 2055-10-17T23:43:59.550334Z, \"|EnD &e~>`\".npriy.OU = inf, m3y3lj.viyt2w.c = \"U/qmv0wx=W/ib/M^t { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 2185943', () => { const src = dedent` ei.Nj = """ >!~Wg-y)~""" @@ -18,7 +21,7 @@ test.fails('distilled regression for fuzz seed 2185943', () => { const obj = parse(src) as any; delete obj["megac68k-"][0].kw[5].cnoff.l7["UMN$eUS9="]; - const result = patch(src, obj); + const result = patch(src, obj, undefined); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("ei.Nj = \"\"\"\n>!~Wg-y)~\"\"\"\n[[megac68k-]]\nz = [\n]\nkw = [46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275_068, {\n cnoff = { l7 = {} },\n aujqbb = '''\nP/*''',\n }, ]\n 22787.072880, 79051.79185, true\n\"1x(4u.WK2x\".v4r4evh1.c3pw90 = \"\"\"\nm@7\"\"\""); diff --git a/scripts/distilled-2497422.ts b/scripts/distilled-2497422.ts index 5e76b432..1a372fc8 100644 --- a/scripts/distilled-2497422.ts +++ b/scripts/distilled-2497422.ts @@ -1,4 +1,7 @@ -test.fails('distilled regression for fuzz seed 2497422', () => { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 2497422', () => { const src = dedent` "x^@zw" = [' ', -78860.81892, { ik4o2d6z4j = """ DM}T+uCZVx*t+qMtQtbPrFiI""" }, "\`kO^E(R8324tzB;#iBmn9aX!9KUL*N0eWvf/J:R", 36709.83314, [true, "ZrS|K#MaiwaYr4UOIP<$=8clcC*+Px{^#v@>G|(@>;a>w&X&Jb", 945e+85, -30134.83738, { @@ -9,7 +12,7 @@ test.fails('distilled regression for fuzz seed 2497422', () => { const obj = parse(src) as any; delete obj["x^@zw"][2].ik4o2d6z4j; - const result = patch(src, obj); + const result = patch(src, obj, undefined); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("\"x^@zw\" = [' ', -78860.81892, {}, \"`kO^E(R8324tzB;#iBmn9aX!9KUL*N0eWvf/J:R\", 36709.83314, [true, \"ZrS|K#MaiwaYr4UOIP<$=8clcC*+Px{^#v@>G|(@>;a>w&X&Jb\", 945e+85, -30134.83738, {\n}, \"\"\"\nHLzq$8|?\"\"\", \" { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 2531104', () => { const src = dedent` mmx9rwvk7k.lqmp_-1cgl = ''' 5>ZvY=\`r~ha''' @@ -12,7 +15,18 @@ test.fails('distilled regression for fuzz seed 2531104', () => { const obj = parse(src) as any; obj.wcyw.tf["O`aB"][1] = { "k96": 1640, "k18": true }; - const result = patch(src, obj); + const result = patch(src, obj, { + inlineTableStart: 1, + trailingComma: true, + bracketSpacing: false, + updateOrder: true, + trailingNewline: 1, + newLine: '\n', + leadingBom: true, + truncateZeroTimeInDates: false, + useTabsForIndentation: false, + minimumDecimals: 1 +}); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("mmx9rwvk7k.lqmp_-1cgl = '''\n 5>ZvY=`r~ha'''\nwcyw = {\n tf.\"O`aB\" = [true, {k96 = 1640.0, k18 = true}, 745102, 96226.27680, 0o21016, false, 802344, \"#vH#E:o>J[jB||M\"],\n\n} l.\"~u)HFjuZ\" = 00:16:17,\n"); diff --git a/scripts/distilled-2667551.ts b/scripts/distilled-2667551.ts index 7887479e..de1ce26e 100644 --- a/scripts/distilled-2667551.ts +++ b/scripts/distilled-2667551.ts @@ -1,4 +1,7 @@ -test.fails('distilled regression for fuzz seed 2667551', () => { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 2667551', () => { const src = dedent` [[xwed7dv]] w9ri7t6az = { @@ -11,7 +14,17 @@ test.fails('distilled regression for fuzz seed 2667551', () => { const obj = parse(src) as any; obj.xwed7dv[0].ax.dkibhxt.splice(2, 1); - const result = patch(src, obj); + const result = patch(src, obj, { + inlineTableStart: 1, + trailingComma: false, + bracketSpacing: false, + updateOrder: true, + trailingNewline: 1, + newLine: '\n', + leadingBom: false, + truncateZeroTimeInDates: true, + useTabsForIndentation: false +}); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("[[xwed7dv]]\nw9ri7t6az = {\n}\nax.dkibhxt = ['', \"\"\"\n>X+YrY`N\"\"\", \"\"\"\n$e\"\"\"\n"); diff --git a/scripts/distilled-2824408.ts b/scripts/distilled-2824408.ts index c1aed716..276cd145 100644 --- a/scripts/distilled-2824408.ts +++ b/scripts/distilled-2824408.ts @@ -1,4 +1,7 @@ -test.fails('distilled regression for fuzz seed 2824408', () => { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 2824408', () => { const src = dedent` [ivk.nuan749c7] pJ.kd7jct7_o = { @@ -16,7 +19,17 @@ test.fails('distilled regression for fuzz seed 2824408', () => { const obj = parse(src) as any; obj.ivk.nuan749c7.pJ.kd7jct7_o.xwjbnijuw = { "k19": -188 }; - const result = patch(src, obj); + const result = patch(src, obj, { + trailingComma: true, + bracketSpacing: false, + updateOrder: true, + trailingNewline: 2, + newLine: '\n', + leadingBom: false, + truncateZeroTimeInDates: false, + useTabsForIndentation: false, + minimumDecimals: 1 +}); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("[ivk.nuan749c7]\npJ.kd7jct7_o = {\n xwjbnijuw = {k19 = -188.0},\n\"`pD\"U9SE]2S+ { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 2858114', () => { const src = dedent` sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, "qEZv", 0x703714, "p_\`:SMIvsp(<$", "m|2Z@v-c},BQ~+EDUZQr(lxlbX^iy0Mi>{G%c", """ ;(d7x""", { @@ -11,7 +14,7 @@ test.fails('distilled regression for fuzz seed 2858114', () => { const obj = parse(src) as any; obj.sw2.rfc4998bx.splice(5, 1); - const result = patch(src, obj); + const result = patch(src, obj, undefined); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, \"qEZv\", \"p_`:SMIvsp(<$\", \"m|2Z@v-c},BQ~+EDUZQr(lxlbX^iy0Mi>{G%c\", \"\"\"\n;(d7x\"\"\", {\n e_8y4s = 59108.35246,\n ]\nxe_p.w5yln8z5 = '''\nxo5o'''"); diff --git a/scripts/distilled-377453.ts b/scripts/distilled-377453.ts index 72de4051..a80a70c9 100644 --- a/scripts/distilled-377453.ts +++ b/scripts/distilled-377453.ts @@ -1,4 +1,7 @@ -test.fails('distilled regression for fuzz seed 377453', () => { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 377453', () => { const src = dedent` eqki- = { v1xubvr5b2 = [false, true, ''' @@ -11,7 +14,17 @@ test.fails('distilled regression for fuzz seed 377453', () => { const obj = parse(src) as any; obj["eqki-"].v1xubvr5b2[4] = { "k94": -4472 }; - const result = patch(src, obj); + const result = patch(src, obj, { + trailingComma: true, + bracketSpacing: true, + updateOrder: false, + trailingNewline: 1, + newLine: '\n', + leadingBom: false, + truncateZeroTimeInDates: true, + useTabsForIndentation: false, + minimumDecimals: 1 +}); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("eqki- = {\n v1xubvr5b2 = [false, true, '''\n<6@Qd}q =`q]B>FSx''', -49_687.079945, { k94 = -4472.0 }, \"I3q/hH\", 753862, 'z', -64864.2541],\n\n} gmpmfyn4.y1np1 = 0o4755211,\n"); diff --git a/scripts/distilled-771152.ts b/scripts/distilled-771152.ts index 1f9f3190..6dd46ec2 100644 --- a/scripts/distilled-771152.ts +++ b/scripts/distilled-771152.ts @@ -1,4 +1,7 @@ -test.fails('distilled regression for fuzz seed 771152', () => { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 771152', () => { const src = dedent` b_3cmsbhh.al1erl4-9 = [236463, ''' -xl6''', 0o34, 90356.53508, true, [{ us.xl."/" = """ @@ -8,7 +11,7 @@ test.fails('distilled regression for fuzz seed 771152', () => { const obj = parse(src) as any; obj.b_3cmsbhh["al1erl4-9"][5][0].us.xl = { "k51": -3337.0673237368464, "k49": "QhvX_vl aKj9dsQ0r7" }; - const result = patch(src, obj); + const result = patch(src, obj, undefined); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("b_3cmsbhh.al1erl4-9 = [236463, '''\n-xl6''', 0o34, 90356.53508,]true, [{us.xl.k51 = -3337.0673237368464, us.xl.k49 = \"QhvX_vl aKj9dsQ0r7\"}, 1986-03-02]"); diff --git a/scripts/distilled-863664.ts b/scripts/distilled-863664.ts index 93fd8f16..d324c2ec 100644 --- a/scripts/distilled-863664.ts +++ b/scripts/distilled-863664.ts @@ -1,4 +1,7 @@ -test.fails('distilled regression for fuzz seed 863664', () => { +import dedent from 'dedent'; +import { parse, patch } from '../src'; + +test('distilled regression for fuzz seed 863664', () => { const src = dedent` [pm."+w_y2".nlgwd4] k86xv_q."]7]P2g".xq99y = [ @@ -13,7 +16,7 @@ test.fails('distilled regression for fuzz seed 863664', () => { const obj = parse(src) as any; obj.d5 = "P- L9H"; - const result = patch(src, obj); + const result = patch(src, obj, undefined); expect(parse(result)).toEqual(obj); // TODO: assert exact output after the implementation fix. // expect(result).toEqual("d5 = \"P- L9H\"\n\n[pm.\"+w_y2\".nlgwd4]\nk86xv_q.\"]7]P2g\".xq99y = [\n { chm72ws.si3 = 0xd5c289d, x = 2075-01-15T23:10:01.402080, s9gatl14xa.f6fbq_msjy = { f.nl = 597050, k.z30i = 939925, i_6d2w.\"g3}7A0\" = 785203, 5C = 465053, e3tzu = 822110, \"_hGRMk`Qg~\".ku = 412e-33, fvemmt = false, wrs2ylbfaf.\"AQ#B,[LL=c\".ep = -282456, bs3o4.knr3jqyf.h = 70302.033641, h.z3ohf3j16 = -75001.099256, nju0p.tipdjb3pfk = 54219.46102 }, \"w@LY+a#1<\".z.\")e$TtimN7\" = 482015, \"fa}@[`\".\"ae,>ImvDxv\".49e = true, \"~/yZqL.$\" = \"gze@7dH;YExh\", by1 = '|Xd', \"\".nytor2grmi.\"\" = { h53y.uujhq.hxk5o = \"\"\"\n2^t\"\"\" }, axau5.\"NQ6U?Fo{\" = false, ubqaz = 0b111111110111000 },\n '''\n''',\n]"); diff --git a/src/__tests__/patch.distilled.fuzz.test.ts b/src/__tests__/patch.distilled.fuzz.test.ts index b28bce91..e64dbf63 100644 --- a/src/__tests__/patch.distilled.fuzz.test.ts +++ b/src/__tests__/patch.distilled.fuzz.test.ts @@ -1,207 +1,23 @@ import dedent from 'dedent'; import { parse, patch } from '../'; -test.fails('distilled fuzz seed 175924: delete a multiline string field in a nested array object', () => { - const src = dedent` - [[l."=".p]] - qsof = [0b000000110001011, { t7a5uwjr2 = """ - ]UjKeB""" }, -907693] - `; - const obj = parse(src) as any; - delete obj.l['='].p[0].qsof[1].t7a5uwjr2; - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 377453: edit an item in a multiline nested array', () => { - const src = dedent` - eqki- = { v1xubvr5b2 = [false, true, ''' - <6@Qd}q =\`q]B>FSx''', -49_687.079945, { czt."@C~NuX" = "x", t7.V.v = 0x5c5fa0, c4u9he = """ - o0q*""" }, "I3q/hH"] } - `; - const obj = parse(src) as any; - obj['eqki-'].v1xubvr5b2[4] = { k94: -4472 }; - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 771152: edit a dotted key in an inline object', () => { - const src = dedent` - b_3cmsbhh.al1erl4-9 = [236463, ''' - -xl6''', 0o34, 90356.53508, true, [{ us.xl."/" = """ - fr;,Iq*!9""" }, 1986-03-02]] - `; - const obj = parse(src) as any; - obj.b_3cmsbhh['al1erl4-9'][5][0].us.xl = { k51: -3337.0673237368464, k49: 'QhvX_vl aKj9dsQ0r7' }; - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 863664: delete the only key of a multiline inline object', () => { - const src = dedent` - [pm."+w_y2".nlgwd4] - k86xv_q."]7]P2g".xq99y = [{ "".nytor2grmi."" = { h53y.uujhq.hxk5o = """ - 2^t""" }, axau5 = false }, ''' - '''] - `; - const obj = parse(src) as any; - delete obj.pm['+w_y2'].nlgwd4.k86xv_q[']7]P2g'].xq99y[0][''].nytor2grmi[''].h53y; - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 1112646: replace a multiline AOT value with an array', () => { - const src = dedent` - [["2S38X#"]] - "dj/{gXY".sp_ya888x.mx7h8hlr = ''' - D<''' - ln_n = [ - ''' - ,''', - [{ "t&R/jR" = ''' - TPV!''' }, false, 287_173, "NAzv", 1984-04-16T12:37:13Z], - ] - `; - const obj = parse(src) as any; - obj['2S38X#'][0]['dj/{gXY'].sp_ya888x = [new Date(Date.UTC(2018, 5, 14)), -2069, 784]; - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 1286183: delete a multiline nested dotted value', () => { - const src = dedent` - [p7jqr__s] - j1."d1bJi gG""", ZZ = 0o64 } }, - } - `; - const obj = parse(src) as any; - delete obj.p7jqr__s.j1['d { - const src = dedent` - "1vn[1flkZ".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, """ - s~""", 76968.6746] }, 574310, 28569, -63757100000000418476, """ - 8=Qed7]-^jftM""", -50759.88688, -inf, true] - [[kmza]] - ptvl.s2fdfonrz9 = 'vQb/mr3cU' - `; - const obj = parse(src) as any; - obj.kmza[0].ptvl = new Date(Date.UTC(2006, 1, 14)); - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 1693919: replace a nested multiline object', () => { - const src = dedent` - [g3s_y5jv_d.mfdfdc] - "".c4esqy.t79d__y = { - bnJF1EQ = { jg08iizbia = """ - fP=SCd-/e]!;bs(AR*w3L""" }, - "#?VRmg"." { - const src = dedent` - [syn2x.b.o6y2i] - yutxpp.uzz."/S|" = ["WM^ZLQ}e", -836_768, { c19.hv = { "Z\`xk2<4{" = "08RXrX/B]Wjo}v77}^Rx", be9z3h.gaxei2pmox = """ - """ } }, { }, 2004-04-25T17:21:11] - `; - const obj = parse(src) as any; - delete obj.syn2x.b.o6y2i.yutxpp.uzz['/S|'][2].c19.hv; - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 2185943: delete a leaf in a nested multiline table', () => { - const src = dedent` - [[megac68k-]] - kw = [46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275_068, { - cnoff = { l7."UMN$eUS9="."" = ''' - C/,9''' }, - aujqbb = ''' - P/*''', - }, 22787.072880, true] - `; - const obj = parse(src) as any; - delete obj['megac68k-'][0].kw[5].cnoff.l7['UMN$eUS9=']; - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 2497422: delete a multiline inline-table member', () => { - const src = dedent` - "x^@zw" = [' ', -78860.81892, { ik4o2d6z4j = """ - DM}T+uCZVx*t+qMtQtbPrFiI""" }, "tail", 36709.83314, [true, false]] - `; - const obj = parse(src) as any; - delete obj['x^@zw'][2].ik4o2d6z4j; - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 2531104: replace an inline-table array object', () => { - const src = dedent` - wcyw = { tf."O\`aB" = [true, { "%#FUrN".cvmmswb.">&K?" = 115668, kvy9 = false, kvy9h1bo.fe7b1px4bb = """ - :-i1""" }, 745102, 96226.27680, 0o21016, false] } - `; - const obj = parse(src) as any; - obj.wcyw.tf['O`aB'][1] = { k96: 1640, k18: true }; - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 2667551: remove an array item before multiline strings', () => { - const src = dedent` - [[xwed7dv]] - ax.dkibhxt = ['', """ - >X+YrY\`N""", 2088-10-24, """ - $e""] - `; - const obj = parse(src) as any; - obj.xwed7dv[0].ax.dkibhxt.splice(2, 1); - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 2824408: replace a nested object beside multiline values', () => { - const src = dedent` - [ivk.nuan749c7] - pJ.kd7jct7_o = { xwjbnijuw = { k-l259ef = "E2 U!/a", vi.dg5 = true, f4wkz1o_j.cgog.gw8umgy = ''' - t5B02omW_Qc5gU}''', qcc3pxkelq._G = """ - \`]cA6:81:-L__K?yaccQdE{zs{v$8?CN?be""" }, "\`pD".r = "U9SE]2S+ { - const src = dedent` - sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, "qEZv", 0x703714, """ - ;(d7x""", { e_8y4s = 59108.35246 }] - `; - const obj = parse(src) as any; - obj.sw2.rfc4998bx.splice(5, 1); - const result = patch(src, obj); - expect(parse(result)).toEqual(obj); -}); - -test.fails('distilled fuzz seed 2591153: preserve nested array coordinates across edits', () => { +import '../../scripts/distilled-175924'; +import '../../scripts/distilled-377453'; +import '../../scripts/distilled-771152'; +import '../../scripts/distilled-863664'; +import '../../scripts/distilled-1112646'; +import '../../scripts/distilled-1286183'; +import '../../scripts/distilled-1383962'; +import '../../scripts/distilled-1693919'; +import '../../scripts/distilled-1896226'; +import '../../scripts/distilled-2185943'; +import '../../scripts/distilled-2497422'; +import '../../scripts/distilled-2531104'; +import '../../scripts/distilled-2667551'; +import '../../scripts/distilled-2824408'; +import '../../scripts/distilled-2858114'; + +test('distilled fuzz seed 2591153: preserve nested array coordinates across edits', () => { const src = dedent` w2 = 2049-04-03T21:46:36.579301 ["AKy:}nV@"] diff --git a/src/patch.ts b/src/patch.ts index 09efc34a..b1704b2e 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -1129,10 +1129,10 @@ function applyChanges(original: Document, updated: Document, changes: Change[], const hasMoveOrAdd = containerChanges.some(change => isMove(change) || isAdd(change)); const strings = multilineStringCount(container); const hasEnoughStrings = strings >= 2 || - (strings >= 1 && multilineHasUnresolvedChange.has(container)); + (strings >= 1 && (multilineHasUnresolvedChange.has(container) || count === 1)); return hasEnoughStrings && (count >= 3 || (count === 2 && (!hasMoveOrAdd || multilineHasUnresolvedChange.has(container))) || - (isInlineArray(container) && count === 1 && strings >= 2)); + (count === 1 && strings >= 1)); }) .map(([container]) => multilineChangePaths.get(container)!) .filter((path, index, paths) => !paths.some((other, otherIndex) => From d2ca21def507612f14204ec23692d23d7f0208ba Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:47:26 -0400 Subject: [PATCH 07/36] docs: add sweep notes for 0-3000000 --- .../fuzz-sweep-0-3000000-roundtrip-fixes.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md diff --git a/docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md b/docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md new file mode 100644 index 00000000..f5ad2e3f --- /dev/null +++ b/docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md @@ -0,0 +1,48 @@ +# Fuzz sweep 0..3,000,000 round-trip fixes + +The rerun reported 16 failures: 15 malformed round-trips and one patch exception. +The original seeds were rechecked with `scripts/fuzz-run.ts` after the fixes and +all report `FAILURES: 0`. The distilled regressions live in +`src/__tests__/patch.distilled.fuzz.test.ts` and the format-faithful fixtures +under `scripts/distilled-*.ts`. + +## Fixes + +- **175924**: deleting a multiline string member inside a nested array left the + enclosing array's closing bracket and comma offsets stale. Fixed by the + multiline-container transaction in `aa60a53`. +- **377453, 771152, 863664, 1112646, 1286183, 1383962, 1693919, 1896226, + 2185943, 2497422, 2531104, 2667551, 2824408, 2858114**: edits or removals + involving multiline inline arrays/tables applied overlapping item-level + offsets. The planner now coalesces affected multiline container changes and + keeps physical multiline values out of unsafe move chains. Fixed in + `aa60a53`. +- **2591153**: array removals emitted in original-array coordinates were moved + past same-array additions, so a later removal addressed a nonexistent index. + `Remove` changes now carry internal non-enumerable source-coordinate metadata + and `reorder()` places them before same-array additions. Fixed in `aa60a53`. + +## Distillation + +Each failure was reduced against the pre-fix control commit `3a593d0`, with the +same deterministic mutations and formatting options as the authoritative fuzz +harness. Fifteen seeds reduced to one contributing mutation. Seed `2591153` +required all three mutations, and was manually reduced to the affected nested +array plus the deletion that shifts the surrounding document. + +The reducer is tracked at `scripts/distill-seed.ts`; it rejects invalid source +candidates, preserves the AOT mutation guard and emits the seed's format so a +reduction cannot silently change the bug. + +## Validation + +- All 16 original seeds: clean individual reruns. +- All 16 distilled regressions: passing. +- `pnpm run typecheck`: passing. +- `pnpm run lint`: passing with three pre-existing warnings in + `src/__tests__/patch.test.ts`. +- `pnpm run build`: passing. + +The complete Vitest suite still contains unrelated legacy exact-format failures +in the pre-existing multiline fuzz regressions. They are outside this sweep's +reported seeds and are not changed by the final validated fuzz scope. From 3db85f4e3811fdd9b8c292261ca3c9636c455c60 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:59:42 -0400 Subject: [PATCH 08/36] test: consolidate distilled fuzz regressions --- .../fuzz-sweep-0-3000000-roundtrip-fixes.md | 3 +- scripts/check-259-case.ts | 32 -- scripts/distilled-1112646.ts | 24 -- scripts/distilled-1286183.ts | 35 -- scripts/distilled-1383962.ts | 20 - scripts/distilled-1693919.ts | 32 -- scripts/distilled-175924.ts | 33 -- scripts/distilled-1896226.ts | 24 -- scripts/distilled-2185943.ts | 28 -- scripts/distilled-2497422.ts | 19 - scripts/distilled-2531104.ts | 33 -- scripts/distilled-2591153.ts | 124 ------- scripts/distilled-2667551.ts | 31 -- scripts/distilled-2824408.ts | 36 -- scripts/distilled-2858114.ts | 21 -- scripts/distilled-377453.ts | 31 -- scripts/distilled-771152.ts | 18 - scripts/distilled-863664.ts | 23 -- src/__tests__/patch.distilled.fuzz.test.ts | 42 --- src/__tests__/patch.fuzz.test.ts | 348 ++++++++++++++++++ 20 files changed, 349 insertions(+), 608 deletions(-) delete mode 100644 scripts/check-259-case.ts delete mode 100644 scripts/distilled-1112646.ts delete mode 100644 scripts/distilled-1286183.ts delete mode 100644 scripts/distilled-1383962.ts delete mode 100644 scripts/distilled-1693919.ts delete mode 100644 scripts/distilled-175924.ts delete mode 100644 scripts/distilled-1896226.ts delete mode 100644 scripts/distilled-2185943.ts delete mode 100644 scripts/distilled-2497422.ts delete mode 100644 scripts/distilled-2531104.ts delete mode 100644 scripts/distilled-2591153.ts delete mode 100644 scripts/distilled-2667551.ts delete mode 100644 scripts/distilled-2824408.ts delete mode 100644 scripts/distilled-2858114.ts delete mode 100644 scripts/distilled-377453.ts delete mode 100644 scripts/distilled-771152.ts delete mode 100644 scripts/distilled-863664.ts delete mode 100644 src/__tests__/patch.distilled.fuzz.test.ts diff --git a/docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md b/docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md index f5ad2e3f..aca20faf 100644 --- a/docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md +++ b/docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md @@ -3,8 +3,7 @@ The rerun reported 16 failures: 15 malformed round-trips and one patch exception. The original seeds were rechecked with `scripts/fuzz-run.ts` after the fixes and all report `FAILURES: 0`. The distilled regressions live in -`src/__tests__/patch.distilled.fuzz.test.ts` and the format-faithful fixtures -under `scripts/distilled-*.ts`. +`src/__tests__/patch.fuzz.test.ts`. ## Fixes diff --git a/scripts/check-259-case.ts b/scripts/check-259-case.ts deleted file mode 100644 index 876bdd90..00000000 --- a/scripts/check-259-case.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -const target = resolve(process.argv[2] ?? process.cwd()); -const api = await import(pathToFileURL(resolve(target, 'src/index.ts')).href); -const { parse, patch } = api; - -const source = `w2 = 2049-04-03T21:46:36.579301 -["AKy:}nV@"] -p8 = { - uyvk4h."m}5?" = "Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w { - const src = dedent` - [["2S38X#"]] - "dj/{gXY".sp_ya888x.mx7h8hlr = ''' - D<''' - ln_n = [ - ''' - ,''', - [{ "t&R/jR" = ''' - TPV!''' }, false, 287_173, "NAzv Be{}8z]C2QzJpyz}_]idd", 1984-04-16T12:37:13Z], - ] - `; - - const obj = parse(src) as any; - obj["2S38X#"][0]["dj/{gXY"].sp_ya888x = [new Date(Date.UTC(2018, 5, 14)), -2069, 784]; - - const result = patch(src, obj, undefined); - expect(parse(result)).toEqual(obj); - // TODO: assert exact output after the implementation fix. - // expect(result).toEqual("[[\"2S38X#\"]]\n\"dj/{gXY\".sp_ya888x = [ 2018-06-14T00:00:00.000Z, -2069, 784, ]\nln_n = [\n '''\n,''',\n [{ \"t&R/jR\" = '''\nTPV!''' }, false, 287_173, \"NAzv Be{}8z]C2QzJpyz}_]idd\", 1984-04-16T12:37:13Z],\n]"); -}); diff --git a/scripts/distilled-1286183.ts b/scripts/distilled-1286183.ts deleted file mode 100644 index c5d5747e..00000000 --- a/scripts/distilled-1286183.ts +++ /dev/null @@ -1,35 +0,0 @@ -import dedent from 'dedent'; -import { parse, patch } from '../src'; - -test('distilled regression for fuzz seed 1286183', () => { - const src = dedent` - i5qpju2q = """ - 8=""" - [p7jqr__s] - j1."d1bJi gG""", i-43c9963p.bre5k8g28.guv2pn = 'l0-Y?;FYxwz', su.p = 36740, "Pv3!oN4".ub_k79_ = false, dnc3w6 = "", "ag/MY[2".ud = 527558, "Y][2vRao-".ekiao1h.t88fjn = 2080-12-04, "@" = 31256.58522, kqk32ihb.xcd01 = 819597, xf79wkh3q = 0x08, i = -284092, ZZ = 0o64, jbr.r0acvtrwd = true }, "&f".ck.pe = 2081-06-28T00:40:23Z, "5BN)X3P5:".b6dgagg = """ - DU)$R8E>JL*""", do8b6z.f4f-0l = {}, zhd31f5qk.h862qtmhq = 90692.79376, hc."+ZlY;4|" = true }, - } - `; - - const obj = parse(src) as any; - delete obj.p7jqr__s.j1["d1bJi gG\"\"\", i-43c9963p.bre5k8g28.guv2pn = 'l0-Y?;FYxwz', su.p = 36740, \"Pv3!oN4\".ub_k79_ = false, dnc3w6 = \"\", \"ag/MY[2\".ud = 527558, \"Y][2vRao-\".ekiao1h.t88fjn = 2080-12-04, \"@\" = 31256.58522, kqk32ihb.xcd01 = 819597, xf79wkh3q = 0x08,,i = -284092, ZZ = 0o64, jbr.r0acvtrwd = true \"&f\".ck.pe = 2081-06-28T00:40:23Z, \"5BN)X3P5:\".b6dgagg = \"\"\"\r\nDU)$R8E>JL*\"\"\", do8b6z.f4f-0l = {}, zhd31f5qk,h862qtmhq = 90692.79376, hc.\"+ZlY;4|\" = true\r\n }\r\n"); -}); diff --git a/scripts/distilled-1383962.ts b/scripts/distilled-1383962.ts deleted file mode 100644 index 49db10d6..00000000 --- a/scripts/distilled-1383962.ts +++ /dev/null @@ -1,20 +0,0 @@ -import dedent from 'dedent'; -import { parse, patch } from '../src'; - -test('distilled regression for fuzz seed 1383962', () => { - const src = dedent` - "1vn[1flkZ".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, """ - s~""", 76968.6746, 'Qr+qJ|jGKM n=c1oEx^\`p2;L0H_Nyg*3K$:A{?jJg:I', 2069-08-08T04:34:33, "A3!)hHn6}I"] }, 574310, 28569, -63757100000000418476, """ - 8=Qed7]-^jftM""", -50759.88688, -inf, true] - [[kmza]] - ptvl.s2fdfonrz9 = 'vQb/mr3cU )zJScrEv2bQ-$}kK1U&o,5hpf9@N|Ke[' - `; - - const obj = parse(src) as any; - obj.kmza[0].ptvl = new Date(Date.UTC(2006, 1, 14)); - - const result = patch(src, obj, undefined); - expect(parse(result)).toEqual(obj); - // TODO: assert exact output after the implementation fix. - // expect(result).toEqual("\"1vn[1flkZ\".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, \"\"\"\ns~\"\"\", 76968.6746, 'Qr+qJ|jGKM n=c1oEx^`p2;L0H_Nyg*3K$:A{?jJg:I', 2069-08-08T04:34:33, \"A3!)hHn6}I\"] }, 574310, 28569, -63757100000000418476, \"\"\"\n8=Qed7]-^jftM\"\"\", -50759.88688, -inf, true]\n[[kmza]]\nptvl = 2006-02-14T00:00:00.000Z"); -}); diff --git a/scripts/distilled-1693919.ts b/scripts/distilled-1693919.ts deleted file mode 100644 index ca5a0f52..00000000 --- a/scripts/distilled-1693919.ts +++ /dev/null @@ -1,32 +0,0 @@ -import dedent from 'dedent'; -import { parse, patch } from '../src'; - -test('distilled regression for fuzz seed 1693919', () => { - const src = dedent` - [g3s_y5jv_d.mfdfdc] - "".c4esqy.t79d__y = { - bnJF1EQ = { jg08iizbia = """ - fP=SCd-/e]!;bs(AR*w3L""" }, - "#?VRmg"." { - const src = dedent` - [[l."=".p]] - m = { - } - qsof = ["--2#k+z+M,Et1[EnDbG0_Ykh7^ ", 1988-12-04T04:19:06, [ - 0b000000110001011, - { t7a5uwjr2 = """ - ]UjKeB""" }, - -907693, - ], 65260.050825] - `; - - const obj = parse(src) as any; - delete obj.l["="].p[0].qsof[2][1].t7a5uwjr2; - - const result = patch(src, obj, { - trailingComma: false, - bracketSpacing: true, - updateOrder: false, - trailingNewline: 1, - newLine: '\r\n', - leadingBom: true, - truncateZeroTimeInDates: false, - useTabsForIndentation: false -}); - expect(parse(result)).toEqual(obj); - // TODO: assert exact output after the implementation fix. - // expect(result).toEqual("[[l.\"=\".p]]\r\nm = {\r\n}\r\nqsof = [\"--2#k+z+M,Et1[EnDbG0_Ykh7^ \", 1988-12-04T04:19:06, [\r\n 0b000000110001011,\r\n {},\r\n-90769,\r\n,] ]\r\n 65260.050825\r\n"); -}); diff --git a/scripts/distilled-1896226.ts b/scripts/distilled-1896226.ts deleted file mode 100644 index 25672d35..00000000 --- a/scripts/distilled-1896226.ts +++ /dev/null @@ -1,24 +0,0 @@ -import dedent from 'dedent'; -import { parse, patch } from '../src'; - -test('distilled regression for fuzz seed 1896226', () => { - const src = dedent` - y0pgo = ''' - zw$#+{rD] Q:|WI_5xV''' - [syn2x.b.o6y2i] - xx1dnmb-lu.i-b5qipqve = { kh5rr3tnn.tfv.acbd8jed_ = true, am7fwt.jri8xuo = "drC+m!9n vxA(?w8N+)9]jF1 /", eu0e.ygeqkl.rv5zela02z = -792779, c.teb.pe = 0xd45, fk-no = false, wvddr.gmv.d8iwt = "]49F#3@Gi^y{@4y(eYalh%@", piif.qwkfgiyp = false, "kNg6#mA".mmu1i.u1aubp = true, hk3wa8v.">uk|E\`!8a" = 2055-10-17T23:43:59.550334Z, "|EnD &e~>\`".npriy.OU = inf, m3y3lj.viyt2w.c = "U/qmv0wx=W/ib/M^tuk|E`!8a\" = 2055-10-17T23:43:59.550334Z, \"|EnD &e~>`\".npriy.OU = inf, m3y3lj.viyt2w.c = \"U/qmv0wx=W/ib/M^t { - const src = dedent` - ei.Nj = """ - >!~Wg-y)~""" - [[megac68k-]] - z = [ - ] - kw = [46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275_068, { - cnoff = { l7."UMN$eUS9="."" = ''' - C/,9''' }, - aujqbb = ''' - P/*''', - }, 22787.072880, 79051.79185, true] - "1x(4u.WK2x".v4r4evh1.c3pw90 = """ - m@7""" - `; - - const obj = parse(src) as any; - delete obj["megac68k-"][0].kw[5].cnoff.l7["UMN$eUS9="]; - - const result = patch(src, obj, undefined); - expect(parse(result)).toEqual(obj); - // TODO: assert exact output after the implementation fix. - // expect(result).toEqual("ei.Nj = \"\"\"\n>!~Wg-y)~\"\"\"\n[[megac68k-]]\nz = [\n]\nkw = [46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275_068, {\n cnoff = { l7 = {} },\n aujqbb = '''\nP/*''',\n }, ]\n 22787.072880, 79051.79185, true\n\"1x(4u.WK2x\".v4r4evh1.c3pw90 = \"\"\"\nm@7\"\"\""); -}); diff --git a/scripts/distilled-2497422.ts b/scripts/distilled-2497422.ts deleted file mode 100644 index 1a372fc8..00000000 --- a/scripts/distilled-2497422.ts +++ /dev/null @@ -1,19 +0,0 @@ -import dedent from 'dedent'; -import { parse, patch } from '../src'; - -test('distilled regression for fuzz seed 2497422', () => { - const src = dedent` - "x^@zw" = [' ', -78860.81892, { ik4o2d6z4j = """ - DM}T+uCZVx*t+qMtQtbPrFiI""" }, "\`kO^E(R8324tzB;#iBmn9aX!9KUL*N0eWvf/J:R", 36709.83314, [true, "ZrS|K#MaiwaYr4UOIP<$=8clcC*+Px{^#v@>G|(@>;a>w&X&Jb", 945e+85, -30134.83738, { - }, """ - HLzq$8|?""", "G|(@>;a>w&X&Jb\", 945e+85, -30134.83738, {\n}, \"\"\"\nHLzq$8|?\"\"\", \" { - const src = dedent` - mmx9rwvk7k.lqmp_-1cgl = ''' - 5>ZvY=\`r~ha''' - wcyw = { - tf."O\`aB" = [true, { "%#FUrN".cvmmswb6.">&K?" = 115668, p2ohe0.sqh9uj."$Qk^~(" = -92131.88369, kvy9 = false, kvy9h1bo.fe7b1px4bb = """ - :-i1""" }, 745102, 96226.27680, 0o21016, false, 802344, "#vH#E:o>J[jB||M"], - l."~u)HFjuZ" = 00:16:17, - } - `; - - const obj = parse(src) as any; - obj.wcyw.tf["O`aB"][1] = { "k96": 1640, "k18": true }; - - const result = patch(src, obj, { - inlineTableStart: 1, - trailingComma: true, - bracketSpacing: false, - updateOrder: true, - trailingNewline: 1, - newLine: '\n', - leadingBom: true, - truncateZeroTimeInDates: false, - useTabsForIndentation: false, - minimumDecimals: 1 -}); - expect(parse(result)).toEqual(obj); - // TODO: assert exact output after the implementation fix. - // expect(result).toEqual("mmx9rwvk7k.lqmp_-1cgl = '''\n 5>ZvY=`r~ha'''\nwcyw = {\n tf.\"O`aB\" = [true, {k96 = 1640.0, k18 = true}, 745102, 96226.27680, 0o21016, false, 802344, \"#vH#E:o>J[jB||M\"],\n\n} l.\"~u)HFjuZ\" = 00:16:17,\n"); -}); diff --git a/scripts/distilled-2591153.ts b/scripts/distilled-2591153.ts deleted file mode 100644 index 988e7db5..00000000 --- a/scripts/distilled-2591153.ts +++ /dev/null @@ -1,124 +0,0 @@ -test.fails('distilled regression for fuzz seed 2591153', () => { - const src = dedent` - gas = true - sz = 178262 - "v^.aq/(w" = 2058-10-24T21:21:43 - mt5434cc."Cx[+"."q(?TYenJR" = """ - &m}Y""" - ix4t8ryk8."hBTJ&l&3bl".yt3z6_ = true - w2 = 2049-04-03T21:46:36.579301 - "R " = 0o150531 - clkd176.gs6 = -579731 - "FkeQ?W".u5oxixqh = "w4*5](T\`Wgjlbi+@wYY2o~" - # yg=|nPERhmd_2Qj;Qu$O^|T2*u - ryhwr.m5tn.gsg1h = 207_795 - p6jzbb677n.uud.fyoqco70y = 90023.48570 - k0cxt83.iwobqz4vi.NQSltlW = 513513 - # nx%JXe@oDPCx$/Iz-dV%:EI)/ - [["".lcmm1m0.vfkm5_p]] - "Wg[" = 0b0011011 - "m4}cz".jnyu = 2080-08-12T23:09:05Z - # |J^H\`E{/un~cEYJoJb - # 7ZBeQqq/ot:" = 258084 - vhashp_7d-."zD_>~7Lf3" = -464765 - "q=y".ojj = """ - iB""" - x7co_.u.AtJ = 754_620 - - [q2c."o!AxxvqZj"] - g9p75i5s_ = ''' - !esM3g7w - }I2t-:Y-|#MD - E] - ,^vU - oEA - w - 0 - ! - 7nGBp - - ''' - cmzsfzz.f3utn728zu = true - "^]jfx9Hz_" = 2053-10-21T07:50:22Z - f6ziybf.egyieof = false - "B3:c%G9".sf_90m7q.ncide1 = 1981-12-01T06:47:08Z - t = 986e-61 - ez."yTxL(kP[j0"."SPCIi(YbYe" = 53780.13362 - cpqdsgprv."%-+WE".x9vi = 2091-11-01T14:04:57 - s-yvw-4f.jes8rouk = 82_668.64849 - - [gspp.e.vg_08p5dgs] - eegf54qq.b3ff_g = true - bttb8h.vk5c."" = [] - dx2feuc.3 = """ - -z - =t9EF) - 6+""" - # ~xsBrSdMAd;mr\`JPYi - yh3l3-g2x.gmw3_.ppq_ep3k3 = """ - = VD95 - v?{gW - j - 1,]OS5""" - f6qrvqct.ny0fj-3 = 367707 - fx = nan - # oO!S;$e^N>g{%,+eJzcq(}kE,@J=je+M - [[qqqk."C<4M\`%W2L"]] - - [[y9wecl5]] - rw3l2q8mk.d-ps = ']zgL)[hSjD.R+' - # i(M^R&yAn3x3pj]ayUh -^ *xi\`1,cQ(~ - qe = 995783 - vag."[Q&%][ 6ap" = 559418 - aam48 = true - - ["AKy:}nV@"] - kl7u = true - # Vu_I\`CdTJh>9oacHXu\`o>|Nt\`=pHltLN - "hG b+T".oyyuk3 = 937467 - r46f8rhrh.rprqr8g = -566314 - p8 = { - uyvk4h."m}5?" = "Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w:\" = 258084\nvhashp_7d-.\"zD_>~7Lf3\" = -464765\n\"q=y\".ojj = \"\"\"\niB\"\"\"\nx7co_.u.AtJ = 754_620\n\n[q2c.\"o!AxxvqZj\"]\ng9p75i5s_ = '''\n!esM3g7w\n}I2t-:Y-|#MD\nE]\n,^vU\noEA\nw\n0\n!\n7nGBp\n\n'''\ncmzsfzz.f3utn728zu = true\n\"^]jfx9Hz_\" = 2053-10-21T07:50:22Z\nf6ziybf.egyieof = false\n\"B3:c%G9\".sf_90m7q.ncide1 = 1981-12-01T06:47:08Z\nt = 986e-61\nez.\"yTxL(kP[j0\".\"SPCIi(YbYe\" = 53780.13362\ncpqdsgprv.\"%-+WE\".x9vi = 2091-11-01T14:04:57\ns-yvw-4f.jes8rouk = 82_668.64849\n\n[gspp.e.vg_08p5dgs]\neegf54qq.b3ff_g = true\nbttb8h.vk5c.\"\" = []\ndx2feuc.3 = \"\"\"\n-z\n=t9EF)\n6+\"\"\"\n# ~xsBrSdMAd;mr`JPYi\nyh3l3-g2x.gmw3_.ppq_ep3k3 = \"\"\"\n= VD95\n v?{gW\nj\n1,]OS5\"\"\"\nf6qrvqct.ny0fj-3 = 367707\nfx = nan\n# oO!S;$e^N>g{%,+eJzcq(}kE,@J=je+M\n[[qqqk.\"C<4M`%W2L\"]]\n\n[[y9wecl5]]\nrw3l2q8mk.d-ps = ']zgL)[hSjD.R+'\n# i(M^R&yAn3x3pj]ayUh -^ *xi`1,cQ(~\nqe = 995783\nvag.\"[Q&%][ 6ap\" = 559418\naam48 = true\n\n[\"AKy:}nV@\"]\nkl7u = true\n# Vu_I`CdTJh>9oacHXu`o>|Nt`=pHltLN\n\"hG b+T\".oyyuk3 = 937467\nr46f8rhrh.rprqr8g = -566314\np8 = {\n uyvk4h.\"m}5?\" = \"Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w { - const src = dedent` - [[xwed7dv]] - w9ri7t6az = { - } - ax.dkibhxt = ['', """ - >X+YrY\`N""", 2088-10-24T00:54:56, """ - $e"""] - `; - - const obj = parse(src) as any; - obj.xwed7dv[0].ax.dkibhxt.splice(2, 1); - - const result = patch(src, obj, { - inlineTableStart: 1, - trailingComma: false, - bracketSpacing: false, - updateOrder: true, - trailingNewline: 1, - newLine: '\n', - leadingBom: false, - truncateZeroTimeInDates: true, - useTabsForIndentation: false -}); - expect(parse(result)).toEqual(obj); - // TODO: assert exact output after the implementation fix. - // expect(result).toEqual("[[xwed7dv]]\nw9ri7t6az = {\n}\nax.dkibhxt = ['', \"\"\"\n>X+YrY`N\"\"\", \"\"\"\n$e\"\"\"\n"); -}); diff --git a/scripts/distilled-2824408.ts b/scripts/distilled-2824408.ts deleted file mode 100644 index 276cd145..00000000 --- a/scripts/distilled-2824408.ts +++ /dev/null @@ -1,36 +0,0 @@ -import dedent from 'dedent'; -import { parse, patch } from '../src'; - -test('distilled regression for fuzz seed 2824408', () => { - const src = dedent` - [ivk.nuan749c7] - pJ.kd7jct7_o = { - xwjbnijuw = { k-l259ef = "E2 U!/a", vi.dg5 = true, f4wkz1o_j.cgog.gw8umgy = ''' - t5B02omW_Qc5gU}''', qcc3pxkelq._G = """ - \`]cA6:81:-L__K?yaccQdE{zs{v$8?CN?be""" }, - "\`pD".r = "U9SE]2S+ { - const src = dedent` - sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, "qEZv", 0x703714, "p_\`:SMIvsp(<$", "m|2Z@v-c},BQ~+EDUZQr(lxlbX^iy0Mi>{G%c", """ - ;(d7x""", { - e_8y4s = 59108.35246, - }] - xe_p.w5yln8z5 = ''' - xo5o''' - `; - - const obj = parse(src) as any; - obj.sw2.rfc4998bx.splice(5, 1); - - const result = patch(src, obj, undefined); - expect(parse(result)).toEqual(obj); - // TODO: assert exact output after the implementation fix. - // expect(result).toEqual("sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, \"qEZv\", \"p_`:SMIvsp(<$\", \"m|2Z@v-c},BQ~+EDUZQr(lxlbX^iy0Mi>{G%c\", \"\"\"\n;(d7x\"\"\", {\n e_8y4s = 59108.35246,\n ]\nxe_p.w5yln8z5 = '''\nxo5o'''"); -}); diff --git a/scripts/distilled-377453.ts b/scripts/distilled-377453.ts deleted file mode 100644 index a80a70c9..00000000 --- a/scripts/distilled-377453.ts +++ /dev/null @@ -1,31 +0,0 @@ -import dedent from 'dedent'; -import { parse, patch } from '../src'; - -test('distilled regression for fuzz seed 377453', () => { - const src = dedent` - eqki- = { - v1xubvr5b2 = [false, true, ''' - <6@Qd}q =\`q]B>FSx''', -49_687.079945, { czt."@C~NuX" = "o5!A|8C-ri)@-U{I", t7.V.v = 0x5c5fa0, ")iE=18F=" = "h#\`^&yX^7wqnyK}(2j?~uEXfHhW1VmI;7)zeF54:Z&+whORWa", zC."&E".p2-fnhimsk = true, zo.HK.o_etoxccp6 = 0o26771, y95lhy8.x.ibl4bqh4di = 0xcfe, hmmu35k7g.iit__292.zjjfy = 15:08:47, c4u9he.";[c76C".bkkhwx2_5w = """ - o0q*""" }, "I3q/hH", 753862, 'z', -64864.2541], - gmpmfyn4.y1np1 = 0o4755211, - } - `; - - const obj = parse(src) as any; - obj["eqki-"].v1xubvr5b2[4] = { "k94": -4472 }; - - const result = patch(src, obj, { - trailingComma: true, - bracketSpacing: true, - updateOrder: false, - trailingNewline: 1, - newLine: '\n', - leadingBom: false, - truncateZeroTimeInDates: true, - useTabsForIndentation: false, - minimumDecimals: 1 -}); - expect(parse(result)).toEqual(obj); - // TODO: assert exact output after the implementation fix. - // expect(result).toEqual("eqki- = {\n v1xubvr5b2 = [false, true, '''\n<6@Qd}q =`q]B>FSx''', -49_687.079945, { k94 = -4472.0 }, \"I3q/hH\", 753862, 'z', -64864.2541],\n\n} gmpmfyn4.y1np1 = 0o4755211,\n"); -}); diff --git a/scripts/distilled-771152.ts b/scripts/distilled-771152.ts deleted file mode 100644 index 6dd46ec2..00000000 --- a/scripts/distilled-771152.ts +++ /dev/null @@ -1,18 +0,0 @@ -import dedent from 'dedent'; -import { parse, patch } from '../src'; - -test('distilled regression for fuzz seed 771152', () => { - const src = dedent` - b_3cmsbhh.al1erl4-9 = [236463, ''' - -xl6''', 0o34, 90356.53508, true, [{ us.xl."/" = """ - fr;,Iq*!9""" }, 1986-03-02]] - `; - - const obj = parse(src) as any; - obj.b_3cmsbhh["al1erl4-9"][5][0].us.xl = { "k51": -3337.0673237368464, "k49": "QhvX_vl aKj9dsQ0r7" }; - - const result = patch(src, obj, undefined); - expect(parse(result)).toEqual(obj); - // TODO: assert exact output after the implementation fix. - // expect(result).toEqual("b_3cmsbhh.al1erl4-9 = [236463, '''\n-xl6''', 0o34, 90356.53508,]true, [{us.xl.k51 = -3337.0673237368464, us.xl.k49 = \"QhvX_vl aKj9dsQ0r7\"}, 1986-03-02]"); -}); diff --git a/scripts/distilled-863664.ts b/scripts/distilled-863664.ts deleted file mode 100644 index d324c2ec..00000000 --- a/scripts/distilled-863664.ts +++ /dev/null @@ -1,23 +0,0 @@ -import dedent from 'dedent'; -import { parse, patch } from '../src'; - -test('distilled regression for fuzz seed 863664', () => { - const src = dedent` - [pm."+w_y2".nlgwd4] - k86xv_q."]7]P2g".xq99y = [ - { chm72ws.si3 = 0xd5c289d, x = 2075-01-15T23:10:01.402080, s9gatl14xa.f6fbq_msjy = { f.nl = 597050, k.z30i = 939925, i_6d2w."g3}7A0" = 785203, 5C = 465053, e3tzu = 822110, "_hGRMk\`Qg~".ku = 412e-33, fvemmt = false, wrs2ylbfaf."AQ#B,[LL=c".ep = -282456, bs3o4.knr3jqyf.h = 70302.033641, h.z3ohf3j16 = -75001.099256, nju0p.tipdjb3pfk = 54219.46102 }, "w@LY+a#1<".z.")e$TtimN7" = 482015, "fa}@[\`"."ae,>ImvDxv".49e = true, "~/yZqL.$" = "gze@7dH;YExh", by1 = '|Xd', "".nytor2grmi."" = { h53y.uujhq.hxk5o = """ - 2^t""" }, axau5."NQ6U?Fo{" = false, ubqaz = 0b111111110111000 }, - ''' - ''', - ] - [d5.dlf.n] - `; - - const obj = parse(src) as any; - obj.d5 = "P- L9H"; - - const result = patch(src, obj, undefined); - expect(parse(result)).toEqual(obj); - // TODO: assert exact output after the implementation fix. - // expect(result).toEqual("d5 = \"P- L9H\"\n\n[pm.\"+w_y2\".nlgwd4]\nk86xv_q.\"]7]P2g\".xq99y = [\n { chm72ws.si3 = 0xd5c289d, x = 2075-01-15T23:10:01.402080, s9gatl14xa.f6fbq_msjy = { f.nl = 597050, k.z30i = 939925, i_6d2w.\"g3}7A0\" = 785203, 5C = 465053, e3tzu = 822110, \"_hGRMk`Qg~\".ku = 412e-33, fvemmt = false, wrs2ylbfaf.\"AQ#B,[LL=c\".ep = -282456, bs3o4.knr3jqyf.h = 70302.033641, h.z3ohf3j16 = -75001.099256, nju0p.tipdjb3pfk = 54219.46102 }, \"w@LY+a#1<\".z.\")e$TtimN7\" = 482015, \"fa}@[`\".\"ae,>ImvDxv\".49e = true, \"~/yZqL.$\" = \"gze@7dH;YExh\", by1 = '|Xd', \"\".nytor2grmi.\"\" = { h53y.uujhq.hxk5o = \"\"\"\n2^t\"\"\" }, axau5.\"NQ6U?Fo{\" = false, ubqaz = 0b111111110111000 },\n '''\n''',\n]"); -}); diff --git a/src/__tests__/patch.distilled.fuzz.test.ts b/src/__tests__/patch.distilled.fuzz.test.ts deleted file mode 100644 index e64dbf63..00000000 --- a/src/__tests__/patch.distilled.fuzz.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import dedent from 'dedent'; -import { parse, patch } from '../'; - -import '../../scripts/distilled-175924'; -import '../../scripts/distilled-377453'; -import '../../scripts/distilled-771152'; -import '../../scripts/distilled-863664'; -import '../../scripts/distilled-1112646'; -import '../../scripts/distilled-1286183'; -import '../../scripts/distilled-1383962'; -import '../../scripts/distilled-1693919'; -import '../../scripts/distilled-1896226'; -import '../../scripts/distilled-2185943'; -import '../../scripts/distilled-2497422'; -import '../../scripts/distilled-2531104'; -import '../../scripts/distilled-2667551'; -import '../../scripts/distilled-2824408'; -import '../../scripts/distilled-2858114'; - -test('distilled fuzz seed 2591153: preserve nested array coordinates across edits', () => { - const src = dedent` - w2 = 2049-04-03T21:46:36.579301 - ["AKy:}nV@"] - p8 = { - uyvk4h."m}5?" = "Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w { // `); }); +test('distilled regression for fuzz seed 1112646', () => { + const src = dedent` + [["2S38X#"]] + "dj/{gXY".sp_ya888x.mx7h8hlr = ''' + D<''' + ln_n = [ + ''' + ,''', + [{ "t&R/jR" = ''' + TPV!''' }, false, 287_173, "NAzv Be{}8z]C2QzJpyz}_]idd", 1984-04-16T12:37:13Z], + ] + `; + + const obj = parse(src) as any; + obj["2S38X#"][0]["dj/{gXY"].sp_ya888x = [new Date(Date.UTC(2018, 5, 14)), -2069, 784]; + + const result = patch(src, obj, undefined); + expect(parse(result)).toEqual(obj); +}); + +test('distilled regression for fuzz seed 1286183', () => { + const src = dedent` + i5qpju2q = """ + 8=""" + [p7jqr__s] + j1."d1bJi gG""", i-43c9963p.bre5k8g28.guv2pn = 'l0-Y?;FYxwz', su.p = 36740, "Pv3!oN4".ub_k79_ = false, dnc3w6 = "", "ag/MY[2".ud = 527558, "Y][2vRao-".ekiao1h.t88fjn = 2080-12-04, "@" = 31256.58522, kqk32ihb.xcd01 = 819597, xf79wkh3q = 0x08, i = -284092, ZZ = 0o64, jbr.r0acvtrwd = true }, "&f".ck.pe = 2081-06-28T00:40:23Z, "5BN)X3P5:".b6dgagg = """ + DU)$R8E>JL*""", do8b6z.f4f-0l = {}, zhd31f5qk.h862qtmhq = 90692.79376, hc."+ZlY;4|" = true }, + } + `; + + const obj = parse(src) as any; + delete obj.p7jqr__s.j1["d { + const src = dedent` + "1vn[1flkZ".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, """ + s~""", 76968.6746, 'Qr+qJ|jGKM n=c1oEx^\`p2;L0H_Nyg*3K$:A{?jJg:I', 2069-08-08T04:34:33, "A3!)hHn6}I"] }, 574310, 28569, -63757100000000418476, """ + 8=Qed7]-^jftM""", -50759.88688, -inf, true] + [[kmza]] + ptvl.s2fdfonrz9 = 'vQb/mr3cU )zJScrEv2bQ-$}kK1U&o,5hpf9@N|Ke[' + `; + + const obj = parse(src) as any; + obj.kmza[0].ptvl = new Date(Date.UTC(2006, 1, 14)); + + const result = patch(src, obj, undefined); + expect(parse(result)).toEqual(obj); +}); + +test('distilled regression for fuzz seed 1693919', () => { + const src = dedent` + [g3s_y5jv_d.mfdfdc] + "".c4esqy.t79d__y = { + bnJF1EQ = { jg08iizbia = """ + fP=SCd-/e]!;bs(AR*w3L""" }, + "#?VRmg"." { + const src = dedent` + [[l."=".p]] + m = { + } + qsof = ["--2#k+z+M,Et1[EnDbG0_Ykh7^ ", 1988-12-04T04:19:06, [ + 0b000000110001011, + { t7a5uwjr2 = """ + ]UjKeB""" }, + -907693, + ], 65260.050825] + `; + + const obj = parse(src) as any; + delete obj.l["="].p[0].qsof[2][1].t7a5uwjr2; + + const result = patch(src, obj, { + trailingComma: false, + bracketSpacing: true, + updateOrder: false, + trailingNewline: 1, + newLine: '\r\n', + leadingBom: true, + truncateZeroTimeInDates: false, + useTabsForIndentation: false + }); + expect(parse(result)).toEqual(obj); +}); + +test('distilled regression for fuzz seed 1896226', () => { + const src = dedent` + y0pgo = ''' + zw$#+{rD] Q:|WI_5xV''' + [syn2x.b.o6y2i] + xx1dnmb-lu.i-b5qipqve = { kh5rr3tnn.tfv.acbd8jed_ = true, am7fwt.jri8xuo = "drC+m!9n vxA(?w8N+)9]jF1 /", eu0e.ygeqkl.rv5zela02z = -792779, c.teb.pe = 0xd45, fk-no = false, wvddr.gmv.d8iwt = "]49F#3@Gi^y{@4y(eYalh%@", piif.qwkfgiyp = false, "kNg6#mA".mmu1i.u1aubp = true, hk3wa8v.">uk|E\`!8a" = 2055-10-17T23:43:59.550334Z, "|EnD &e~>\`".npriy.OU = inf, m3y3lj.viyt2w.c = "U/qmv0wx=W/ib/M^t { + const src = dedent` + ei.Nj = """ + >!~Wg-y)~""" + [[megac68k-]] + z = [ + ] + kw = [46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275_068, { + cnoff = { l7."UMN$eUS9="."" = ''' + C/,9''' }, + aujqbb = ''' + P/*''', + }, 22787.072880, 79051.79185, true] + "1x(4u.WK2x".v4r4evh1.c3pw90 = """ + m@7""" + `; + + const obj = parse(src) as any; + delete obj["megac68k-"][0].kw[5].cnoff.l7["UMN$eUS9="]; + + const result = patch(src, obj, undefined); + expect(parse(result)).toEqual(obj); +}); + +test('distilled regression for fuzz seed 2497422', () => { + const src = dedent` + "x^@zw" = [' ', -78860.81892, { ik4o2d6z4j = """ + DM}T+uCZVx*t+qMtQtbPrFiI""" }, "\`kO^E(R8324tzB;#iBmn9aX!9KUL*N0eWvf/J:R", 36709.83314, [true, "ZrS|K#MaiwaYr4UOIP<$=8clcC*+Px{^#v@>G|(@>;a>w&X&Jb", 945e+85, -30134.83738, { + }, """ + HLzq$8|?""", " { + const src = dedent` + mmx9rwvk7k.lqmp_-1cgl = ''' + 5>ZvY=\`r~ha''' + wcyw = { + tf."O\`aB" = [true, { "%#FUrN".cvmmswb.">&K?" = 115668, p2ohe0.sqh9uj."$Qk^~(" = -92131.88369, kvy9 = false, kvy9h1bo.fe7b1px4bb = """ + :-i1""" }, 745102, 96226.27680, 0o21016, false, 802344, "#vH#E:o>J[jB||M"], + l."~u)HFjuZ" = 00:16:17, + } + `; + + const obj = parse(src) as any; + obj.wcyw.tf["O`aB"][1] = { "k96": 1640, "k18": true }; + + const result = patch(src, obj, { + inlineTableStart: 1, + trailingComma: true, + bracketSpacing: false, + updateOrder: true, + trailingNewline: 1, + newLine: '\n', + leadingBom: true, + truncateZeroTimeInDates: false, + useTabsForIndentation: false, + minimumDecimals: 1 + }); + expect(parse(result)).toEqual(obj); +}); + +test('distilled regression for fuzz seed 2667551', () => { + const src = dedent` + [[xwed7dv]] + w9ri7t6az = { + } + ax.dkibhxt = ['', """ + >X+YrY\`N""", 2088-10-24T00:54:56, """ + $e"""] + `; + + const obj = parse(src) as any; + obj.xwed7dv[0].ax.dkibhxt.splice(2, 1); + + const result = patch(src, obj, { + inlineTableStart: 1, + trailingComma: false, + bracketSpacing: false, + updateOrder: true, + trailingNewline: 1, + newLine: '\n', + leadingBom: false, + truncateZeroTimeInDates: true, + useTabsForIndentation: false + }); + expect(parse(result)).toEqual(obj); +}); + +test('distilled regression for fuzz seed 2824408', () => { + const src = dedent` + [ivk.nuan749c7] + pJ.kd7jct7_o = { + xwjbnijuw = { k-l259ef = "E2 U!/a", vi.dg5 = true, f4wkz1o_j.cgog.gw8umgy = ''' + t5B02omW_Qc5gU}''', qcc3pxkelq._G = """ + \`]cA6:81:-L__K?yaccQdE{zs{v$8?CN?be""" }, + "\`pD".r = "U9SE]2S+ { + const src = dedent` + sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, "qEZv", 0x703714, "p_\`:SMIvsp(<$", "m|2Z@v-c},BQ~+EDUZQr(lxlbX^iy0Mi>{G%c", """ + ;(d7x""", { + e_8y4s = 59108.35246, + }] + xe_p.w5yln8z5 = ''' + xo5o''' + `; + + const obj = parse(src) as any; + obj.sw2.rfc4998bx.splice(5, 1); + + const result = patch(src, obj, undefined); + expect(parse(result)).toEqual(obj); +}); + +test('distilled regression for fuzz seed 377453', () => { + const src = dedent` + eqki- = { + v1xubvr5b2 = [false, true, ''' + <6@Qd}q = \`q]B>FSx''', -49_687.079945, { czt."@C~NuX" = "o5!A|8C-ri)@-U{I", t7.V.v = 0x5c5fa0, ")iE=18F=" = "h#\`^&yX^7wqnyK}(2j?~uEXfHhW1VmI;7)zeF54:Z&+whORWa", zC."&E".p2-fnhimsk = true, zo.HK.o_etoxccp6 = 0o26771, y95lhy8.x.ibl4bqh4di = 0xcfe, hmmu35k7g.iit__292.zjjfy = 15:08:47, c4u9he.";[c76C".bkkhwx2_5w = """ + o0q*""" }, "I3q/hH", 753862, 'z', -64864.2541], + gmpmfyn4.y1np1 = 0o4755211, + } + `; + + const obj = parse(src) as any; + obj["eqki-"].v1xubvr5b2[4] = { "k94": -4472 }; + + const result = patch(src, obj, { + trailingComma: true, + bracketSpacing: true, + updateOrder: false, + trailingNewline: 1, + newLine: '\n', + leadingBom: false, + truncateZeroTimeInDates: true, + useTabsForIndentation: false, + minimumDecimals: 1 + }); + expect(parse(result)).toEqual(obj); +}); + +test('distilled regression for fuzz seed 771152', () => { + const src = dedent` + b_3cmsbhh.al1erl4-9 = [236463, ''' + -xl6''', 0o34, 90356.53508, true, [{ us.xl."/" = """ + fr;,Iq*!9""" }, 1986-03-02]] + `; + + const obj = parse(src) as any; + obj.b_3cmsbhh["al1erl4-9"][5][0].us.xl = { "k51": -3337.0673237368464, "k49": "QhvX_vl aKj9dsQ0r7" }; + + const result = patch(src, obj, undefined); + expect(parse(result)).toEqual(obj); +}); + +test('distilled regression for fuzz seed 863664', () => { + const src = dedent` + [pm."+w_y2".nlgwd4] + k86xv_q."]7]P2g".xq99y = [ + { chm72ws.si3 = 0xd5c289d, x = 2075-01-15T23:10:01.402080, s9gatl14xa.f6fbq_msjy = { f.nl = 597050, k.z30i = 939925, i_6d2w."g3}7A0" = 785203, 5C = 465053, e3tzu = 822110, "_hGRMk\`Qg~".ku = 412e-33, fvemmt = false, wrs2ylbfaf."AQ#B,[LL=c".ep = -282456, bs3o4.knr3jqyf.h = 70302.033641, h.z3ohf3j16 = -75001.099256, nju0p.tipdjb3pfk = 54219.46102 }, "w@LY+a#1<".z.")e$TtimN7" = 482015, "fa}@[\`"."ae,>ImvDxv".49e = true, "~/yZqL.$" = "gze@7dH;YExh", by1 = '|Xd', "".nytor2grmi."" = { h53y.uujhq.hxk5o = """ + 2^t""" }, axau5."NQ6U?Fo{" = false, ubqaz = 0b111111110111000 }, + ''' + ''', + ] + [d5.dlf.n] + `; + + const obj = parse(src) as any; + obj.d5 = "P- L9H"; + + const result = patch(src, obj, undefined); + expect(parse(result)).toEqual(obj); +}); + From cd2e34240e81f4919b2a9ea05ad9149815065237 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:08:51 -0400 Subject: [PATCH 09/36] fix: preserve formatting before transactional retry --- src/patch.ts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/patch.ts b/src/patch.ts index b1704b2e..1d2a34fb 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -87,7 +87,22 @@ export default function patch(existing: string, updated: any, format?: Partial = new WeakSet(), rawUpdated: any = undefined): Document { +function applyChanges(original: Document, updated: Document, changes: Change[], format: TomlFormat, temporal: boolean = false, commentEligibleNodes: WeakSet = new WeakSet(), rawUpdated: any = undefined, useMultilineTransactions = false): Document { // Track AOT keys whose entries were all removed so we can insert empty arrays. Keyed by // the dotted name for de-duplication, but carrying the path segments — a nested key like // [[a.b]] has to be re-materialised as `a.b = []`, not as a root key named `a.b`. @@ -1124,6 +1139,7 @@ function applyChanges(original: Document, updated: Document, changes: Change[], } const transactionalPaths = [...multilineChangeCounts] .filter(([container, count]) => { + if (!useMultilineTransactions) return false; if (!multilineChangePaths.has(container)) return false; const containerChanges = multilineChanges.get(container) ?? []; const hasMoveOrAdd = containerChanges.some(change => isMove(change) || isAdd(change)); @@ -1132,12 +1148,12 @@ function applyChanges(original: Document, updated: Document, changes: Change[], (strings >= 1 && (multilineHasUnresolvedChange.has(container) || count === 1)); return hasEnoughStrings && (count >= 3 || (count === 2 && (!hasMoveOrAdd || multilineHasUnresolvedChange.has(container))) || - (count === 1 && strings >= 1)); + (count === 1 && (multilineChanges.get(container) ?? []).some(isRemove))); }) .map(([container]) => multilineChangePaths.get(container)!) .filter((path, index, paths) => !paths.some((other, otherIndex) => - otherIndex !== index && other.length > path.length && - arraysEqual(other.slice(0, path.length), path) + otherIndex !== index && path.length > other.length && + arraysEqual(path.slice(0, other.length), other) )); if (process.env.TOML_PATCH_DEBUG_TRANSACTION) { console.warn([...multilineChangeCounts].map(([container, count]) => ({ From 363407f6389ab5c33e3d7efec5bec0cc347adef5 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:35:20 -0400 Subject: [PATCH 10/36] fix: make transaction debug logging browser-safe --- src/patch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/patch.ts b/src/patch.ts index 1d2a34fb..e40b3595 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -1155,7 +1155,7 @@ function applyChanges(original: Document, updated: Document, changes: Change[], otherIndex !== index && path.length > other.length && arraysEqual(path.slice(0, other.length), other) )); - if (process.env.TOML_PATCH_DEBUG_TRANSACTION) { + if (typeof process !== 'undefined' && process.env.TOML_PATCH_DEBUG_TRANSACTION) { console.warn([...multilineChangeCounts].map(([container, count]) => ({ type: container.type, path: multilineChangePaths.get(container), From 4ae4f257e427b2d177722ea0bf4d9cb1a07163d1 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:57:34 -0400 Subject: [PATCH 11/36] test: cover unvalidated patch retry --- src/__tests__/patch.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/__tests__/patch.test.ts b/src/__tests__/patch.test.ts index 5940775e..fdb39d7b 100644 --- a/src/__tests__/patch.test.ts +++ b/src/__tests__/patch.test.ts @@ -5,6 +5,24 @@ import { example } from '../__fixtures__'; import dedent from 'dedent'; import { TomlFormat } from '../toml-format'; +// This is a fault-injection test for the fallback in patch(), not a normal +// usage example. patch() validates the first output and retries with the +// transactional path when that validation fails. The getter changes the +// requested value on every read, so the retry produces a result for a +// different value. The retry must be validated too and must throw rather than +// silently return TOML that does not match the requested object. test.fails is +// intentional until that second validation is implemented. +test.fails('retry result is validated before patch returns', () => { + let reads = 0; + const updated: Record = {}; + Object.defineProperty(updated, 'value', { + enumerable: true, + get: () => ++reads + }); + + expect(() => patch('value = 0\n', updated)).toThrow(/round-trip/i); +}); + test('it should apply edit to key-value', () => { const value = parse(example); value.owner.name = 'Tim Hall'; From a32cf0b3f42e4b8b4bd67119b3a1d9b09fe23362 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:27:43 -0400 Subject: [PATCH 12/36] fix: validate patch retry results --- src/__tests__/patch.test.ts | 6 +++--- src/patch.ts | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/__tests__/patch.test.ts b/src/__tests__/patch.test.ts index fdb39d7b..77702d1c 100644 --- a/src/__tests__/patch.test.ts +++ b/src/__tests__/patch.test.ts @@ -10,9 +10,9 @@ import { TomlFormat } from '../toml-format'; // transactional path when that validation fails. The getter changes the // requested value on every read, so the retry produces a result for a // different value. The retry must be validated too and must throw rather than -// silently return TOML that does not match the requested object. test.fails is -// intentional until that second validation is implemented. -test.fails('retry result is validated before patch returns', () => { +// silently return TOML that does not match the requested object. The assertion +// documents the required fail-fast behavior when validation cannot succeed. +test('retry result is validated before patch returns', () => { let reads = 0; const updated: Record = {}; Object.defineProperty(updated, 'value', { diff --git a/src/patch.ts b/src/patch.ts index e40b3595..3f0348f7 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -93,18 +93,52 @@ export default function patch(existing: string, updated: any, format?: Partial = {}; + for (const key of Object.keys(value)) { + const normalizedValue = normalizePatchComparison(value[key]); + if (normalizedValue !== undefined) normalized[key] = normalizedValue; + } + return normalized; + } + return value; +} + /** * Recursively checks if an object graph contains any Temporal values. * Used to auto-detect whether temporal mode should be enabled for patching. From 09c6f4ed00e1641b06b4341dc57a036cb9246d48 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:27:54 -0400 Subject: [PATCH 13/36] style: remove trailing whitespace --- src/patch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/patch.ts b/src/patch.ts index 3f0348f7..ecd157f5 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -1159,7 +1159,7 @@ function applyChanges(original: Document, updated: Document, changes: Change[], const ancestors = multilineAncestors.get(target) ?? []; const targetIsMultiline = (isInlineTable(target) || isInlineArray(target)) && target.loc.end.line > target.loc.start.line; - + for (const container of targetIsMultiline ? [...ancestors, target] : ancestors) { if (containsComment(container) || multilineStringCount(container) < 1) continue; multilineChangeCounts.set(container, (multilineChangeCounts.get(container) ?? 0) + 1); From 9976c08b0ebed52ec2c5bb892cc0289ec6f02c1a Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:28:02 -0400 Subject: [PATCH 14/36] docs: fix inline table TODO wording --- src/__tests__/patch.fuzz.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/patch.fuzz.test.ts b/src/__tests__/patch.fuzz.test.ts index caece6a1..56daf0e4 100644 --- a/src/__tests__/patch.fuzz.test.ts +++ b/src/__tests__/patch.fuzz.test.ts @@ -32,8 +32,8 @@ test('replacing an object in a nested multiline array preserves trailing sibling details: { values: [-2765, new Date(Date.UTC(2016, 6, 12))] } }; - //TODO: Decide if we should consider making the inline table multiline to preserve the - //original formatting of the array. Currently, it is being converted to an single line inline table. + // TODO: Decide if we should consider making the inline table multiline to preserve the + // original formatting of the array. Currently, it is being converted to a single-line inline table. const result = patch(src, obj); expect(parse(result)).toEqual(obj); expect(result).toEqual(dedent` From dfadf5f36b15d9be675f8a091485bc5995bc108b Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:28:09 -0400 Subject: [PATCH 15/36] docs: clarify fuzz output TODO --- src/__tests__/patch.fuzz.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/patch.fuzz.test.ts b/src/__tests__/patch.fuzz.test.ts index 56daf0e4..e11a4d8b 100644 --- a/src/__tests__/patch.fuzz.test.ts +++ b/src/__tests__/patch.fuzz.test.ts @@ -3491,8 +3491,8 @@ test('regression for fuzz seed 2591153', () => { const result = patch(src, obj); expect(parse(result)).toEqual(obj); - // TODO: Change the expected value to match the actual expected result after - // applying the edits to the src (and fixing the patch function). + // TODO: Add an exact-output assertion once the intended formatting for this + // complex multi-change regression is specified. // expect(result).toEqual(dedent` // [...] // `); From ac9a959b6c513fb298f0d797618d959ed241c572 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:39:23 -0400 Subject: [PATCH 16/36] docs: update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07159d9b..64301af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## Fixed + +- Patching: Resolve additional patching roundtrip errors from fuzzing harness (3M seeds) ([#293]) + ## [3.0.3] - 2026-08-21 ### Fixed @@ -399,4 +403,5 @@ This first forked version from [timhall/toml-patch](https://github.com/timhall/t [#289]: https://github.com/DecimalTurn/toml-patch/pull/289 [#290]: https://github.com/DecimalTurn/toml-patch/pull/290 [#292]: https://github.com/DecimalTurn/toml-patch/pull/292 +[#293]: https://github.com/DecimalTurn/toml-patch/pull/293 [0e66e68]: https://github.com/DecimalTurn/toml-patch/commit/0e66e68cbf42a07bc44445e46c3ea7bea97f95c1 From 7f7bb0a4eec3f0af42896e2c8fcfa6838beb3399 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:05:53 -0400 Subject: [PATCH 17/36] fix: validate patch newline formats --- src/__tests__/__js__/patch.test.mjs | 25 +++++++++++++++++++++++++ src/patch.ts | 16 +++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/__tests__/__js__/patch.test.mjs b/src/__tests__/__js__/patch.test.mjs index 557f6f9c..1f31d4a9 100644 --- a/src/__tests__/__js__/patch.test.mjs +++ b/src/__tests__/__js__/patch.test.mjs @@ -838,6 +838,31 @@ port = 5432 } }); + it.each([ + '\r', + '\\r', + '', + 'invalid' + ])('should reject unsupported newLine value %j', (newLine) => { + expect(() => patch(originalToml, baseUpdatedObject, { newLine })) + .toThrow('Invalid newLine value: expected LF or CRLF'); + }); + + it.each([ + ['\n', '\n'], + ['\r\n', '\r\n'], + ['\\n', '\n'], + ['\\r\\n', '\r\n'], + ['lf', '\n'], + ['CrLf', '\r\n'], + ['unix', '\n'], + ['DOS', '\r\n'] + ])('should accept lenient newLine value %j as %j', (newLine, expectedNewLine) => { + const result = patch(originalToml, baseUpdatedObject, { newLine }); + const lineEnding = result.includes('\r\n') ? '\r\n' : '\n'; + expect(lineEnding).toBe(expectedNewLine); + }); + it('should handle TomlFormat constructor with all optional parameters', () => { // Test parameterless constructor - should work and use defaults expect(() => { diff --git a/src/patch.ts b/src/patch.ts index ecd157f5..06b9c099 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -76,7 +76,7 @@ export default function patch(existing: string, updated: any, format?: Partial = new WeakSet()): boolean { } return false; } +function normalizePatchFormat(format: Partial | TomlFormat | undefined): Partial | TomlFormat | undefined { + if (!format || format instanceof TomlFormat || typeof format !== 'object') return format; + if (typeof format.newLine !== 'string') return format; + if (format.newLine === '\\n') return { ...format, newLine: '\n' }; + if (format.newLine === '\\r\\n') return { ...format, newLine: '\r\n' }; + const name = format.newLine.toUpperCase(); + if (name === 'LF' || name === 'UNIX') return { ...format, newLine: '\n' }; + if (name === 'CRLF' || name === 'DOS') return { ...format, newLine: '\r\n' }; + if (format.newLine !== '\n' && format.newLine !== '\r\n') { + throw new TypeError('Invalid newLine value: expected LF or CRLF'); + } + + return format; +} /** * Length of the existing dotted key's prefix that the edit path matches. From 236922bc0410913f47c9cd37ab8b4ca55ecfffb2 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:08:08 -0400 Subject: [PATCH 18/36] test: expand JavaScript patch coverage --- src/__tests__/__js__/patch.test.mjs | 33 ++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/__tests__/__js__/patch.test.mjs b/src/__tests__/__js__/patch.test.mjs index 1f31d4a9..7094a754 100644 --- a/src/__tests__/__js__/patch.test.mjs +++ b/src/__tests__/__js__/patch.test.mjs @@ -16,7 +16,7 @@ */ import { vi } from 'vitest'; -import { patch, TomlFormat } from "../../../dist/toml-patch.js"; +import { parse, patch, TomlFormat } from "../../../dist/toml-patch.js"; describe('patch() Function JavaScript Integration', () => { const originalToml = `# Configuration file @@ -268,6 +268,37 @@ cache = true }); }); + describe('newer public behavior', () => { + it('should preserve large integers as BigInt when patching', () => { + const original = 'count = 9007199254740993\n'; + const updated = { count: 9007199254740995n }; + + const result = patch(original, updated); + + expect(result).toBe('count = 9007199254740995\n'); + expect(parse(result).count).toBe(9007199254740995n); + }); + + it('should reorder root keys when updateOrder is enabled', () => { + const original = 'first = 1\nsecond = 2\n'; + const updated = { second: 2, first: 1 }; + + const result = patch(original, updated, { updateOrder: true }); + + expect(result).toBe('second = 2\nfirst = 1\n'); + }); + + it('should append entries to an existing array of tables', () => { + const original = '[[servers]]\nname = "web"\n'; + const updated = { servers: [{ name: 'web' }, { name: 'api' }] }; + + const result = patch(original, updated); + + expect(result).toBe('[[servers]]\nname = "web"\n\n[[servers]]\nname = "api"\n'); + expect(parse(result).servers).toEqual(updated.servers); + }); + }); + describe('complex format objects', () => { it('should work with multiple format properties using object literals', () => { const updatedObject = { ...baseUpdatedObject }; From e5f40a6549d9efbdcb0604e4dc28c5608a09a95d Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:43:13 -0400 Subject: [PATCH 19/36] feat(patch): add validate option to opt out of round-trip verification patch() re-parses its own output and checks that it round-trips to the updated object on every call. Measured against latest, that costs ~35% on a small config and ~19% on a 45KB document, and it accounts for the whole regression: with validation removed the numbers return to baseline. Validation stays on by default so the guarantee holds. Callers for whom patching is hot can pass { validate: false }, which returns the first attempt directly and reaches baseline (0.999x small, 1.008x big). Opting out also disables the retry and the throw, so a patch that would have been repaired returns malformed TOML instead. The tests assert both outcomes exactly so the tradeoff is visible rather than implied. Also drops two redundant walks from the default path: hasTemporal() ran once inside patchCst() and again in every comparison, and normalizePatchComparison() was applied to the updated object twice. The function is idempotent, so neither removal changes behavior. Finally, the multiline transaction planner only feeds the retry pass, but it walked the whole document plus every change on the fine-grained pass as well and discarded the result. It now runs only when useMultilineTransactions is set. --- CHANGELOG.md | 8 +++- src/__tests__/patch.test.ts | 95 +++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/patch-options.ts | 25 ++++++++++ src/patch.ts | 94 +++++++++++++++++++++--------------- 5 files changed, 185 insertions(+), 38 deletions(-) create mode 100644 src/patch-options.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64301af8..732793c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## Fixed +### Added + +- Patching: `patch()` accepts a fourth optional `options` argument. Set `validate: false` + to skip round-trip verification of the output, which is the fastest path but returns + the unrepaired result instead of retrying or throwing ([#293]) + +### Fixed - Patching: Resolve additional patching roundtrip errors from fuzzing harness (3M seeds) ([#293]) diff --git a/src/__tests__/patch.test.ts b/src/__tests__/patch.test.ts index 77702d1c..8c0923d5 100644 --- a/src/__tests__/patch.test.ts +++ b/src/__tests__/patch.test.ts @@ -23,6 +23,101 @@ test('retry result is validated before patch returns', () => { expect(() => patch('value = 0\n', updated)).toThrow(/round-trip/i); }); +// `validate: false` opts out of the round-trip check in the four tests below. +// Together they pin the whole contract: the happy path is unchanged, the +// retry stops repairing, the throw stops firing, and only an explicit `false` +// turns any of it off. + +test('validate: false produces the same output on a patch that needs no repair', () => { + const original = dedent` + a = 1 + b = "two" + + [c] + d = [1, 2, 3] + ` + '\n'; + + const validated = parse(original); + validated.a = 42; + const skipped = parse(original); + skipped.a = 42; + + const expected = dedent` + a = 42 + b = "two" + + [c] + d = [1, 2, 3] + ` + '\n'; + + expect(patch(original, validated)).toBe(expected); + expect(patch(original, skipped, undefined, { validate: false })).toBe(expected); +}); + +// Distilled from fuzz seed 771152, which only round-trips because validation +// catches the first attempt and retries transactionally. It is the clearest +// demonstration of what opting out costs: the fine-grained write leaves the +// enclosing array's separators stale, and the result does not even parse. +test('validate: false returns the unrepaired result instead of retrying', () => { + const original = dedent` + b_3cmsbhh.al1erl4-9 = [236463, ''' + -xl6''', 0o34, 90356.53508, true, [{ us.xl."/" = """ + fr;,Iq*!9""" }, 1986-03-02]] + ` + '\n'; + + const mutate = (obj: any) => { + obj.b_3cmsbhh['al1erl4-9'][5][0].us.xl = { k51: -3337.0673237368464, k49: 'QhvX_vl aKj9dsQ0r7' }; + return obj; + }; + + // Validated: the retry rewrites the whole multiline container, which loses + // the multiline literal string, the octal base and the dotted keys, but the + // result is valid TOML that matches the requested object. + expect(patch(original, mutate(parse(original)))).toBe( + 'b_3cmsbhh.al1erl4-9 = [236463, "-xl6", 28, 90356.53508, true, ' + + '[{us = {xl = {k51 = -3337.0673237368464, k49 = "QhvX_vl aKj9dsQ0r7"}}}, 1986-03-02]]\n' + ); + + // Opted out: the original formatting survives, but the output is malformed. + const unrepaired = patch(original, mutate(parse(original)), undefined, { validate: false }); + expect(unrepaired).toBe(dedent` + b_3cmsbhh.al1erl4-9 = [236463, ''' + -xl6''', 0o34, 90356.53508,]true, [{us.xl.k51 = -3337.0673237368464, us.xl.k49 = "QhvX_vl aKj9dsQ0r7"}, 1986-03-02] + ` + '\n'); + expect(() => parse(unrepaired)).toThrow(); +}); + +test('validate: false suppresses the round-trip throw', () => { + const makeUnsatisfiable = () => { + let reads = 0; + const updated: Record = {}; + Object.defineProperty(updated, 'value', { enumerable: true, get: () => ++reads }); + return updated; + }; + + expect(() => patch('value = 0\n', makeUnsatisfiable())).toThrow(/round-trip/i); + + // The exact number is deliberately not asserted: it depends on how many + // times patch() reads the property, which is an implementation detail. + // What matters is that opting out returns unvalidated output rather than + // throwing, even though that output disagrees with any single read. + const unvalidated = patch('value = 0\n', makeUnsatisfiable(), undefined, { validate: false }); + expect(unvalidated).toMatch(/^value = \d+\n$/); +}); + +test('validation stays on unless validate is explicitly false', () => { + const makeUnsatisfiable = () => { + let reads = 0; + const updated: Record = {}; + Object.defineProperty(updated, 'value', { enumerable: true, get: () => ++reads }); + return updated; + }; + + expect(() => patch('value = 0\n', makeUnsatisfiable(), undefined, {})).toThrow(/round-trip/i); + expect(() => patch('value = 0\n', makeUnsatisfiable(), undefined, { validate: true })).toThrow(/round-trip/i); + expect(() => patch('value = 0\n', makeUnsatisfiable(), undefined, undefined)).toThrow(/round-trip/i); +}); + test('it should apply edit to key-value', () => { const value = parse(example); value.owner.name = 'Tim Hall'; diff --git a/src/index.ts b/src/index.ts index 143b222a..f8578336 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { decodeUtf8Bytes, stripLeadingBom, UTF8_BOM } from './decode-utf8'; import { TomlDocument } from './toml-document'; export type { IntegersAsBigInt, ParseOptions } from './parse-options'; +export type { PatchOptions } from './patch-options'; /** * Parses a TOML string or raw UTF-8 bytes into a JavaScript object. diff --git a/src/patch-options.ts b/src/patch-options.ts new file mode 100644 index 00000000..cac490b6 --- /dev/null +++ b/src/patch-options.ts @@ -0,0 +1,25 @@ +export interface PatchOptions { + /** + * Controls whether `patch()` verifies its own output before returning it. + * + * When true (the default), `patch()` re-parses the TOML it produced and + * checks that it round-trips back to the `updated` object. If it does not, + * `patch()` retries with a coarser writer that rewrites whole multiline + * inline containers instead of splicing individual members, and throws if + * that retry still does not round-trip. This guarantees `patch()` never + * silently returns TOML that disagrees with the object it was given. + * + * The check costs one extra parse of the output plus a structural + * comparison, which is roughly 20-40% of total `patch()` time (the + * proportion is higher for small documents, where it does not amortise). + * + * Set to false to skip verification and return the first result directly. + * The retry and the throw both become unreachable, so a patch that would + * have been repaired instead returns malformed or mismatched TOML. Only + * worth doing when patching is measurably hot and the inputs are known to + * be well covered by tests. + * + * Default: true. + */ + validate?: boolean; +} diff --git a/src/patch.ts b/src/patch.ts index 06b9c099..5c3983ea 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -53,6 +53,7 @@ import { } from './comment-alignment'; import { getSpan } from './location'; import { stripLeadingBom, UTF8_BOM } from './decode-utf8'; +import type { PatchOptions } from './patch-options'; import traverse from './traverse'; /** @@ -63,12 +64,20 @@ import traverse from './traverse'; * and updated data, then strategically applies only the necessary changes to maintain the * original document structure as much as possible. * + * By default the result is verified: `patch()` re-parses its own output and + * checks that it round-trips back to `updated`, retrying with a coarser writer + * and ultimately throwing rather than returning TOML that disagrees with the + * requested object. Pass `{ validate: false }` to skip that check when + * patching is measurably hot; see {@link PatchOptions.validate}. + * * @param existing - The original TOML document as a string * @param updated - The updated JavaScript object with desired changes * @param format - Optional formatting options to apply to new or modified sections + * @param options - Optional patch options + * @param options.validate - Verify the output round-trips to `updated`. Default: true. * @returns A new TOML string with the changes applied */ -export default function patch(existing: string, updated: any, format?: Partial | TomlFormat): string { +export default function patch(existing: string, updated: any, format?: Partial | TomlFormat, options?: PatchOptions): string { // The tokenizer flags mixed line endings as it scans, so this requires no // separate pass over the input. const newlineState = createNewlineScanState(); @@ -87,24 +96,31 @@ export default function patch(existing: string, updated: any, format?: Partial (fmt.leadingBom ? `${UTF8_BOM}${toml}` : toml); + + // Opting out returns the first attempt as-is, so nothing below runs. + if (options?.validate === false) return withBom(patchedToml); + + // Detected once and threaded into both comparisons; patchCst() derives its + // own copy internally for the diff. + const temporal = hasTemporal(updated); + if (patchResultMatches(updated, patchedToml, temporal)) return withBom(patchedToml); const retryCst = Array.from(parseTOML(stripLeadingBom(existing), createNewlineScanState())); const retriedToml = patchCst(retryCst, updated, fmt, true).tomlString; - if (!patchResultMatches(updated, retriedToml)) { + if (!patchResultMatches(updated, retriedToml, temporal)) { throw new Error('Patch retry failed round-trip validation'); } - return fmt.leadingBom ? `${UTF8_BOM}${retriedToml}` : retriedToml; + return withBom(retriedToml); } -function patchResultMatches(updated: any, toml: string): boolean { +function patchResultMatches(updated: any, toml: string, temporal: boolean): boolean { try { const parsed = Array.from(parseTOML(stripLeadingBom(toml))); - const expected = normalizePatchComparison(updated); - const actual = toJS(parsed, '', { temporal: hasTemporal(updated) }); - return stableStringify(normalizePatchComparison(actual)) === stableStringify(normalizePatchComparison(expected)); + const actual = toJS(parsed, '', { temporal }); + // normalizePatchComparison is idempotent, so `updated` is normalized once + // here rather than twice as it was when `expected` was a separate local. + return stableStringify(normalizePatchComparison(actual)) === stableStringify(normalizePatchComparison(updated)); } catch { return false; } @@ -1131,7 +1147,6 @@ function applyChanges(original: Document, updated: Document, changes: Change[], for (const item of node.items as TreeNode[]) indexMultilineAncestors(item, nextAncestors); } }; - indexMultilineAncestors(original); const commentCache = new WeakMap(); const multilineStringCountCache = new WeakMap(); function containsComment(node: TreeNode): boolean { @@ -1158,36 +1173,41 @@ function applyChanges(original: Document, updated: Document, changes: Change[], multilineStringCountCache.set(node, result); return result; } - for (const change of changes) { - let target = tryFindByPath(original, change.path); - const unresolved = !target; - if (!target) { - // An Add earlier in the same array can make a later Remove path refer to - // post-mutation coordinates. Resolve its nearest existing ancestor so - // both changes still contribute to the same transaction (seed 175924). - for (let length = change.path.length - 1; length >= 0 && !target; length--) { - target = tryFindByPath(original, change.path.slice(0, length)); + // Only the transactional pass consumes any of this, and building it walks the + // whole document plus every change, so skip it entirely on the fine-grained + // pass. The maps stay empty and transactionalPaths below comes out empty. + if (useMultilineTransactions) { + indexMultilineAncestors(original); + for (const change of changes) { + let target = tryFindByPath(original, change.path); + const unresolved = !target; + if (!target) { + // An Add earlier in the same array can make a later Remove path refer to + // post-mutation coordinates. Resolve its nearest existing ancestor so + // both changes still contribute to the same transaction (seed 175924). + for (let length = change.path.length - 1; length >= 0 && !target; length--) { + target = tryFindByPath(original, change.path.slice(0, length)); + } + } + if (!target) continue; + const ancestors = multilineAncestors.get(target) ?? []; + const targetIsMultiline = (isInlineTable(target) || isInlineArray(target)) && + target.loc.end.line > target.loc.start.line; + + for (const container of targetIsMultiline ? [...ancestors, target] : ancestors) { + if (containsComment(container) || multilineStringCount(container) < 1) continue; + multilineChangeCounts.set(container, (multilineChangeCounts.get(container) ?? 0) + 1); + const containerChanges = multilineChanges.get(container); + if (containerChanges) containerChanges.push(change); + else multilineChanges.set(container, [change]); + if (unresolved) multilineHasUnresolvedChange.add(container); + const path = absolutePathOf(container); + if (path !== undefined) multilineChangePaths.set(container, path); } - } - if (!target) continue; - const ancestors = multilineAncestors.get(target) ?? []; - const targetIsMultiline = (isInlineTable(target) || isInlineArray(target)) && - target.loc.end.line > target.loc.start.line; - - for (const container of targetIsMultiline ? [...ancestors, target] : ancestors) { - if (containsComment(container) || multilineStringCount(container) < 1) continue; - multilineChangeCounts.set(container, (multilineChangeCounts.get(container) ?? 0) + 1); - const containerChanges = multilineChanges.get(container); - if (containerChanges) containerChanges.push(change); - else multilineChanges.set(container, [change]); - if (unresolved) multilineHasUnresolvedChange.add(container); - const path = absolutePathOf(container); - if (path !== undefined) multilineChangePaths.set(container, path); } } const transactionalPaths = [...multilineChangeCounts] .filter(([container, count]) => { - if (!useMultilineTransactions) return false; if (!multilineChangePaths.has(container)) return false; const containerChanges = multilineChanges.get(container) ?? []; const hasMoveOrAdd = containerChanges.some(change => isMove(change) || isAdd(change)); @@ -1203,7 +1223,7 @@ function applyChanges(original: Document, updated: Document, changes: Change[], otherIndex !== index && path.length > other.length && arraysEqual(path.slice(0, other.length), other) )); - if (typeof process !== 'undefined' && process.env.TOML_PATCH_DEBUG_TRANSACTION) { + if (useMultilineTransactions && typeof process !== 'undefined' && process.env.TOML_PATCH_DEBUG_TRANSACTION) { console.warn([...multilineChangeCounts].map(([container, count]) => ({ type: container.type, path: multilineChangePaths.get(container), From c3cafac9cdc023c66075f34e2934fc2e3929f0b0 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:44:51 -0400 Subject: [PATCH 20/36] fix(toml-document): verify patch results and roll back on failure TomlDocument.patch() called patchCst() directly, so the round-trip guarantee in patch() never applied to it. On the distilled seed 771152 input it silently stored TOML that does not parse, and every later toJsObject()/toTomlString() call read from that broken state. This reproduces on latest. TomlDocument.patch() now validates and retries the way patch() does, and accepts the same { validate: false } opt-out. Rollback is required rather than optional here, because patchCst() mutates the CST nodes it is handed: by the time the first attempt is known to be bad, this._cst is already spent. So the retry starts from a fresh parse of the pre-patch source, and if neither attempt round-trips the document is restored from that source before throwing. A caught failure therefore leaves the document byte-identical and still patchable. The comparison honours the document's own integersAsBigInt. That exposed a gap in the shared comparison: TOML has one integer type, so a document read as bigint legitimately accepts a plain number assigned back into it, but 2 and 2n stringified differently and failed validation on a correct patch. Integers are now canonicalised to a decimal string, which keeps that working while still catching genuine precision loss, where the two decimal strings differ. patchResultMatches(), normalizePatchComparison() and hasTemporal() move to patch-validate.ts so both entry points share one implementation. That extraction was verified behaviour-neutral before any of the above. --- CHANGELOG.md | 9 ++- src/__tests__/toml-document.test.ts | 115 ++++++++++++++++++++++++++++ src/patch-validate.ts | 94 +++++++++++++++++++++++ src/patch.ts | 62 +-------------- src/toml-document.ts | 67 ++++++++++++++-- 5 files changed, 281 insertions(+), 66 deletions(-) create mode 100644 src/patch-validate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 732793c0..3a75383c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Patching: `patch()` accepts a fourth optional `options` argument. Set `validate: false` - to skip round-trip verification of the output, which is the fastest path but returns - the unrepaired result instead of retrying or throwing ([#293]) +- Patching: `patch()` and `TomlDocument.patch()` accept an optional `options` argument. Set + `validate: false` to skip round-trip verification of the output, which is the fastest + path but returns the unrepaired result instead of retrying or throwing ([#293]) ### Fixed - Patching: Resolve additional patching roundtrip errors from fuzzing harness (3M seeds) ([#293]) +- Patching: `TomlDocument.patch()` now verifies its result the way `patch()` does, instead of + silently keeping TOML that does not round-trip. A document that cannot be patched is rolled + back to its pre-patch state rather than left partially modified ([#293]) ## [3.0.3] - 2026-08-21 diff --git a/src/__tests__/toml-document.test.ts b/src/__tests__/toml-document.test.ts index 783f2fb9..ac8a2443 100644 --- a/src/__tests__/toml-document.test.ts +++ b/src/__tests__/toml-document.test.ts @@ -1909,4 +1909,119 @@ describe('TomlDocument', () => { }); }); }); + + describe('patch result validation', () => { + // Distilled from fuzz seed 771152. Before validation reached TomlDocument + // this produced TOML that did not parse, and the document silently kept it. + const trickyToml = dedent` + b_3cmsbhh.al1erl4-9 = [236463, ''' + -xl6''', 0o34, 90356.53508, true, [{ us.xl."/" = """ + fr;,Iq*!9""" }, 1986-03-02]] + ` + '\n'; + + const mutate = (doc: TomlDocument) => { + const obj = doc.toJsObject; + obj.b_3cmsbhh['al1erl4-9'][5][0].us.xl = { k51: -3337.0673237368464, k49: 'QhvX_vl aKj9dsQ0r7' }; + return obj; + }; + + it('retries so the patched document is always valid TOML', () => { + const doc = new TomlDocument(trickyToml); + doc.patch(mutate(doc)); + + // The retry rewrites the whole multiline container, losing the multiline + // literal string and the octal base, but the result is valid TOML. + expect(doc.toTomlString).toBe( + 'b_3cmsbhh.al1erl4-9 = [236463, "-xl6", 28, 90356.53508, true, ' + + '[{us = {xl = {k51 = -3337.0673237368464, k49 = "QhvX_vl aKj9dsQ0r7"}}}, 1986-03-02T00:00:00.000Z]]\n' + ); + expect(() => new TomlDocument(doc.toTomlString).toJsObject).not.toThrow(); + }); + + it('validate: false keeps the unrepaired result', () => { + const doc = new TomlDocument(trickyToml); + doc.patch(mutate(doc), undefined, { validate: false }); + + expect(doc.toTomlString).toBe(dedent` + b_3cmsbhh.al1erl4-9 = [236463, ''' + -xl6''', 0o34, 90356.53508, true, [{us.xl.k51 = -3337.0673237368464, us.xl.k49 = "QhvX_vl aKj9dsQ0r7"}, 1986-03-02T00:00:00.000Z] + ` + '\n'); + expect(() => new TomlDocument(doc.toTomlString).toJsObject).toThrow(); + }); + + // The getter changes the requested value on every read, so no output can + // ever round-trip. patchCst() mutates CST nodes as it goes, so without an + // explicit rollback the document would be left holding a tree that no + // longer agrees with its own TOML string. + const unsatisfiable = () => { + let reads = 0; + const updated: Record = {}; + Object.defineProperty(updated, 'value', { enumerable: true, get: () => ++reads }); + return updated; + }; + + it('rolls the document back when no attempt round-trips', () => { + const original = 'value = 0\n'; + const doc = new TomlDocument(original); + + expect(() => doc.patch(unsatisfiable())).toThrow(/round-trip/i); + + // Unchanged, still readable, and still patchable afterwards. + expect(doc.toTomlString).toBe(original); + expect(doc.toJsObject).toEqual({ value: 0 }); + + const obj = doc.toJsObject; + obj.value = 7; + doc.patch(obj); + expect(doc.toTomlString).toBe('value = 7\n'); + expect(doc.toJsObject).toEqual({ value: 7 }); + }); + + it('validate: false suppresses the rollback and the throw', () => { + const doc = new TomlDocument('value = 0\n'); + doc.patch(unsatisfiable(), undefined, { validate: false }); + expect(doc.toTomlString).toMatch(/^value = \d+\n$/); + }); + + // TOML has one integer type, so a document read as bigint accepts a plain + // number assigned back into it. Comparing 2 against 2n as different values + // would fail validation on a correct patch and destroy the formatting via a + // needless retry. + it.each([ + ['asNeeded' as const, 'bigint'], + [true as const, 'bigint'], + [false as const, 'number'] + ])('does not retry when integersAsBigInt is %j', (integersAsBigInt, expectedType) => { + const original = dedent` + big = 9007199254740993 + note = [ + """ + line""", + 1, + ] + x = 1 + ` + '\n'; + + const doc = new TomlDocument(original, { integersAsBigInt }); + const obj = doc.toJsObject; + expect(typeof obj.big).toBe(expectedType); + + obj.x = 2; + doc.patch(obj); + + // A retry would reflow the multiline array, so the indented `"""` and the + // trailing comma surviving proves the fine-grained path was kept. + const expectedBig = integersAsBigInt === false ? '9007199254740992.0' : '9007199254740993'; + expect(doc.toTomlString).toBe(dedent` + big = ${expectedBig} + note = [ + """ + line""", + 1, + ] + x = 2 + ` + '\n'); + }); + }); + }); diff --git a/src/patch-validate.ts b/src/patch-validate.ts new file mode 100644 index 00000000..68ed06c6 --- /dev/null +++ b/src/patch-validate.ts @@ -0,0 +1,94 @@ +import parseTOML from './parse-toml'; +import toJS from './to-js'; +import { stripLeadingBom } from './decode-utf8'; +import { isTemporal, stableStringify } from './utils'; +import type { IntegersAsBigInt } from './parse-options'; + +/** + * How the produced TOML should be read back when verifying a patch. + * + * Both fields must describe the SAME representation the caller's object uses, + * otherwise the comparison reports a mismatch for a correct patch and forces a + * needless retry. `temporal` therefore follows the updated object (see + * {@link hasTemporal}) rather than any parse-time setting, and + * `integersAsBigInt` must match whatever produced the object being compared. + */ +export interface PatchComparison { + /** Read date/time values back as Temporal objects rather than Date subclasses. */ + temporal: boolean; + /** Read integers back the way the caller's object represents them. Default: 'asNeeded'. */ + integersAsBigInt?: IntegersAsBigInt; +} + +/** + * Recursively checks if an object graph contains any Temporal values. + * Used to auto-detect whether temporal mode should be enabled for patching, + * and to decide how date/time values are read back when verifying a result. + * Cycle-safe. + */ +export function hasTemporal(obj: any, seen: WeakSet = new WeakSet()): boolean { + if (obj == null || typeof obj !== 'object') return false; + if (isTemporal(obj)) return true; + if (seen.has(obj)) return false; + seen.add(obj); + for (const v of Object.values(obj)) { + if (hasTemporal(v, seen)) return true; + } + return false; +} + +/** + * Re-parses TOML that a patch produced and reports whether it round-trips back + * to `updated`. A false result means the written TOML disagrees with the object + * it was supposed to represent, or is not parseable at all. + */ +export function patchResultMatches(updated: any, toml: string, comparison: PatchComparison): boolean { + try { + const parsed = Array.from(parseTOML(stripLeadingBom(toml))); + const actual = toJS(parsed, '', { + temporal: comparison.temporal, + integersAsBigInt: comparison.integersAsBigInt ?? 'asNeeded' + }); + // normalizePatchComparison is idempotent, so `updated` is normalized once + // here rather than twice as it was when `expected` was a separate local. + return stableStringify(normalizePatchComparison(actual)) === stableStringify(normalizePatchComparison(updated)); + } catch { + return false; + } +} + +function normalizePatchComparison(value: any): any { + if (value === undefined) return undefined; + if (typeof value === 'string') return value.replace(/\r\n?/g, '\n'); + if (Array.isArray(value)) return value.map(normalizePatchComparison); + // TOML has a single integer type, so `2` and `2n` denote the same value and + // must compare equal: a document read with integersAsBigInt yields bigints, + // and assigning a plain number back into it is legitimate. Canonicalising + // both to a decimal string keeps that working while still catching genuine + // precision loss, where the two decimal strings differ. + if (typeof value === 'bigint') return `Int:${value.toString()}`; + if (typeof value === 'number' && Number.isInteger(value)) return `Int:${BigInt(value).toString()}`; + if (value instanceof Date) { + if (value.getUTCFullYear() <= 0) { + const hours = String(value.getUTCHours()).padStart(2, '0'); + const minutes = String(value.getUTCMinutes()).padStart(2, '0'); + const seconds = String(value.getUTCSeconds()).padStart(2, '0'); + const milliseconds = String(value.getUTCMilliseconds()).padStart(3, '0'); + return `Time:${hours}:${minutes}:${seconds}.${milliseconds}`; + } + return `Date:${value.getTime()}`; + } + if (isTemporal(value)) return value.toString(); + if (value && typeof value.toJSON === 'function') { + return normalizePatchComparison(value.toJSON()); + } + if (value && typeof value === 'object') { + const normalized: Record = {}; + for (const key of Object.keys(value)) { + const normalizedValue = normalizePatchComparison(value[key]); + if (normalizedValue !== undefined) normalized[key] = normalizedValue; + } + return normalized; + } + return value; +} diff --git a/src/patch.ts b/src/patch.ts index 5c3983ea..10b43e9e 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -54,6 +54,7 @@ import { import { getSpan } from './location'; import { stripLeadingBom, UTF8_BOM } from './decode-utf8'; import type { PatchOptions } from './patch-options'; +import { hasTemporal, patchResultMatches } from './patch-validate'; import traverse from './traverse'; /** @@ -103,72 +104,17 @@ export default function patch(existing: string, updated: any, format?: Partial = {}; - for (const key of Object.keys(value)) { - const normalizedValue = normalizePatchComparison(value[key]); - if (normalizedValue !== undefined) normalized[key] = normalizedValue; - } - return normalized; - } - return value; -} - -/** - * Recursively checks if an object graph contains any Temporal values. - * Used to auto-detect whether temporal mode should be enabled for patching. - */ -function hasTemporal(obj: any, seen: WeakSet = new WeakSet()): boolean { - if (obj == null || typeof obj !== 'object') return false; - if (isTemporal(obj)) return true; - if (seen.has(obj)) return false; - seen.add(obj); - for (const v of Object.values(obj)) { - if (hasTemporal(v, seen)) return true; - } - return false; -} function normalizePatchFormat(format: Partial | TomlFormat | undefined): Partial | TomlFormat | undefined { if (!format || format instanceof TomlFormat || typeof format !== 'object') return format; if (typeof format.newLine !== 'string') return format; diff --git a/src/toml-document.ts b/src/toml-document.ts index 486cd319..1680e78d 100644 --- a/src/toml-document.ts +++ b/src/toml-document.ts @@ -3,6 +3,8 @@ import toJS from './to-js'; import { TomlFormat } from './toml-format'; import { Block } from './cst'; import { patchCst } from './patch'; +import { hasTemporal, patchResultMatches } from './patch-validate'; +import type { PatchOptions } from './patch-options'; import { detectNewline, resolveTomlFormat } from './toml-format'; import { truncateCst } from './truncate'; import type { ParseOptions, IntegersAsBigInt } from './parse-options'; @@ -74,21 +76,76 @@ export class TomlDocument { /** * Applies a patch to the current CST using a modified JS object. * Updates the internal CST. Use toTomlString getter to retrieve the updated TOML string. + * + * By default the result is verified: the produced TOML is re-parsed and checked + * against `updatedObject`, retrying with a coarser writer if it does not match. + * If neither attempt round-trips, the document is rolled back to its pre-patch + * state and an error is thrown, so a caught failure never leaves a half-patched + * document behind. Pass `{ validate: false }` to skip the check; see + * {@link PatchOptions.validate}. + * * @param updatedObject - The modified JS object to patch with * @param format - Optional formatting options + * @param options - Optional patch options + * @param options.validate - Verify the result round-trips to `updatedObject`. Default: true. */ - patch(updatedObject: any, format?: Partial | TomlFormat) : void { + patch(updatedObject: any, format?: Partial | TomlFormat, options?: PatchOptions) : void { const fmt = resolveTomlFormat(format, this._format); - const { tomlString, document } = patchCst( + // patchCst() mutates the nodes it is handed, so this._cst is already spent + // once the call below returns, whether or not the result is usable. Keeping + // the pre-patch source lets every later path re-derive a clean CST from it. + const sourceBefore = this._currentTomlString; + + const first = patchCst( this._cst, updatedObject, fmt ); - this._cst = document.items; - this._format = fmt; - this._currentTomlString = tomlString; + + const commit = (tomlString: string, cst: Block[]) => { + this._cst = cst; + this._format = fmt; + this._currentTomlString = tomlString; + }; + + if (options?.validate === false) { + commit(first.tomlString, first.document.items); + return; + } + + // The comparison has to read values back the way this document produces + // them, or a correct patch looks like a mismatch: integersAsBigInt is the + // document's own setting, and temporal follows the updated object exactly + // as the internal diff does. + const comparison = { + temporal: hasTemporal(updatedObject), + integersAsBigInt: this._integersAsBigInt + }; + + if (patchResultMatches(updatedObject, first.tomlString, comparison)) { + commit(first.tomlString, first.document.items); + return; + } + + const retried = patchCst( + Array.from(parseTOML(sourceBefore)), + updatedObject, + fmt, + true + ); + if (patchResultMatches(updatedObject, retried.tomlString, comparison)) { + commit(retried.tomlString, retried.document.items); + return; + } + + // Neither attempt round-trips. Both mutated CST nodes on their way through, + // so restore from the pre-patch source rather than leaving the document + // holding a tree that no longer matches its own TOML string. + this._cst = Array.from(parseTOML(sourceBefore)); + this._currentTomlString = sourceBefore; + throw new Error('Patch retry failed round-trip validation'); } /** From 2c7e2839327cf298ffe0c4dd7aefae9bc9f5f8da Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:46:17 -0400 Subject: [PATCH 21/36] refactor(patch): derive transaction change count from the change list multilineChangeCounts mirrored multilineChanges exactly: both were written in the same block with no branch between them, so the count was always multilineChanges.get(container).length. Reading it from the list drops the parallel map and the `?? []` fallbacks that only existed because the counter was the thing being iterated. Container insertion order is unchanged, since the first write for a given container landed in both maps in the same iteration, so transactionalPaths comes out in the same order. --- src/patch.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/patch.ts b/src/patch.ts index 10b43e9e..001395f8 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -1074,7 +1074,6 @@ function applyChanges(original: Document, updated: Document, changes: Change[], // stale end position even when each write is flushed individually. Treat only those // multi-change, comment-free containers transactionally; a single edit still uses the // formatting-preserving fine-grained path and comments retain their ownership handling. - const multilineChangeCounts = new Map(); const multilineChangePaths = new Map(); const multilineChanges = new Map(); const multilineHasUnresolvedChange = new Set(); @@ -1142,7 +1141,6 @@ function applyChanges(original: Document, updated: Document, changes: Change[], for (const container of targetIsMultiline ? [...ancestors, target] : ancestors) { if (containsComment(container) || multilineStringCount(container) < 1) continue; - multilineChangeCounts.set(container, (multilineChangeCounts.get(container) ?? 0) + 1); const containerChanges = multilineChanges.get(container); if (containerChanges) containerChanges.push(change); else multilineChanges.set(container, [change]); @@ -1152,17 +1150,17 @@ function applyChanges(original: Document, updated: Document, changes: Change[], } } } - const transactionalPaths = [...multilineChangeCounts] - .filter(([container, count]) => { + const transactionalPaths = [...multilineChanges] + .filter(([container, containerChanges]) => { if (!multilineChangePaths.has(container)) return false; - const containerChanges = multilineChanges.get(container) ?? []; + const count = containerChanges.length; const hasMoveOrAdd = containerChanges.some(change => isMove(change) || isAdd(change)); const strings = multilineStringCount(container); const hasEnoughStrings = strings >= 2 || (strings >= 1 && (multilineHasUnresolvedChange.has(container) || count === 1)); return hasEnoughStrings && (count >= 3 || (count === 2 && (!hasMoveOrAdd || multilineHasUnresolvedChange.has(container))) || - (count === 1 && (multilineChanges.get(container) ?? []).some(isRemove))); + (count === 1 && containerChanges.some(isRemove))); }) .map(([container]) => multilineChangePaths.get(container)!) .filter((path, index, paths) => !paths.some((other, otherIndex) => @@ -1170,12 +1168,12 @@ function applyChanges(original: Document, updated: Document, changes: Change[], arraysEqual(path.slice(0, other.length), other) )); if (useMultilineTransactions && typeof process !== 'undefined' && process.env.TOML_PATCH_DEBUG_TRANSACTION) { - console.warn([...multilineChangeCounts].map(([container, count]) => ({ + console.warn([...multilineChanges].map(([container, containerChanges]) => ({ type: container.type, path: multilineChangePaths.get(container), - count, + count: containerChanges.length, strings: multilineStringCount(container), - changes: (multilineChanges.get(container) ?? []).map(change => change.type) + changes: containerChanges.map(change => change.type) }))); } if (transactionalPaths.length > 0) { From 81b9e3a28609b21104932928591fab4c6ac27c15 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:19:29 -0400 Subject: [PATCH 22/36] docs(patch): restore comments dropped from coalesceStructuralReplacements bc3e844 ("fix: harden multiline patch planning") also deleted two inline rationales and two blank lines from coalesceStructuralReplacements, a function it did not otherwise touch. The comments explained why each guard exists, which is not obvious from the conditions alone: parentPath.length === 0 -> never coalesce at the document root tryFindByPath(original, path) -> parent still exists literally The function is now byte-identical to its pre-bc3e844 state. --- src/patch.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/patch.ts b/src/patch.ts index 001395f8..3b9787a1 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -484,6 +484,7 @@ function reorder(changes: Change[]): Change[] { function coalesceStructuralReplacements(original: Document, updated_js: any, changes: Change[]): Change[] { const consumed = new Set(); const coalescedEdits: Change[] = []; + // Strategy 1: Remove(prefix.oldKey) + Add(prefix.newKey) sharing an // implicit parent with no literal node of its own. const groups = new Map(); @@ -491,7 +492,7 @@ function coalesceStructuralReplacements(original: Document, updated_js: any, cha if (!isRemove(change) && !isAdd(change)) continue; const parentPath = change.path.slice(0, -1); - if (parentPath.length === 0) continue; + if (parentPath.length === 0) continue; // never coalesce at the document root itself const key = JSON.stringify(parentPath); let group = groups.get(key); @@ -503,7 +504,7 @@ function coalesceStructuralReplacements(original: Document, updated_js: any, cha } for (const group of groups.values()) { if (group.removes.length === 0 || group.adds.length === 0) continue; - if (tryFindByPath(original, group.path)) continue; + if (tryFindByPath(original, group.path)) continue; // parent still exists literally group.removes.forEach(change => consumed.add(change)); group.adds.forEach(change => consumed.add(change)); @@ -563,6 +564,7 @@ function coalesceStructuralReplacements(original: Document, updated_js: any, cha const firstEntry = tryFindByPath(original, prefix.concat(0)); if (!firstEntry || !isTableArray(firstEntry)) continue; + arrayPrefixes.set(JSON.stringify(prefix), prefix); } for (const prefix of arrayPrefixes.values()) { From 3a43e22f875d59b5b34f59f3870cc29810a04d22 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:20:53 -0400 Subject: [PATCH 23/36] docs: repair unresolvable commit references in the sweep notes The round-trip fixes note attributed every fix to `aa60a53` and named `3a593d0` as the distillation control. Neither SHA is reachable from this branch any more: both were rebased away, so a reader after merge cannot resolve either one. Commits are now named by subject, which survives a rebase, with a note saying why. The one SHA still quoted, `82d6bca`, is verified reachable and identified by its relationship to the fix commit rather than standing alone. Also drops the closing claim that the suite still contains legacy exact-format failures. That described an intermediate state; the suite is green. --- .../fuzz-sweep-0-3000000-roundtrip-fixes.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md b/docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md index aca20faf..53f1b102 100644 --- a/docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md +++ b/docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md @@ -7,24 +7,29 @@ all report `FAILURES: 0`. The distilled regressions live in ## Fixes +Commits are named by subject rather than SHA: this branch has been rebased, and +the SHAs originally recorded here no longer resolve. + - **175924**: deleting a multiline string member inside a nested array left the enclosing array's closing bracket and comma offsets stale. Fixed by the - multiline-container transaction in `aa60a53`. + multiline-container transaction in `fix: harden multiline patch planning`. - **377453, 771152, 863664, 1112646, 1286183, 1383962, 1693919, 1896226, 2185943, 2497422, 2531104, 2667551, 2824408, 2858114**: edits or removals involving multiline inline arrays/tables applied overlapping item-level offsets. The planner now coalesces affected multiline container changes and keeps physical multiline values out of unsafe move chains. Fixed in - `aa60a53`. + `fix: harden multiline patch planning`. - **2591153**: array removals emitted in original-array coordinates were moved past same-array additions, so a later removal addressed a nonexistent index. `Remove` changes now carry internal non-enumerable source-coordinate metadata - and `reorder()` places them before same-array additions. Fixed in `aa60a53`. + and `reorder()` places them before same-array additions. Fixed in + `fix: harden multiline patch planning`. ## Distillation -Each failure was reduced against the pre-fix control commit `3a593d0`, with the -same deterministic mutations and formatting options as the authoritative fuzz +Each failure was reduced against the pre-fix control commit `82d6bca` (the +commit before `fix: harden multiline patch planning`), with the same +deterministic mutations and formatting options as the authoritative fuzz harness. Fifteen seeds reduced to one contributing mutation. Seed `2591153` required all three mutations, and was manually reduced to the affected nested array plus the deletion that shifts the surrounding document. @@ -42,6 +47,4 @@ reduction cannot silently change the bug. `src/__tests__/patch.test.ts`. - `pnpm run build`: passing. -The complete Vitest suite still contains unrelated legacy exact-format failures -in the pre-existing multiline fuzz regressions. They are outside this sweep's -reported seeds and are not changed by the final validated fuzz scope. +The complete Vitest suite passes. From 5163decd1c9bd60c3869045f99a7162128616958 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:26:11 -0400 Subject: [PATCH 24/36] fix(toml-format): normalize newLine wherever a format is resolved normalizePatchFormat() lived in patch.ts, so only patch() validated newLine. Every other entry point took the raw value straight to the writer: stringify(value, { newLine: 'LF' }) -> "a = 9LFb = 2LF" stringify(value, { newLine: '\r' }) -> invalid TOML, silently new TomlFormat('LF') -> literal "LF" between lines The normalization moves into validateFormatObject(), which every plain format object already passes through, and into the TomlFormat constructor, which is the only place that can normalize what an instance carries because resolveTomlFormat() returns instances untouched. patch() no longer needs its own copy. Aliases (LF, CRLF, unix, DOS and the two-character \n / \r\n spellings) are accepted case-insensitively; anything that denotes neither LF nor CRLF throws. The alias table is a Map so a key like `constructor` cannot resolve through Object.prototype. Ordering is deliberate: normalization runs after the schema type checks, so a non-string newLine is still reported as a type error rather than an unsupported value. A null or absent newLine still falls back to the default, as `?? DEFAULT_NEWLINE` did. Two existing tests pinned the old behaviour and are updated: - TomlFormat('') asserted that an empty newLine was accepted, which runs every line together into TOML that cannot parse. - The js stringify test asserted that newLine: "\n" was emitted verbatim, checking the output ended with a literal backslash-n. It now resolves to a real LF. --- CHANGELOG.md | 5 ++ src/__tests__/__js__/stringify.test.mjs | 4 +- src/__tests__/toml-format.test.ts | 97 ++++++++++++++++++++++--- src/patch.ts | 17 +---- src/toml-format.ts | 42 ++++++++++- 5 files changed, 138 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a75383c..861a5c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Patching: Resolve additional patching roundtrip errors from fuzzing harness (3M seeds) ([#293]) +- Stringify: `newLine` is now normalized and validated wherever a format is resolved, not + only in `patch()`. `stringify(value, { newLine: 'LF' })` wrote the literal text `LF` + between lines, and `{ newLine: '\r' }` or `{ newLine: '' }` silently produced TOML + that cannot parse. Aliases (`LF`, `CRLF`, `unix`, `DOS`, `\n`, `\r\n`) are + accepted and anything else throws ([#293]) - Patching: `TomlDocument.patch()` now verifies its result the way `patch()` does, instead of silently keeping TOML that does not round-trip. A document that cannot be patched is rolled back to its pre-patch state rather than left partially modified ([#293]) diff --git a/src/__tests__/__js__/stringify.test.mjs b/src/__tests__/__js__/stringify.test.mjs index 99785ec9..da11a4ff 100644 --- a/src/__tests__/__js__/stringify.test.mjs +++ b/src/__tests__/__js__/stringify.test.mjs @@ -230,6 +230,8 @@ describe('stringify() Function JavaScript Integration', () => { const format = { bracketSpacing: true, trailingComma: true, + // The two-character escape spelling now resolves to a real LF; it used + // to be written out verbatim between every pair of lines. newLine: "\\n", trailingNewline: 2 }; @@ -238,7 +240,7 @@ describe('stringify() Function JavaScript Integration', () => { expect(result).toContain('servers = [ "web", "api", "db", ]'); expect(result).toContain('auth = [ "basic", "oauth", ]'); - expect(result.endsWith('\\n\\n')).toBe(true); + expect(result.endsWith('\n\n')).toBe(true); }); it('should work with format objects created from prototypes', () => { diff --git a/src/__tests__/toml-format.test.ts b/src/__tests__/toml-format.test.ts index 1eec0d19..e3d9e4c0 100644 --- a/src/__tests__/toml-format.test.ts +++ b/src/__tests__/toml-format.test.ts @@ -1,5 +1,5 @@ import { TomlFormat, detectNewline, countTrailingNewlines, validateFormatObject, resolveTomlFormat } from '../toml-format'; -import { patch } from '../index'; +import { patch, stringify } from '../index'; import parseTOML from '../parse-toml'; import toTOML from '../to-toml'; import { stripLeadingBom } from '../decode-utf8'; @@ -128,13 +128,10 @@ describe('TomlFormat comprehensive tests', () => { expect(format.bracketSpacing).toBe(true); // Default value }); - test('should handle empty string as newLine', () => { - const format = new TomlFormat('', 1); - - expect(format.newLine).toBe(''); - expect(format.trailingNewline).toBe(1); - expect(format.trailingComma).toBe(false); // Default value - expect(format.bracketSpacing).toBe(true); // Default value + // An empty newLine runs every line together (`a = 1b = 2`), so the + // constructor rejects it rather than producing TOML that cannot parse. + test('should reject empty string as newLine', () => { + expect(() => new TomlFormat('', 1)).toThrow('Invalid newLine value: expected LF or CRLF'); }); test('should handle zero as trailingNewline', () => { @@ -723,4 +720,86 @@ describe('updateOrder option wiring (docs/PLAN-Update-Order.md)', () => { expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); }); -}); \ No newline at end of file +}); + +// Before this moved into resolveTomlFormat, only patch() normalized newLine. +// stringify() took the raw value, so `{ newLine: 'LF' }` wrote the literal text +// "LF" between lines and `{ newLine: '\r' }` produced TOML that cannot parse, +// both silently. These cover every entry point that resolves a format. +describe('newLine normalization', () => { + const config = { title: 'example', port: 8080 }; + + describe.each([ + ['LF', '\n'], + ['CRLF', '\r\n'], + ['lf', '\n'], + ['CrLf', '\r\n'], + ['unix', '\n'], + ['DOS', '\r\n'], + ['\n', '\n'], + ['\r\n', '\r\n'], + ['\n', '\n'], + ['\r\n', '\r\n'] + ])('accepts %j as %j', (newLine, expected) => { + it('via stringify', () => { + expect(stringify(config, { newLine })).toBe(`title = "example"${expected}port = 8080${expected}`); + }); + + it('via patch', () => { + const original = 'title = "example"\nport = 8080\n'; + expect(patch(original, { title: 'example', port: 9090 }, { newLine })) + .toBe(`title = "example"${expected}port = 9090${expected}`); + }); + + it('via the TomlFormat constructor', () => { + expect(new TomlFormat(newLine).newLine).toBe(expected); + }); + + it('via validateFormatObject', () => { + expect(validateFormatObject({ newLine }).newLine).toBe(expected); + }); + }); + + // '\r' alone is a legal string but not a legal TOML line ending, and '' runs + // every line together. Both used to pass straight through to the writer. + describe.each(['\r', '\r', '', 'invalid', 'CR', '\n\n'])('rejects %j', (newLine) => { + const message = 'Invalid newLine value: expected LF or CRLF'; + + it('via stringify', () => { + expect(() => stringify(config, { newLine })).toThrow(message); + }); + + it('via patch', () => { + expect(() => patch('title = "example"\n', { title: 'other' }, { newLine })).toThrow(message); + }); + + it('via the TomlFormat constructor', () => { + expect(() => new TomlFormat(newLine)).toThrow(message); + }); + }); + + it('reports a non-string newLine as a type error, not a value error', () => { + expect(() => stringify({ title: 'example' }, { newLine: 42 as any })) + .toThrow('Invalid types for format properties: newLine (expected string, got number)'); + }); + + it('treats a missing newLine as absent rather than invalid', () => { + expect(new TomlFormat().newLine).toBe('\n'); + expect(new TomlFormat(undefined).newLine).toBe('\n'); + expect(new TomlFormat(null as any).newLine).toBe('\n'); + expect(validateFormatObject({}).newLine).toBeUndefined(); + }); + + // A TomlFormat instance is returned from resolveTomlFormat untouched, so the + // constructor is the only place that can normalize what it carries. + it('normalizes through a TomlFormat instance passed to stringify', () => { + expect(stringify(config, new TomlFormat('CRLF'))) + .toBe('title = "example"\r\nport = 8080\r\n'); + }); + + it('does not resolve aliases through Object.prototype', () => { + for (const newLine of ['constructor', 'toString', 'valueOf', '__proto__']) { + expect(() => new TomlFormat(newLine)).toThrow('Invalid newLine value: expected LF or CRLF'); + } + }); +}); diff --git a/src/patch.ts b/src/patch.ts index 3b9787a1..1c58c9bf 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -86,7 +86,7 @@ export default function patch(existing: string, updated: any, format?: Partial | TomlFormat | undefined): Partial | TomlFormat | undefined { - if (!format || format instanceof TomlFormat || typeof format !== 'object') return format; - if (typeof format.newLine !== 'string') return format; - if (format.newLine === '\\n') return { ...format, newLine: '\n' }; - if (format.newLine === '\\r\\n') return { ...format, newLine: '\r\n' }; - const name = format.newLine.toUpperCase(); - if (name === 'LF' || name === 'UNIX') return { ...format, newLine: '\n' }; - if (name === 'CRLF' || name === 'DOS') return { ...format, newLine: '\r\n' }; - if (format.newLine !== '\n' && format.newLine !== '\r\n') { - throw new TypeError('Invalid newLine value: expected LF or CRLF'); - } - - return format; -} - /** * Length of the existing dotted key's prefix that the edit path matches. * Only the path's RELATIVE tail (past the key's container) is eligible: diff --git a/src/toml-format.ts b/src/toml-format.ts index 9d362db0..6cdb39dc 100644 --- a/src/toml-format.ts +++ b/src/toml-format.ts @@ -243,6 +243,40 @@ export function detectTabsForIndentation(str: string): boolean { * @param format - The format object to validate * @returns The validated format object with only supported properties and correct types */ +/** + * Spellings accepted for `newLine`, mapped to the line ending they denote. + * A Map rather than an object literal so that a key like `constructor` cannot + * resolve through Object.prototype. + */ +const NEW_LINE_ALIASES = new Map([ + ['\n', '\n'], + ['\r\n', '\r\n'], + ['\\n', '\n'], + ['\\r\\n', '\r\n'], + ['LF', '\n'], + ['CRLF', '\r\n'], + ['UNIX', '\n'], + ['DOS', '\r\n'] +]); + +/** + * Resolves an accepted `newLine` spelling to the literal it denotes. + * + * TOML permits only LF and CRLF, so anything else would silently produce a + * document that does not parse (a bare '\\r', or the literal text 'LF' written + * between every pair of lines). Rejecting it here means every entry point that + * resolves a format gets the check, rather than just `patch()`. + * + * @throws TypeError when the value denotes neither LF nor CRLF + */ +export function normalizeNewLine(value: string): string { + const resolved = NEW_LINE_ALIASES.get(value) ?? NEW_LINE_ALIASES.get(value.toUpperCase()); + if (resolved === undefined) { + throw new TypeError('Invalid newLine value: expected LF or CRLF'); + } + return resolved; +} + export function validateFormatObject(format: any): any { if (!format || typeof format !== 'object') { return {}; @@ -292,6 +326,12 @@ export function validateFormatObject(format: any): any { throw new TypeError(`Invalid types for format properties: ${invalid.join(', ')}`); } + // Runs after the type checks so a non-string newLine is still reported as a + // type error rather than an unsupported value. + if ('newLine' in validatedFormat) { + validatedFormat.newLine = normalizeNewLine(validatedFormat.newLine); + } + return validatedFormat; } @@ -462,7 +502,7 @@ export class TomlFormat { updateOrder?: boolean ) { // Use provided values or fall back to defaults - this.newLine = newLine ?? DEFAULT_NEWLINE; + this.newLine = newLine == null ? DEFAULT_NEWLINE : normalizeNewLine(newLine); this.trailingNewline = trailingNewline ?? DEFAULT_TRAILING_NEWLINE; this.trailingComma = trailingComma ?? DEFAULT_TRAILING_COMMA; this.bracketSpacing = bracketSpacing ?? DEFAULT_BRACKET_SPACING; From d40023b9c2c403ecbf97259dca3f306739d1f1a9 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:28:15 -0400 Subject: [PATCH 25/36] test: use readable keys in the validation regressions The three tests added for patch validation were copied verbatim from fuzz seed 771152, so they carried the seed's random identifiers (b_3cmsbhh, al1erl4-9, us.xl."/", k51, QhvX_vl aKj9dsQ0r7). That hid what the cases actually exercise. What reproduces the bug is the document structure: a multiline inline array holding multiline strings, with several writes landing in one container. The key spellings are irrelevant, which I confirmed by checking that the renamed fixture still needs the transactional retry and still produces unparseable TOML when validation is skipped. Renamed to build.artifacts / meta.notes / width / label, and the numeric noise reduced to values that read as deliberate. The assertions stay exact. --- src/__tests__/patch.test.ts | 34 +++++++++++++++-------------- src/__tests__/toml-document.test.ts | 25 +++++++++++---------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/__tests__/patch.test.ts b/src/__tests__/patch.test.ts index 8c0923d5..ff59399b 100644 --- a/src/__tests__/patch.test.ts +++ b/src/__tests__/patch.test.ts @@ -54,35 +54,37 @@ test('validate: false produces the same output on a patch that needs no repair', expect(patch(original, skipped, undefined, { validate: false })).toBe(expected); }); -// Distilled from fuzz seed 771152, which only round-trips because validation -// catches the first attempt and retries transactionally. It is the clearest -// demonstration of what opting out costs: the fine-grained write leaves the -// enclosing array's separators stale, and the result does not even parse. +// Structurally distilled from fuzz seed 771152, with the seed's random keys and +// values renamed: what reproduces the bug is a multiline inline array holding +// multiline strings, not the key spellings. This case only round-trips because +// validation catches the first attempt and retries transactionally, so it is the +// clearest demonstration of what opting out costs. test('validate: false returns the unrepaired result instead of retrying', () => { const original = dedent` - b_3cmsbhh.al1erl4-9 = [236463, ''' - -xl6''', 0o34, 90356.53508, true, [{ us.xl."/" = """ - fr;,Iq*!9""" }, 1986-03-02]] + build.artifacts = [1, ''' + alpha''', 0o34, 2.5, true, [{ meta.notes.summary = """ + detail""" }, 1986-03-02]] ` + '\n'; const mutate = (obj: any) => { - obj.b_3cmsbhh['al1erl4-9'][5][0].us.xl = { k51: -3337.0673237368464, k49: 'QhvX_vl aKj9dsQ0r7' }; + obj.build.artifacts[5][0].meta.notes = { width: 3, label: 'measured value' }; return obj; }; - // Validated: the retry rewrites the whole multiline container, which loses - // the multiline literal string, the octal base and the dotted keys, but the - // result is valid TOML that matches the requested object. + // Validated: the retry rewrites the whole multiline container, which loses the + // multiline literal string, the octal base and the dotted keys, but the result + // is valid TOML that matches the requested object. expect(patch(original, mutate(parse(original)))).toBe( - 'b_3cmsbhh.al1erl4-9 = [236463, "-xl6", 28, 90356.53508, true, ' + - '[{us = {xl = {k51 = -3337.0673237368464, k49 = "QhvX_vl aKj9dsQ0r7"}}}, 1986-03-02]]\n' + 'build.artifacts = [1, "alpha", 28, 2.5, true, ' + + '[{meta = {notes = {width = 3, label = "measured value"}}}, 1986-03-02]]\n' ); - // Opted out: the original formatting survives, but the output is malformed. + // Opted out: the original formatting survives, but the array loses its closing + // bracket and the output no longer parses. const unrepaired = patch(original, mutate(parse(original)), undefined, { validate: false }); expect(unrepaired).toBe(dedent` - b_3cmsbhh.al1erl4-9 = [236463, ''' - -xl6''', 0o34, 90356.53508,]true, [{us.xl.k51 = -3337.0673237368464, us.xl.k49 = "QhvX_vl aKj9dsQ0r7"}, 1986-03-02] + build.artifacts = [1, ''' + alpha''', 0o34, 2.5, true, [{meta.notes.width = 3, meta.notes.label = "measured value"}, 1986-03-02] ` + '\n'); expect(() => parse(unrepaired)).toThrow(); }); diff --git a/src/__tests__/toml-document.test.ts b/src/__tests__/toml-document.test.ts index ac8a2443..920925db 100644 --- a/src/__tests__/toml-document.test.ts +++ b/src/__tests__/toml-document.test.ts @@ -1911,17 +1911,18 @@ describe('TomlDocument', () => { }); describe('patch result validation', () => { - // Distilled from fuzz seed 771152. Before validation reached TomlDocument - // this produced TOML that did not parse, and the document silently kept it. + // Structurally distilled from fuzz seed 771152, with the seed's random keys + // and values renamed. Before validation reached TomlDocument this produced + // TOML that does not parse, and the document silently kept it. const trickyToml = dedent` - b_3cmsbhh.al1erl4-9 = [236463, ''' - -xl6''', 0o34, 90356.53508, true, [{ us.xl."/" = """ - fr;,Iq*!9""" }, 1986-03-02]] + build.artifacts = [1, ''' + alpha''', 0o34, 2.5, true, [{ meta.notes.summary = """ + detail""" }, 1986-03-02]] ` + '\n'; const mutate = (doc: TomlDocument) => { const obj = doc.toJsObject; - obj.b_3cmsbhh['al1erl4-9'][5][0].us.xl = { k51: -3337.0673237368464, k49: 'QhvX_vl aKj9dsQ0r7' }; + obj.build.artifacts[5][0].meta.notes = { width: 3, label: 'measured value' }; return obj; }; @@ -1930,10 +1931,12 @@ describe('TomlDocument', () => { doc.patch(mutate(doc)); // The retry rewrites the whole multiline container, losing the multiline - // literal string and the octal base, but the result is valid TOML. + // literal string and the octal base, but the result is valid TOML. The + // date widening to an offset date-time is a separate pre-existing issue + // in toJsObject, pinned here so a fix to it shows up as a diff. expect(doc.toTomlString).toBe( - 'b_3cmsbhh.al1erl4-9 = [236463, "-xl6", 28, 90356.53508, true, ' + - '[{us = {xl = {k51 = -3337.0673237368464, k49 = "QhvX_vl aKj9dsQ0r7"}}}, 1986-03-02T00:00:00.000Z]]\n' + 'build.artifacts = [1, "alpha", 28, 2.5, true, ' + + '[{meta = {notes = {width = 3, label = "measured value"}}}, 1986-03-02T00:00:00.000Z]]\n' ); expect(() => new TomlDocument(doc.toTomlString).toJsObject).not.toThrow(); }); @@ -1943,8 +1946,8 @@ describe('TomlDocument', () => { doc.patch(mutate(doc), undefined, { validate: false }); expect(doc.toTomlString).toBe(dedent` - b_3cmsbhh.al1erl4-9 = [236463, ''' - -xl6''', 0o34, 90356.53508, true, [{us.xl.k51 = -3337.0673237368464, us.xl.k49 = "QhvX_vl aKj9dsQ0r7"}, 1986-03-02T00:00:00.000Z] + build.artifacts = [1, ''' + alpha''', 0o34, 2.5, true, [{meta.notes.width = 3, meta.notes.label = "measured value"}, 1986-03-02T00:00:00.000Z] ` + '\n'); expect(() => new TomlDocument(doc.toTomlString).toJsObject).toThrow(); }); From 14ca7dae633e6b37e996ffecf3e0eeca2a18ed01 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:32:20 -0400 Subject: [PATCH 26/36] test(fuzz): assert exact output for the distilled regressions Every distilled regression asserted only expect(parse(result)).toEqual(obj), which ignores formatting by construction. Most of these cases round-trip only because patch() validates its first attempt and retries transactionally, and that retry rewrites whole multiline inline containers: multiline strings collapse to basic strings, non-decimal integer bases are normalized, dotted keys expand into nested inline tables. None of that was visible to the suite, so the formatting cost of the retry could grow unnoticed. All 16 now assert the exact TOML as well. That includes the 2591153 case, which carried a commented-out placeholder and a TODO asking for an exact assertion once the intended formatting was specified; recording what is actually produced today is more useful than leaving it unasserted. Inline snapshots rather than hand-written strings: these fixtures embed multiline basic and literal strings, and quoting them by hand invites an assertion that is subtly wrong. The values are generated, so they record current output including the known losses above. A change that recovers any original formatting now surfaces as a reviewable diff instead of passing silently. Regenerate with `vitest -u` once a change is confirmed to be an improvement. --- src/__tests__/patch.fuzz.test.ts | 228 ++++++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 5 deletions(-) diff --git a/src/__tests__/patch.fuzz.test.ts b/src/__tests__/patch.fuzz.test.ts index e11a4d8b..203d428c 100644 --- a/src/__tests__/patch.fuzz.test.ts +++ b/src/__tests__/patch.fuzz.test.ts @@ -3490,14 +3490,125 @@ test('regression for fuzz seed 2591153', () => { obj["AKy:}nV@"].p8["(J:\\" = 258084 + vhashp_7d-.\\"zD_>~7Lf3\\" = -464765 + \\"q=y\\".ojj = \\"\\"\\" + iB\\"\\"\\" + x7co_.u.AtJ = 754_620 + + [q2c.\\"o!AxxvqZj\\"] + g9p75i5s_ = ''' + !esM3g7w + }I2t-:Y-|#MD + E] + ,^vU + oEA + w + 0 + ! + 7nGBp + + ''' + cmzsfzz.f3utn728zu = true + \\"^]jfx9Hz_\\" = 2053-10-21T07:50:22Z + f6ziybf.egyieof = false + \\"B3:c%G9\\".sf_90m7q.ncide1 = 1981-12-01T06:47:08Z + t = 986e-61 + ez.\\"yTxL(kP[j0\\".\\"SPCIi(YbYe\\" = 53780.13362 + cpqdsgprv.\\"%-+WE\\".x9vi = 2091-11-01T14:04:57 + s-yvw-4f.jes8rouk = 82_668.64849 + + [gspp.e.vg_08p5dgs] + eegf54qq.b3ff_g = true + bttb8h.vk5c.\\"\\" = [] + dx2feuc.3 = \\"\\"\\" + -z + =t9EF) + 6+\\"\\"\\" + # ~xsBrSdMAd;mr\`JPYi + yh3l3-g2x.gmw3_.ppq_ep3k3 = \\"\\"\\" + = VD95 + v?{gW + j + 1,]OS5\\"\\"\\" + f6qrvqct.ny0fj-3 = 367707 + fx = nan + # oO!S;$e^N>g{%,+eJzcq(}kE,@J=je+M + [[qqqk.\\"C<4M\`%W2L\\"]] + + [[y9wecl5]] + rw3l2q8mk.d-ps = ']zgL)[hSjD.R+' + # i(M^R&yAn3x3pj]ayUh -^ *xi\`1,cQ(~ + qe = 995783 + vag.\\"[Q&%][ 6ap\\" = 559418 + aam48 = true + + [\\"AKy:}nV@\\"] + kl7u = true + # Vu_I\`CdTJh>9oacHXu\`o>|Nt\`=pHltLN + \\"hG b+T\\".oyyuk3 = 937467 + r46f8rhrh.rprqr8g = -566314 + p8 = { + uyvk4h.\\"m}5?\\" = \\"Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w { const src = dedent` [["2S38X#"]] @@ -3515,6 +3626,16 @@ test('distilled regression for fuzz seed 1112646', () => { obj["2S38X#"][0]["dj/{gXY"].sp_ya888x = [new Date(Date.UTC(2018, 5, 14)), -2069, 784]; const result = patch(src, obj, undefined); + expect(result).toMatchInlineSnapshot(` + "[[\\"2S38X#\\"]] + \\"dj/{gXY\\".sp_ya888x = [ 2018-06-14T00:00:00.000Z, -2069, 784, ] + ln_n = [ + ''' + ,''', + [{ \\"t&R/jR\\" = ''' + TPV!''' }, false, 287_173, \\"NAzv Be{}8z]C2QzJpyz}_]idd\\", 1984-04-16T12:37:13Z], + ]" + `); expect(parse(result)).toEqual(obj); }); @@ -3546,6 +3667,13 @@ test('distilled regression for fuzz seed 1286183', () => { truncateZeroTimeInDates: true, useTabsForIndentation: false }); + expect(result).toMatchInlineSnapshot(` + "i5qpju2q = \\"\\"\\" + 8=\\"\\"\\" + [p7jqr__s] + j1.\\"d1bJi gG\\" }, i-43c9963p = { bre5k8g28 = { guv2pn = \\"l0-Y?;FYxwz\\" } }, su = { p = 36740 }, \\"Pv3!oN4\\" = { ub_k79_ = false }, dnc3w6 = \\"\\", \\"ag/MY[2\\" = { ud = 527558 }, \\"Y][2vRao-\\" = { ekiao1h = { t88fjn = 2080-12-04 } }, \\"@\\" = 31256.58522, kqk32ihb = { xcd01 = 819597 }, xf79wkh3q = 8, i = -284092, ZZ = 52, jbr = { r0acvtrwd = true } }, \\"&f\\" = { ck = { pe = 2081-06-28T00:40:23Z } }, \\"5BN)X3P5:\\" = { b6dgagg = \\"DU)$R8E>JL*\\" }, do8b6z = { f4f-0l = {} }, zhd31f5qk = { h862qtmhq = 90692.79376 }, hc = { \\"+ZlY;4|\\" = true } } } + " + `); expect(parse(result)).toEqual(obj); }); @@ -3562,6 +3690,13 @@ test('distilled regression for fuzz seed 1383962', () => { obj.kmza[0].ptvl = new Date(Date.UTC(2006, 1, 14)); const result = patch(src, obj, undefined); + expect(result).toMatchInlineSnapshot(` + "\\"1vn[1flkZ\\".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, \\"\\"\\" + s~\\"\\"\\", 76968.6746, 'Qr+qJ|jGKM n=c1oEx^\`p2;L0H_Nyg*3K$:A{?jJg:I', 2069-08-08T04:34:33, \\"A3!)hHn6}I\\"] }, 574310, 28569, -63757100000000418476, \\"\\"\\" + 8=Qed7]-^jftM\\"\\"\\", -50759.88688, -inf, true] + [[kmza]] + ptvl = 2006-02-14T00:00:00.000Z" + `); expect(parse(result)).toEqual(obj); }); @@ -3590,6 +3725,11 @@ test('distilled regression for fuzz seed 1693919', () => { truncateZeroTimeInDates: false, useTabsForIndentation: true }); + expect(result).toMatchInlineSnapshot(` + "[g3s_y5jv_d.mfdfdc] + \\"\\".c4esqy.t79d__y = { bnJF1EQ = { k27 = { k57 = false, k17 = false, k67 = [ 2036-09-23T00:00:00.000Z, true, -1287.6224634237587, ], }, }, \\"#?VRmg\\" = { \\" { truncateZeroTimeInDates: false, useTabsForIndentation: false }); + expect(result).toMatchInlineSnapshot(` + "[[l.\\"=\\".p]] + m = { + } + qsof = [ \\"--2#k+z+M,Et1[EnDbG0_Ykh7^ \\", 1988-12-04T04:19:06, [ 395, {}, -907693 ], 65260.050825 ] + " + `); expect(parse(result)).toEqual(obj); }); @@ -3639,6 +3786,15 @@ test('distilled regression for fuzz seed 1896226', () => { delete obj.syn2x.b.o6y2i.yutxpp.uzz["/S|"][2].c19.hv; const result = patch(src, obj, undefined); + expect(result).toMatchInlineSnapshot(` + "y0pgo = ''' + zw$#+{rD] Q:|WI_5xV''' + [syn2x.b.o6y2i] + xx1dnmb-lu.i-b5qipqve = { kh5rr3tnn.tfv.acbd8jed_ = true, am7fwt.jri8xuo = \\"drC+m!9n vxA(?w8N+)9]jF1 /\\", eu0e.ygeqkl.rv5zela02z = -792779, c.teb.pe = 0xd45, fk-no = false, wvddr.gmv.d8iwt = \\"]49F#3@Gi^y{@4y(eYalh%@\\", piif.qwkfgiyp = false, \\"kNg6#mA\\".mmu1i.u1aubp = true, hk3wa8v.\\">uk|E\`!8a\\" = 2055-10-17T23:43:59.550334Z, \\"|EnD &e~>\`\\".npriy.OU = inf, m3y3lj.viyt2w.c = \\"U/qmv0wx=W/ib/M^t { delete obj["megac68k-"][0].kw[5].cnoff.l7["UMN$eUS9="]; const result = patch(src, obj, undefined); + expect(result).toMatchInlineSnapshot(` + "ei.Nj = \\"\\"\\" + >!~Wg-y)~\\"\\"\\" + [[megac68k-]] + z = [ + ] + kw = [ 46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275068, { cnoff = { l7 = {} }, aujqbb = \\"P/*\\" }, 22787.07288, 79051.79185, true ] + \\"1x(4u.WK2x\\".v4r4evh1.c3pw90 = \\"\\"\\" + m@7\\"\\"\\"" + `); expect(parse(result)).toEqual(obj); }); @@ -3678,6 +3844,7 @@ test('distilled regression for fuzz seed 2497422', () => { delete obj["x^@zw"][2].ik4o2d6z4j; const result = patch(src, obj, undefined); + expect(result).toMatchInlineSnapshot(`"\\"x^@zw\\" = [\\" \\", -78860.81892, {}, \\"\`kO^E(R8324tzB;#iBmn9aX!9KUL*N0eWvf/J:R\\", 36709.83314, [true, \\"ZrS|K#MaiwaYr4UOIP<$=8clcC*+Px{^#v@>G|(@>;a>w&X&Jb\\", 9.45e+87, -30134.83738, {}, \\"HLzq$8|?\\", \\" { useTabsForIndentation: false, minimumDecimals: 1 }); + expect(result).toMatchInlineSnapshot(` + "mmx9rwvk7k.lqmp_-1cgl = ''' + 5>ZvY=\`r~ha''' + [wcyw] + tf = {\\"O\`aB\\" = [true, {k96 = 1640.0, k18 = true,}, 745102.0, 96226.2768, 8718.0, false, 802344.0, \\"#vH#E:o>J[jB||M\\",],} + l = {\\"~u)HFjuZ\\" = 00:16:17,} + " + `); expect(parse(result)).toEqual(obj); }); @@ -3734,6 +3909,13 @@ test('distilled regression for fuzz seed 2667551', () => { truncateZeroTimeInDates: true, useTabsForIndentation: false }); + expect(result).toMatchInlineSnapshot(` + "[[xwed7dv]] + w9ri7t6az = { + } + ax.dkibhxt = [\\"\\", \\">X+YrY\`N\\", \\"$e\\"] + " + `); expect(parse(result)).toEqual(obj); }); @@ -3766,6 +3948,16 @@ test('distilled regression for fuzz seed 2824408', () => { useTabsForIndentation: false, minimumDecimals: 1 }); + expect(result).toMatchInlineSnapshot(` + "[ivk.nuan749c7] + pJ.kd7jct7_o = {xwjbnijuw = {k19 = -188.0,}, \\"\`pD\\" = {r = \\"U9SE]2S+ { obj.sw2.rfc4998bx.splice(5, 1); const result = patch(src, obj, undefined); + expect(result).toMatchInlineSnapshot(` + "sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, \\"qEZv\\", \\"p_\`:SMIvsp(<$\\", \\"m|2Z@v-c},BQ~+EDUZQr(lxlbX^iy0Mi>{G%c\\", \\"\\"\\" + ;(d7x\\"\\"\\", { + e_8y4s = 59108.35246, + }] + xe_p.w5yln8z5 = ''' + xo5o'''" + `); expect(parse(result)).toEqual(obj); }); @@ -3810,6 +4010,12 @@ test('distilled regression for fuzz seed 377453', () => { useTabsForIndentation: false, minimumDecimals: 1 }); + expect(result).toMatchInlineSnapshot(` + "[eqki-] + v1xubvr5b2 = [ false, true, \\"<6@Qd}q = \`q]B>FSx\\", -49687.079945, { k94 = -4472.0, }, \\"I3q/hH\\", 753862.0, \\"z\\", -64864.2541, ] + gmpmfyn4 = { y1np1 = 1301129.0, } + " + `); expect(parse(result)).toEqual(obj); }); @@ -3824,6 +4030,7 @@ test('distilled regression for fuzz seed 771152', () => { obj.b_3cmsbhh["al1erl4-9"][5][0].us.xl = { "k51": -3337.0673237368464, "k49": "QhvX_vl aKj9dsQ0r7" }; const result = patch(src, obj, undefined); + expect(result).toMatchInlineSnapshot(`"b_3cmsbhh.al1erl4-9 = [236463, \\"-xl6\\", 28, 90356.53508, true, [{us = {xl = {k51 = -3337.0673237368464, k49 = \\"QhvX_vl aKj9dsQ0r7\\"}}}, 1986-03-02]]"`); expect(parse(result)).toEqual(obj); }); @@ -3843,6 +4050,17 @@ test('distilled regression for fuzz seed 863664', () => { obj.d5 = "P- L9H"; const result = patch(src, obj, undefined); + expect(result).toMatchInlineSnapshot(` + "d5 = \\"P- L9H\\" + + [pm.\\"+w_y2\\".nlgwd4] + k86xv_q.\\"]7]P2g\\".xq99y = [ + { chm72ws.si3 = 0xd5c289d, x = 2075-01-15T23:10:01.402080, s9gatl14xa.f6fbq_msjy = { f.nl = 597050, k.z30i = 939925, i_6d2w.\\"g3}7A0\\" = 785203, 5C = 465053, e3tzu = 822110, \\"_hGRMk\`Qg~\\".ku = 412e-33, fvemmt = false, wrs2ylbfaf.\\"AQ#B,[LL=c\\".ep = -282456, bs3o4.knr3jqyf.h = 70302.033641, h.z3ohf3j16 = -75001.099256, nju0p.tipdjb3pfk = 54219.46102 }, \\"w@LY+a#1<\\".z.\\")e$TtimN7\\" = 482015, \\"fa}@[\`\\".\\"ae,>ImvDxv\\".49e = true, \\"~/yZqL.$\\" = \\"gze@7dH;YExh\\", by1 = '|Xd', \\"\\".nytor2grmi.\\"\\" = { h53y.uujhq.hxk5o = \\"\\"\\" + 2^t\\"\\"\\" }, axau5.\\"NQ6U?Fo{\\" = false, ubqaz = 0b111111110111000 }, + ''' + ''', + ]" + `); expect(parse(result)).toEqual(obj); }); From f1eba3c3c4794fd78abcd43c574fe4547dbc39d5 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:40:05 -0400 Subject: [PATCH 27/36] fix(toml-document): keep TOML date representations through a patch toJsObject() converts the internal TOML date classes to plain Date objects so callers get the standard Date contract. But the writer derives a value's TOML text from toISOString(), and those classes override it to return the original spelling, so a plain Date handed back in was written as a full offset date-time. Reading a document, changing one unrelated value and patching it back rewrote every date in the affected container: stamps = [ 1986-03-02, 07:32:00, 1979-05-27T07:32:00Z, "tail" ] -> [ 1986-03-02T00:00:00.000Z, 0000-01-01T07:32:00.000Z, ... ] Fixed where the loss happens rather than at the getter. Removing the conversion would have been smaller, but toJsObject() would then expose objects whose toISOString() returns the TOML spelling instead of the standard form, which is a documented part of its contract and is covered by an existing test. toISOString() cannot be corrected on the classes either, since generate.ts uses it to produce the TOML text. TomlDocument.patch() now pairs the incoming object against the pre-patch values and puts the original typed instance back wherever the caller left a date alone: still a plain Date, same instant. A date whose instant changed is left to widen, because a plain Date cannot say whether a new instant is still meant to be a local date, and the exported date classes are how the caller says so. Instances of those classes are never touched, and the caller's object is not mutated (a node is copied only when something beneath it changed). The pairing is positional, so a date that moves within an array no longer lines up with its original node and still widens. That boundary is pinned by a test rather than left to be discovered. Cost is negligible: a cheap hasPlainDate() scan gates the work, so a document with no dates skips it entirely. --- CHANGELOG.md | 7 ++ src/__tests__/toml-document.test.ts | 108 ++++++++++++++++++++++++++-- src/toml-document.ts | 81 +++++++++++++++++++++ 3 files changed, 192 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 861a5c70..cf2ceb40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Patching: `TomlDocument.patch()` now verifies its result the way `patch()` does, instead of silently keeping TOML that does not round-trip. A document that cannot be patched is rolled back to its pre-patch state rather than left partially modified ([#293]) +- Patching: `TomlDocument.patch()` no longer widens TOML dates when re-applying an object + read from `toJsObject`. That getter hands out plain `Date` objects, which the writer + rendered as full offset date-times, so one sibling edit rewrote every date in an inline + array (`1986-03-02` became `1986-03-02T00:00:00.000Z`). Dates the caller left alone keep + their original spelling; a date whose instant changed still widens, and the exported + `LocalDate` / `LocalTime` / `LocalDateTime` / `OffsetDateTime` classes state the intended + representation ([#293]) ## [3.0.3] - 2026-08-21 diff --git a/src/__tests__/toml-document.test.ts b/src/__tests__/toml-document.test.ts index 920925db..a41cc633 100644 --- a/src/__tests__/toml-document.test.ts +++ b/src/__tests__/toml-document.test.ts @@ -1932,11 +1932,11 @@ describe('TomlDocument', () => { // The retry rewrites the whole multiline container, losing the multiline // literal string and the octal base, but the result is valid TOML. The - // date widening to an offset date-time is a separate pre-existing issue - // in toJsObject, pinned here so a fix to it shows up as a diff. + // untouched local date keeps its original spelling rather than widening + // to an offset date-time. expect(doc.toTomlString).toBe( 'build.artifacts = [1, "alpha", 28, 2.5, true, ' + - '[{meta = {notes = {width = 3, label = "measured value"}}}, 1986-03-02T00:00:00.000Z]]\n' + '[{meta = {notes = {width = 3, label = "measured value"}}}, 1986-03-02]]\n' ); expect(() => new TomlDocument(doc.toTomlString).toJsObject).not.toThrow(); }); @@ -1947,7 +1947,7 @@ describe('TomlDocument', () => { expect(doc.toTomlString).toBe(dedent` build.artifacts = [1, ''' - alpha''', 0o34, 2.5, true, [{meta.notes.width = 3, meta.notes.label = "measured value"}, 1986-03-02T00:00:00.000Z] + alpha''', 0o34, 2.5, true, [{meta.notes.width = 3, meta.notes.label = "measured value"}, 1986-03-02] ` + '\n'); expect(() => new TomlDocument(doc.toTomlString).toJsObject).toThrow(); }); @@ -2027,4 +2027,104 @@ describe('TomlDocument', () => { }); }); + + // toJsObject() hands out plain Date objects, but the writer derives a value's + // TOML text from toISOString(), which the TOML date classes override to return + // the original spelling. A plain Date handed back therefore used to write as a + // full offset date-time: a single sibling edit in an inline array rewrote every + // date in it, e.g. `1986-03-02` became `1986-03-02T00:00:00.000Z`. + describe('date representation through read-modify-write', () => { + const stamps = 'stamps = [ 1986-03-02, 07:32:00, 1979-05-27T07:32:00, 1979-05-27T07:32:00Z, "tail" ]\n'; + + it('keeps every date kind when only a sibling changes', () => { + const doc = new TomlDocument(stamps); + const obj = doc.toJsObject; + obj.stamps[4] = 'changed'; + doc.patch(obj); + + expect(doc.toTomlString).toBe( + 'stamps = [ 1986-03-02, 07:32:00, 1979-05-27T07:32:00, 1979-05-27T07:32:00Z, "changed" ]\n' + ); + }); + + it('keeps a date when the value beside it forces the container to be rewritten', () => { + const original = dedent` + list = [ 1986-03-02, ''' + alpha''', "tail" ] + ` + '\n'; + + const doc = new TomlDocument(original); + const obj = doc.toJsObject; + obj.list[2] = 'changed'; + doc.patch(obj); + + expect(doc.toTomlString).toBe(dedent` + list = [ 1986-03-02, ''' + alpha''', "changed" ] + ` + '\n'); + }); + + // A caller who changes the instant may or may not still mean a local date, + // and a plain Date cannot express which. The value widens, and the exported + // date classes are how the caller says what they meant. + it('widens a date whose instant actually changed', () => { + const doc = new TomlDocument(stamps); + const obj = doc.toJsObject; + obj.stamps[0] = new Date(Date.UTC(2001, 0, 2)); + doc.patch(obj); + + expect(doc.toTomlString).toBe( + 'stamps = [ 2001-01-02T00:00:00.000Z, 07:32:00, 1979-05-27T07:32:00, 1979-05-27T07:32:00Z, "tail" ]\n' + ); + }); + + it('honours a LocalDate supplied by the caller', () => { + const doc = new TomlDocument(stamps); + const obj = doc.toJsObject; + obj.stamps[0] = new LocalDate('2001-01-02'); + doc.patch(obj); + + expect(doc.toTomlString).toBe( + 'stamps = [ 2001-01-02, 07:32:00, 1979-05-27T07:32:00, 1979-05-27T07:32:00Z, "tail" ]\n' + ); + }); + + // Known limitation: the pre-patch values are matched by position, so a date + // that moves no longer lines up with its original node and widens. Pinned so + // the boundary is visible rather than discovered. + it('widens dates that swap positions', () => { + const original = 'window = [ 1986-03-02, 1991-07-14, "tail" ]\n'; + const doc = new TomlDocument(original); + const obj = doc.toJsObject; + [obj.window[0], obj.window[1]] = [obj.window[1], obj.window[0]]; + doc.patch(obj); + + expect(doc.toTomlString).toBe( + 'window = [ 1991-07-14T00:00:00.000Z, 1986-03-02T00:00:00.000Z, "tail" ]\n' + ); + }); + + it('leaves the caller object untouched', () => { + const doc = new TomlDocument(stamps); + const obj = doc.toJsObject; + obj.stamps[4] = 'changed'; + doc.patch(obj); + + // Still the plain Date toJsObject handed out, not the internal class. + expect(obj.stamps[0].constructor).toBe(Date); + expect(obj.stamps[0]).toBeInstanceOf(Date); + }); + + it('still exposes plain Date objects from toJsObject', () => { + const doc = new TomlDocument(stamps); + const dates = doc.toJsObject.stamps.slice(0, 4); + + for (const value of dates) { + expect(value.constructor).toBe(Date); + } + // The standard Date contract, which the TOML classes deliberately break. + expect(dates[0].toISOString()).toBe('1986-03-02T00:00:00.000Z'); + }); + }); + }); diff --git a/src/toml-document.ts b/src/toml-document.ts index 1680e78d..f9c1dd52 100644 --- a/src/toml-document.ts +++ b/src/toml-document.ts @@ -9,6 +9,7 @@ import { detectNewline, resolveTomlFormat } from './toml-format'; import { truncateCst } from './truncate'; import type { ParseOptions, IntegersAsBigInt } from './parse-options'; import { decodeUtf8Bytes, hasLeadingBom, stripLeadingBom, UTF8_BOM } from './decode-utf8'; +import { isObject } from './utils'; /** * TomlDocument encapsulates a TOML CST and provides methods to interact with it. @@ -98,6 +99,16 @@ export class TomlDocument { // the pre-patch source lets every later path re-derive a clean CST from it. const sourceBefore = this._currentTomlString; + // Only worth deriving the pre-patch values when a stripped date could + // actually be present; temporal mode hands out Temporal objects untouched. + if (!this._temporal && hasPlainDate(updatedObject)) { + const originalObject = toJS(this._cst, sourceBefore, { + integersAsBigInt: this._integersAsBigInt, + temporal: false + }); + updatedObject = restoreDateRepresentations(updatedObject, originalObject); + } + const first = patchCst( this._cst, updatedObject, @@ -240,6 +251,76 @@ export class TomlDocument { } } +/** + * True when `value` holds a plain `Date` anywhere, i.e. one of the objects + * toJsObject() produced by stripping a TOML date class. Used to skip the work + * in restoreDateRepresentations() for the common case of a document with no + * dates in it. Cycle-safe. + */ +function hasPlainDate(value: any, seen: WeakSet = new WeakSet()): boolean { + if (value instanceof Date) return value.constructor === Date; + if (!value || typeof value !== 'object') return false; + if (seen.has(value)) return false; + seen.add(value); + if (Array.isArray(value)) return value.some(item => hasPlainDate(item, seen)); + return Object.keys(value).some(key => hasPlainDate(value[key], seen)); +} + +/** + * Re-attaches the TOML date representation that toJsObject() strips. + * + * toJsObject() deliberately hands out plain `Date` objects so that callers get + * the standard Date contract, notably a standard toISOString(). But the writer + * derives a value's TOML text from toISOString(), and the TOML date classes + * override it to return the original spelling. A plain Date handed back in + * therefore writes as a full offset date-time, so read-modify-write turned + * `1986-03-02` into `1986-03-02T00:00:00.000Z` whenever the surrounding node was + * rewritten rather than preserved from the CST. + * + * Where the caller left a date alone (still a plain Date, same instant), the + * original typed instance goes back in so the original spelling is written. A + * date whose instant actually changed is left as-is: only the caller knows + * whether a new instant is still meant to be a local date, and they can say so + * by passing LocalDate / LocalTime / LocalDateTime / OffsetDateTime, which are + * exported for that purpose. Instances of those classes are never touched. + * + * The caller's object is not mutated; a node is copied only when something + * beneath it changed, so an untouched tree is returned by reference. + */ +function restoreDateRepresentations(updated: any, original: any, seen: WeakSet = new WeakSet()): any { + if (updated instanceof Date) { + const erased = updated.constructor === Date + && original instanceof Date + && original.constructor !== Date + && original.getTime() === updated.getTime(); + return erased ? original : updated; + } + if (!updated || typeof updated !== 'object' || seen.has(updated)) return updated; + seen.add(updated); + + if (Array.isArray(updated)) { + if (!Array.isArray(original)) return updated; + let changed = false; + const restored = updated.map((item, index) => { + const next = restoreDateRepresentations(item, original[index], seen); + if (next !== item) changed = true; + return next; + }); + return changed ? restored : updated; + } + + if (!isObject(original)) return updated; + let changed = false; + const restored: Record = {}; + for (const key of Object.keys(updated)) { + const value = updated[key]; + const next = restoreDateRepresentations(value, original[key], seen); + restored[key] = next; + if (next !== value) changed = true; + } + return changed ? restored : updated; +} + /** * Recursively converts custom date classes to regular JavaScript Date objects. * This ensures that the toJsObject property returns standard Date objects From 0eac6baedc95e3103621f0bf63e469808377e1a2 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:36:15 -0400 Subject: [PATCH 28/36] test: guard historical fuzz seeds --- src/__tests__/patch.fuzz.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/__tests__/patch.fuzz.test.ts b/src/__tests__/patch.fuzz.test.ts index 203d428c..5301ccd9 100644 --- a/src/__tests__/patch.fuzz.test.ts +++ b/src/__tests__/patch.fuzz.test.ts @@ -2,6 +2,23 @@ import patch from '../patch'; import { parse } from '../'; import dedent from 'dedent'; +import { fuzzOne } from './fuzz-patch'; + +const historicalFuzzSeeds = [ + 19506, 21525, 30330, 31662, 32801, 35943, 37465, 39363, 40181, 41613, + 43159, 43199, 46522, 54607, 61827, 62163, 62263, 65785, 67221, 68244, + 68861, 78079, 79938, 80004, 82825, 86547, 86724, 121096, 129645, 136292, + 136865, 175924, 179377, 186384, 208822, 224081, 272851, 299772, 358055, + 377453, 421965, 460447, 599513, 742554, 771152, 863085, 863664, 1020868, + 1024477, 1112646, 1137525, 1285105, 1286183, 1383962, 1428499, 1657445, + 1674968, 1693919, 1845422, 1896226, 1947810, 2185943, 2497422, 2531104, + 2591153, 2667551, 2824408, 2858114 +]; + +test.each(historicalFuzzSeeds)('historical fuzz seed %d still passes the full harness', (seed) => { + const result = fuzzOne(seed, 3); + expect(result.status, result.error).toBe('ok'); +}); test('replacing an object in a nested multiline array preserves trailing siblings (seed 1112646)', () => { const src = dedent` From 1ae6b86a8e7c3cc66539b9be232464eadcae56d9 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:12:37 -0400 Subject: [PATCH 29/36] test: clarify and improve nested array with inline table --- src/__tests__/patch.fuzz.test.ts | 53 ++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/src/__tests__/patch.fuzz.test.ts b/src/__tests__/patch.fuzz.test.ts index 5301ccd9..70626786 100644 --- a/src/__tests__/patch.fuzz.test.ts +++ b/src/__tests__/patch.fuzz.test.ts @@ -1,6 +1,7 @@ import patch from '../patch'; import { parse } from '../'; +import diff from '../diff'; import dedent from 'dedent'; import { fuzzOne } from './fuzz-patch'; @@ -26,10 +27,6 @@ test('replacing an object in a nested multiline array preserves trailing sibling 0, 1, 2, - 3, - 4, - 5, - 6, [ { old = ''' old content @@ -43,14 +40,54 @@ test('replacing an object in a nested multiline array preserves trailing sibling ] `; + const original = parse(src) as any; const obj = parse(src) as any; - obj.values[7][0] = { + // Replace the inline table in the nested array with a new inline table that has a different structure and values. + obj.values[3][0] = { primary: -4807.689925655723, details: { values: [-2765, new Date(Date.UTC(2016, 6, 12))] } }; + const changes = diff(original, obj); + expect(changes).toHaveLength(3); + expect(changes).toMatchInlineSnapshot(` + Array [ + Object { + "path": Array [ + "values", + 3, + 0, + "old", + ], + "type": "Remove", + }, + Object { + "path": Array [ + "values", + 3, + 0, + "primary", + ], + "type": "Add", + }, + Object { + "path": Array [ + "values", + 3, + 0, + "details", + ], + "type": "Add", + }, + ] + `); + // TODO: Decide if we should consider making the inline table multiline to preserve the - // original formatting of the array. Currently, it is being converted to a single-line inline table. + // original formatting of the inline table that is being replaced (or using the fact + // that we are already inside a multiline array). Alternatively, we could just have the + // decision of using a multi-line inline table be based on the desired print-width (only + // using MLIT if they allow to shrink the inline table to fit the print-width). + // Currently, it is being converted to a single-line inline table. const result = patch(src, obj); expect(parse(result)).toEqual(obj); expect(result).toEqual(dedent` @@ -58,10 +95,6 @@ test('replacing an object in a nested multiline array preserves trailing sibling 0, 1, 2, - 3, - 4, - 5, - 6, [ { primary = -4807.689925655723, details = { values = [ -2765, 2016-07-12T00:00:00.000Z, ], }, }, false, From 082e63916ab567e38538c97fcf0c8e56d7051c24 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:59:45 -0400 Subject: [PATCH 30/36] test(fuzz): distill the seed regressions into readable cases The distilled regressions carried the raw output of the fuzzer: keys like b_3cmsbhh.al1erl4-9 and "AKy:}nV@", random string payloads, and in the case of seed 2591153 a 110-line document that was never actually reduced. Nothing in that is load-bearing. What reproduces these bugs is the shape: multiline strings inside multiline inline containers, and how many writes land in one container. All 16 are renamed to readable keys and values, and 2591153 is reduced from 110 lines to 9. Titles now say what each case exercises instead of only quoting a seed number, and each carries a comment explaining the mechanism. Verified rather than assumed. Every case was run against a build of 82d6bca, the commit before `fix: harden multiline patch planning`, where 13 of the 16 still fail. The same 13 fail there after the rewrite, with the same failure modes, so no reproduction was lost. The other three already passed at that commit, so the control cannot speak to them; for those the diff() change-set shape was checked to be unchanged. Two cases needed care. Minimising 1286183 too far removed the trailing multiline strings and it stopped reproducing, so that structure is restored. 2824408 stopped reproducing when the renamed strings came out shorter, since the bug turns on offsets, so its line lengths now match the original. The seeds themselves remain covered byte-for-byte by the historical harness test, which is what makes reducing these safe. --- src/__tests__/patch.fuzz.test.ts | 674 ++++++++++++------------------- 1 file changed, 255 insertions(+), 419 deletions(-) diff --git a/src/__tests__/patch.fuzz.test.ts b/src/__tests__/patch.fuzz.test.ts index 70626786..41b7349c 100644 --- a/src/__tests__/patch.fuzz.test.ts +++ b/src/__tests__/patch.fuzz.test.ts @@ -3420,291 +3420,106 @@ test('converting an AOT into a scalar-first mixed array (seed 136865 alt.2)', () }); -test('regression for fuzz seed 2591153', () => { - const src = dedent` - gas = true - sz = 178262 - "v^.aq/(w" = 2058-10-24T21:21:43 - mt5434cc."Cx[+"."q(?TYenJR" = """ - &m}Y""" - ix4t8ryk8."hBTJ&l&3bl".yt3z6_ = true - w2 = 2049-04-03T21:46:36.579301 - "R " = 0o150531 - clkd176.gs6 = -579731 - "FkeQ?W".u5oxixqh = "w4*5](T\`Wgjlbi+@wYY2o~" - # yg=|nPERhmd_2Qj;Qu$O^|T2*u - ryhwr.m5tn.gsg1h = 207_795 - p6jzbb677n.uud.fyoqco70y = 90023.48570 - k0cxt83.iwobqz4vi.NQSltlW = 513513 - # nx%JXe@oDPCx$/Iz-dV%:EI)/ - [["".lcmm1m0.vfkm5_p]] - "Wg[" = 0b0011011 - "m4}cz".jnyu = 2080-08-12T23:09:05Z - # |J^H\`E{/un~cEYJoJb - # 7ZBeQqq/ot:" = 258084 - vhashp_7d-."zD_>~7Lf3" = -464765 - "q=y".ojj = """ - iB""" - x7co_.u.AtJ = 754_620 - - [q2c."o!AxxvqZj"] - g9p75i5s_ = ''' - !esM3g7w - }I2t-:Y-|#MD - E] - ,^vU - oEA - w - 0 - ! - 7nGBp - - ''' - cmzsfzz.f3utn728zu = true - "^]jfx9Hz_" = 2053-10-21T07:50:22Z - f6ziybf.egyieof = false - "B3:c%G9".sf_90m7q.ncide1 = 1981-12-01T06:47:08Z - t = 986e-61 - ez."yTxL(kP[j0"."SPCIi(YbYe" = 53780.13362 - cpqdsgprv."%-+WE".x9vi = 2091-11-01T14:04:57 - s-yvw-4f.jes8rouk = 82_668.64849 - - [gspp.e.vg_08p5dgs] - eegf54qq.b3ff_g = true - bttb8h.vk5c."" = [] - dx2feuc.3 = """ - -z - =t9EF) - 6+""" - # ~xsBrSdMAd;mr\`JPYi - yh3l3-g2x.gmw3_.ppq_ep3k3 = """ - = VD95 - v?{gW - j - 1,]OS5""" - f6qrvqct.ny0fj-3 = 367707 - fx = nan - # oO!S;$e^N>g{%,+eJzcq(}kE,@J=je+M - [[qqqk."C<4M\`%W2L"]] - - [[y9wecl5]] - rw3l2q8mk.d-ps = ']zgL)[hSjD.R+' - # i(M^R&yAn3x3pj]ayUh -^ *xi\`1,cQ(~ - qe = 995783 - vag."[Q&%][ 6ap" = 559418 - aam48 = true - - ["AKy:}nV@"] - kl7u = true - # Vu_I\`CdTJh>9oacHXu\`o>|Nt\`=pHltLN - "hG b+T".oyyuk3 = 937467 - r46f8rhrh.rprqr8g = -566314 - p8 = { - uyvk4h."m}5?" = "Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w { + const src = dedent` + removed = 2049-04-03T21:46:36.579301 + ["host config"] + enabled = true + options = { + label = "a value", + nested.items.list = [967985, -25027300000000694064, true, true, 50_953.25829, ["inner", 0b100101, -650416], """ + ml text"""], + tail = 'another value', + } + `; const obj = parse(src) as any; - delete obj.w2; - obj["AKy:}nV@"].p8["(J:\\" = 258084 - vhashp_7d-.\\"zD_>~7Lf3\\" = -464765 - \\"q=y\\".ojj = \\"\\"\\" - iB\\"\\"\\" - x7co_.u.AtJ = 754_620 - - [q2c.\\"o!AxxvqZj\\"] - g9p75i5s_ = ''' - !esM3g7w - }I2t-:Y-|#MD - E] - ,^vU - oEA - w - 0 - ! - 7nGBp - - ''' - cmzsfzz.f3utn728zu = true - \\"^]jfx9Hz_\\" = 2053-10-21T07:50:22Z - f6ziybf.egyieof = false - \\"B3:c%G9\\".sf_90m7q.ncide1 = 1981-12-01T06:47:08Z - t = 986e-61 - ez.\\"yTxL(kP[j0\\".\\"SPCIi(YbYe\\" = 53780.13362 - cpqdsgprv.\\"%-+WE\\".x9vi = 2091-11-01T14:04:57 - s-yvw-4f.jes8rouk = 82_668.64849 - - [gspp.e.vg_08p5dgs] - eegf54qq.b3ff_g = true - bttb8h.vk5c.\\"\\" = [] - dx2feuc.3 = \\"\\"\\" - -z - =t9EF) - 6+\\"\\"\\" - # ~xsBrSdMAd;mr\`JPYi - yh3l3-g2x.gmw3_.ppq_ep3k3 = \\"\\"\\" - = VD95 - v?{gW - j - 1,]OS5\\"\\"\\" - f6qrvqct.ny0fj-3 = 367707 - fx = nan - # oO!S;$e^N>g{%,+eJzcq(}kE,@J=je+M - [[qqqk.\\"C<4M\`%W2L\\"]] - - [[y9wecl5]] - rw3l2q8mk.d-ps = ']zgL)[hSjD.R+' - # i(M^R&yAn3x3pj]ayUh -^ *xi\`1,cQ(~ - qe = 995783 - vag.\\"[Q&%][ 6ap\\" = 559418 - aam48 = true - - [\\"AKy:}nV@\\"] - kl7u = true - # Vu_I\`CdTJh>9oacHXu\`o>|Nt\`=pHltLN - \\"hG b+T\\".oyyuk3 = 937467 - r46f8rhrh.rprqr8g = -566314 - p8 = { - uyvk4h.\\"m}5?\\" = \\"Jl*~zOV7.6JlX?C~VdO$dA3A~},$#/*w { - const src = dedent` - [["2S38X#"]] - "dj/{gXY".sp_ya888x.mx7h8hlr = ''' - D<''' - ln_n = [ +// The dotted key loses its last segment and becomes an array, while the +// multiline inline array below it must keep its own layout untouched. +test('replacing a dotted-key subtable with an array beside a multiline inline array (seed 1112646)', () => { + const src = dedent` + [["release notes"]] + "doc path".summary.detail = ''' + ok''' + items = [ ''' ,''', - [{ "t&R/jR" = ''' - TPV!''' }, false, 287_173, "NAzv Be{}8z]C2QzJpyz}_]idd", 1984-04-16T12:37:13Z], + [{ "inner key" = ''' + text!''' }, false, 287_173, "a plain string value", 1984-04-16T12:37:13Z], ] `; const obj = parse(src) as any; - obj["2S38X#"][0]["dj/{gXY"].sp_ya888x = [new Date(Date.UTC(2018, 5, 14)), -2069, 784]; + obj['release notes'][0]['doc path'].summary = [new Date(Date.UTC(2018, 5, 14)), -2069, 784]; const result = patch(src, obj, undefined); expect(result).toMatchInlineSnapshot(` - "[[\\"2S38X#\\"]] - \\"dj/{gXY\\".sp_ya888x = [ 2018-06-14T00:00:00.000Z, -2069, 784, ] - ln_n = [ + "[[\\"release notes\\"]] + \\"doc path\\".summary = [ 2018-06-14T00:00:00.000Z, -2069, 784, ] + items = [ ''' ,''', - [{ \\"t&R/jR\\" = ''' - TPV!''' }, false, 287_173, \\"NAzv Be{}8z]C2QzJpyz}_]idd\\", 1984-04-16T12:37:13Z], + [{ \\"inner key\\" = ''' + text!''' }, false, 287_173, \\"a plain string value\\", 1984-04-16T12:37:13Z], ]" `); expect(parse(result)).toEqual(obj); }); -test('distilled regression for fuzz seed 1286183', () => { +// The deleted key is the first member of a multiline inline table whose +// remaining members span two more lines, so every following offset moves. +test('deleting a deep dotted key inside a multiline inline table (seed 1286183)', () => { const src = dedent` - i5qpju2q = """ - 8=""" - [p7jqr__s] - j1."d1bJi gG""", i-43c9963p.bre5k8g28.guv2pn = 'l0-Y?;FYxwz', su.p = 36740, "Pv3!oN4".ub_k79_ = false, dnc3w6 = "", "ag/MY[2".ud = 527558, "Y][2vRao-".ekiao1h.t88fjn = 2080-12-04, "@" = 31256.58522, kqk32ihb.xcd01 = 819597, xf79wkh3q = 0x08, i = -284092, ZZ = 0o64, jbr.r0acvtrwd = true }, "&f".ck.pe = 2081-06-28T00:40:23Z, "5BN)X3P5:".b6dgagg = """ - DU)$R8E>JL*""", do8b6z.f4f-0l = {}, zhd31f5qk.h862qtmhq = 90692.79376, hc."+ZlY;4|" = true }, + filler = { one.two.three = -65079.12231, flag = false, "q one".mid.leaf = false, group = { count.total = 770012, note.body = """ + first inline note""", a-b.c.d = 'short lit', e.f = 36740, "q two".g = false, empty = "", "q three".h = 527558, "q four".date.stamp = 2080-12-04, "q five" = 31256.58522, i.j = 819597, k = 0x08, l = -284092, MM = 0o64, n.o = true }, "q six".p.q = 2081-06-28T00:40:23Z, "q seven".r = """ + second inline note""", s.t = {}, u.v = 90692.79376, w."q eight" = true }, } `; const obj = parse(src) as any; - delete obj.p7jqr__s.j1["d { bracketSpacing: true, updateOrder: false, trailingNewline: 1, - newLine: '\r\n', + newLine: "\r\n", leadingBom: false, truncateZeroTimeInDates: true, useTabsForIndentation: false }); expect(result).toMatchInlineSnapshot(` - "i5qpju2q = \\"\\"\\" - 8=\\"\\"\\" - [p7jqr__s] - j1.\\"d1bJi gG\\" }, i-43c9963p = { bre5k8g28 = { guv2pn = \\"l0-Y?;FYxwz\\" } }, su = { p = 36740 }, \\"Pv3!oN4\\" = { ub_k79_ = false }, dnc3w6 = \\"\\", \\"ag/MY[2\\" = { ud = 527558 }, \\"Y][2vRao-\\" = { ekiao1h = { t88fjn = 2080-12-04 } }, \\"@\\" = 31256.58522, kqk32ihb = { xcd01 = 819597 }, xf79wkh3q = 8, i = -284092, ZZ = 52, jbr = { r0acvtrwd = true } }, \\"&f\\" = { ck = { pe = 2081-06-28T00:40:23Z } }, \\"5BN)X3P5:\\" = { b6dgagg = \\"DU)$R8E>JL*\\" }, do8b6z = { f4f-0l = {} }, zhd31f5qk = { h862qtmhq = 90692.79376 }, hc = { \\"+ZlY;4|\\" = true } } } + "header = \\"\\"\\" + ab\\"\\"\\" + [section] + outer.\\"quoted key\\" = { first = { second = { third = { alpha = { beta = {} } } } }, filler = { one = { two = { three = -65079.12231 } }, flag = false, \\"q one\\" = { mid = { leaf = false } }, group = { count = { total = 770012 }, note = { body = \\"first inline note\\" }, a-b = { c = { d = \\"short lit\\" } }, e = { f = 36740 }, \\"q two\\" = { g = false }, empty = \\"\\", \\"q three\\" = { h = 527558 }, \\"q four\\" = { date = { stamp = 2080-12-04 } }, \\"q five\\" = 31256.58522, i = { j = 819597 }, k = 8, l = -284092, MM = 52, n = { o = true } }, \\"q six\\" = { p = { q = 2081-06-28T00:40:23Z } }, \\"q seven\\" = { r = \\"second inline note\\" }, s = { t = {} }, u = { v = 90692.79376 }, w = { \\"q eight\\" = true } } } " `); expect(parse(result)).toEqual(obj); }); -test('distilled regression for fuzz seed 1383962', () => { +// The array of tables entry collapses to a single date value while the +// multiline inline array above it, holding a bigint and -inf, stays put. +test('replacing an AOT subtable with a date beside a multiline inline array (seed 1383962)', () => { const src = dedent` - "1vn[1flkZ".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, """ - s~""", 76968.6746, 'Qr+qJ|jGKM n=c1oEx^\`p2;L0H_Nyg*3K$:A{?jJg:I', 2069-08-08T04:34:33, "A3!)hHn6}I"] }, 574310, 28569, -63757100000000418476, """ - 8=Qed7]-^jftM""", -50759.88688, -inf, true] - [[kmza]] - ptvl.s2fdfonrz9 = 'vQb/mr3cU )zJScrEv2bQ-$}kK1U&o,5hpf9@N|Ke[' + "quoted key".items = [{ inner.values = [true, 47577.29573, true, 2077-04-05, 282_582, """ + ml""", 76968.6746, 'literal text', 2069-08-08T04:34:33, "basic text"] }, 574310, 28569, -63757100000000418476, """ + second ml""", -50759.88688, -inf, true] + [[entries]] + label.name = 'another literal' `; const obj = parse(src) as any; - obj.kmza[0].ptvl = new Date(Date.UTC(2006, 1, 14)); + obj.entries[0].label = new Date(Date.UTC(2006, 1, 14)); const result = patch(src, obj, undefined); expect(result).toMatchInlineSnapshot(` - "\\"1vn[1flkZ\\".qmam4fx = [{ dxnzi0.d0hc = [true, 47577.29573, true, 2077-04-05, 282_582, \\"\\"\\" - s~\\"\\"\\", 76968.6746, 'Qr+qJ|jGKM n=c1oEx^\`p2;L0H_Nyg*3K$:A{?jJg:I', 2069-08-08T04:34:33, \\"A3!)hHn6}I\\"] }, 574310, 28569, -63757100000000418476, \\"\\"\\" - 8=Qed7]-^jftM\\"\\"\\", -50759.88688, -inf, true] - [[kmza]] - ptvl = 2006-02-14T00:00:00.000Z" + "\\"quoted key\\".items = [{ inner.values = [true, 47577.29573, true, 2077-04-05, 282_582, \\"\\"\\" + ml\\"\\"\\", 76968.6746, 'literal text', 2069-08-08T04:34:33, \\"basic text\\"] }, 574310, 28569, -63757100000000418476, \\"\\"\\" + second ml\\"\\"\\", -50759.88688, -inf, true] + [[entries]] + label = 2006-02-14T00:00:00.000Z" `); expect(parse(result)).toEqual(obj); }); -test('distilled regression for fuzz seed 1693919', () => { +// The replacement is deeper than what it replaces, so the container grows +// while a sibling multiline array below it has to keep its own positions. +test('replacing an inline-table member with a nested object in a multiline inline table (seed 1693919)', () => { const src = dedent` - [g3s_y5jv_d.mfdfdc] - "".c4esqy.t79d__y = { - bnJF1EQ = { jg08iizbia = """ - fP=SCd-/e]!;bs(AR*w3L""" }, - "#?VRmg"." { bracketSpacing: true, updateOrder: false, trailingNewline: 1, - newLine: '\r\n', + newLine: "\r\n", leadingBom: false, truncateZeroTimeInDates: false, useTabsForIndentation: true }); expect(result).toMatchInlineSnapshot(` - "[g3s_y5jv_d.mfdfdc] - \\"\\".c4esqy.t79d__y = { bnJF1EQ = { k27 = { k57 = false, k17 = false, k67 = [ 2036-09-23T00:00:00.000Z, true, -1287.6224634237587, ], }, }, \\"#?VRmg\\" = { \\" { +// Emptying the inline table removes a multiline string, which shortens the +// enclosing array and leaves its closing bracket and comma offsets stale. +test('deleting the only member of an inline table inside a multiline array (seed 175924)', () => { const src = dedent` - [[l."=".p]] - m = { + [[outer."=".inner]] + empty = { } - qsof = ["--2#k+z+M,Et1[EnDbG0_Ykh7^ ", 1988-12-04T04:19:06, [ + items = ["plain text", 1988-12-04T04:19:06, [ 0b000000110001011, - { t7a5uwjr2 = """ - ]UjKeB""" }, + { target = """ + ml""" }, -907693, ], 65260.050825] `; const obj = parse(src) as any; - delete obj.l["="].p[0].qsof[2][1].t7a5uwjr2; + delete obj.outer['='].inner[0].items[2][1].target; const result = patch(src, obj, { trailingComma: false, bracketSpacing: true, updateOrder: false, trailingNewline: 1, - newLine: '\r\n', + newLine: "\r\n", leadingBom: true, truncateZeroTimeInDates: false, useTabsForIndentation: false }); expect(result).toMatchInlineSnapshot(` - "[[l.\\"=\\".p]] - m = { + "[[outer.\\"=\\".inner]] + empty = { } - qsof = [ \\"--2#k+z+M,Et1[EnDbG0_Ykh7^ \\", 1988-12-04T04:19:06, [ 395, {}, -907693 ], 65260.050825 ] + items = [ \\"plain text\\", 1988-12-04T04:19:06, [ 395, {}, -907693 ], 65260.050825 ] " `); expect(parse(result)).toEqual(obj); }); -test('distilled regression for fuzz seed 1896226', () => { +// The removed inline table contains a multiline string, so the elements after +// it in the same array shift by more than one line. +test('deleting a nested inline table inside a multiline inline array (seed 1896226)', () => { const src = dedent` - y0pgo = ''' - zw$#+{rD] Q:|WI_5xV''' - [syn2x.b.o6y2i] - xx1dnmb-lu.i-b5qipqve = { kh5rr3tnn.tfv.acbd8jed_ = true, am7fwt.jri8xuo = "drC+m!9n vxA(?w8N+)9]jF1 /", eu0e.ygeqkl.rv5zela02z = -792779, c.teb.pe = 0xd45, fk-no = false, wvddr.gmv.d8iwt = "]49F#3@Gi^y{@4y(eYalh%@", piif.qwkfgiyp = false, "kNg6#mA".mmu1i.u1aubp = true, hk3wa8v.">uk|E\`!8a" = 2055-10-17T23:43:59.550334Z, "|EnD &e~>\`".npriy.OU = inf, m3y3lj.viyt2w.c = "U/qmv0wx=W/ib/M^tuk|E\`!8a\\" = 2055-10-17T23:43:59.550334Z, \\"|EnD &e~>\`\\".npriy.OU = inf, m3y3lj.viyt2w.c = \\"U/qmv0wx=W/ib/M^t { +// The deletion empties one branch of a dotted key while its sibling member, +// another multiline literal string, has to keep its own layout. +test('deleting a dotted key inside an inline table in a multiline array (seed 2185943)', () => { const src = dedent` - ei.Nj = """ - >!~Wg-y)~""" - [[megac68k-]] - z = [ + top.name = """ + ml""" + [[entries]] + empty = [ ] - kw = [46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275_068, { - cnoff = { l7."UMN$eUS9="."" = ''' - C/,9''' }, - aujqbb = ''' - P/*''', + values = [46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275_068, { + outer = { inner."quoted"."" = ''' + lit''' }, + other = ''' + lit two''', }, 22787.072880, 79051.79185, true] - "1x(4u.WK2x".v4r4evh1.c3pw90 = """ - m@7""" + "quoted key".middle.leaf = """ + ml two""" `; const obj = parse(src) as any; - delete obj["megac68k-"][0].kw[5].cnoff.l7["UMN$eUS9="]; + delete obj.entries[0].values[5].outer.inner['quoted']; const result = patch(src, obj, undefined); expect(result).toMatchInlineSnapshot(` - "ei.Nj = \\"\\"\\" - >!~Wg-y)~\\"\\"\\" - [[megac68k-]] - z = [ + "top.name = \\"\\"\\" + ml\\"\\"\\" + [[entries]] + empty = [ ] - kw = [ 46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275068, { cnoff = { l7 = {} }, aujqbb = \\"P/*\\" }, 22787.07288, 79051.79185, true ] - \\"1x(4u.WK2x\\".v4r4evh1.c3pw90 = \\"\\"\\" - m@7\\"\\"\\"" + values = [ 46674.18719, 48559.13327, 2091-08-20T06:58:11, 2024-05-10, 275068, { outer = { inner = {} }, other = \\"lit two\\" }, 22787.07288, 79051.79185, true ] + \\"quoted key\\".middle.leaf = \\"\\"\\" + ml two\\"\\"\\"" `); expect(parse(result)).toEqual(obj); }); -test('distilled regression for fuzz seed 2497422', () => { +// The inline table becomes empty mid-array, and the array continues with a +// nested array that itself spans lines. +test('deleting the only member of an inline table holding a multiline string (seed 2497422)', () => { const src = dedent` - "x^@zw" = [' ', -78860.81892, { ik4o2d6z4j = """ - DM}T+uCZVx*t+qMtQtbPrFiI""" }, "\`kO^E(R8324tzB;#iBmn9aX!9KUL*N0eWvf/J:R", 36709.83314, [true, "ZrS|K#MaiwaYr4UOIP<$=8clcC*+Px{^#v@>G|(@>;a>w&X&Jb", 945e+85, -30134.83738, { + "quoted key" = [' ', -78860.81892, { target = """ + ml text""" }, "basic one", 36709.83314, [true, "basic two", 945e+85, -30134.83738, { }, """ - HLzq$8|?""", "G|(@>;a>w&X&Jb\\", 9.45e+87, -30134.83738, {}, \\"HLzq$8|?\\", \\" { +// The replacement drops the multiline string the original member held, so the +// array shrinks by a line while later elements keep their positions. +test('replacing an inline table inside a multiline array (seed 2531104)', () => { const src = dedent` - mmx9rwvk7k.lqmp_-1cgl = ''' - 5>ZvY=\`r~ha''' - wcyw = { - tf."O\`aB" = [true, { "%#FUrN".cvmmswb.">&K?" = 115668, p2ohe0.sqh9uj."$Qk^~(" = -92131.88369, kvy9 = false, kvy9h1bo.fe7b1px4bb = """ - :-i1""" }, 745102, 96226.27680, 0o21016, false, 802344, "#vH#E:o>J[jB||M"], - l."~u)HFjuZ" = 00:16:17, + top.name = ''' + lit text''' + outer = { + inner."quoted" = [true, { "a b".c.d = 115668, e.f."g h" = -92131.88369, flag = false, other.leaf = """ + ml""" }, 745102, 96226.27680, 0o21016, false, 802344, "basic text"], + tail."other q" = 00:16:17, } `; const obj = parse(src) as any; - obj.wcyw.tf["O`aB"][1] = { "k96": 1640, "k18": true }; + obj.outer.inner['quoted'][1] = { alpha: 1640, beta: true }; const result = patch(src, obj, { inlineTableStart: 1, @@ -3918,35 +3743,37 @@ test('distilled regression for fuzz seed 2531104', () => { bracketSpacing: false, updateOrder: true, trailingNewline: 1, - newLine: '\n', + newLine: "\n", leadingBom: true, truncateZeroTimeInDates: false, useTabsForIndentation: false, minimumDecimals: 1 }); expect(result).toMatchInlineSnapshot(` - "mmx9rwvk7k.lqmp_-1cgl = ''' - 5>ZvY=\`r~ha''' - [wcyw] - tf = {\\"O\`aB\\" = [true, {k96 = 1640.0, k18 = true,}, 745102.0, 96226.2768, 8718.0, false, 802344.0, \\"#vH#E:o>J[jB||M\\",],} - l = {\\"~u)HFjuZ\\" = 00:16:17,} + "top.name = ''' + lit text''' + [outer] + inner = {quoted = [true, {alpha = 1640.0, beta = true,}, 745102.0, 96226.2768, 8718.0, false, 802344.0, \\"basic text\\",],} + tail = {\\"other q\\" = 00:16:17,} " `); expect(parse(result)).toEqual(obj); }); -test('distilled regression for fuzz seed 2667551', () => { +// The removed element sits between two multiline basic strings, so the writer +// has to close the gap without disturbing either delimiter. +test('removing a date element from a multiline array of multiline strings (seed 2667551)', () => { const src = dedent` - [[xwed7dv]] - w9ri7t6az = { + [[entries]] + empty = { } - ax.dkibhxt = ['', """ - >X+YrY\`N""", 2088-10-24T00:54:56, """ - $e"""] + outer.items = ['', """ + ml one""", 2088-10-24T00:54:56, """ + ml two"""] `; const obj = parse(src) as any; - obj.xwed7dv[0].ax.dkibhxt.splice(2, 1); + obj.entries[0].outer.items.splice(2, 1); const result = patch(src, obj, { inlineTableStart: 1, @@ -3954,56 +3781,58 @@ test('distilled regression for fuzz seed 2667551', () => { bracketSpacing: false, updateOrder: true, trailingNewline: 1, - newLine: '\n', + newLine: "\n", leadingBom: false, truncateZeroTimeInDates: true, useTabsForIndentation: false }); expect(result).toMatchInlineSnapshot(` - "[[xwed7dv]] - w9ri7t6az = { + "[[entries]] + empty = { } - ax.dkibhxt = [\\"\\", \\">X+YrY\`N\\", \\"$e\\"] + outer.items = [\\"\\", \\"ml one\\", \\"ml two\\"] " `); expect(parse(result)).toEqual(obj); }); -test('distilled regression for fuzz seed 2824408', () => { +// The replaced member holds both a multiline literal and a multiline basic +// string, so the container collapses from three lines to one. +test('replacing an inline-table member with a single-key object (seed 2824408)', () => { const src = dedent` - [ivk.nuan749c7] - pJ.kd7jct7_o = { - xwjbnijuw = { k-l259ef = "E2 U!/a", vi.dg5 = true, f4wkz1o_j.cgog.gw8umgy = ''' - t5B02omW_Qc5gU}''', qcc3pxkelq._G = """ - \`]cA6:81:-L__K?yaccQdE{zs{v$8?CN?be""" }, - "\`pD".r = "U9SE]2S+ { expect(parse(result)).toEqual(obj); }); -test('distilled regression for fuzz seed 2858114', () => { +// The array holds a multiline string and ends with an inline table on its own +// lines, both of which have to survive the element removal. +test('removing an element from a multiline array before an inline table (seed 2858114)', () => { const src = dedent` - sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, "qEZv", 0x703714, "p_\`:SMIvsp(<$", "m|2Z@v-c},BQ~+EDUZQr(lxlbX^iy0Mi>{G%c", """ - ;(d7x""", { - e_8y4s = 59108.35246, + outer.values = [true, 22866.4194, 890412, 399573, "one", 0x703714, "two", "three", """ + ml""", { + inner = 59108.35246, }] - xe_p.w5yln8z5 = ''' - xo5o''' + other.name = ''' + lit''' `; const obj = parse(src) as any; - obj.sw2.rfc4998bx.splice(5, 1); + obj.outer.values.splice(5, 1); const result = patch(src, obj, undefined); expect(result).toMatchInlineSnapshot(` - "sw2.rfc4998bx = [true, 22866.4194, 890412, 399573, \\"qEZv\\", \\"p_\`:SMIvsp(<$\\", \\"m|2Z@v-c},BQ~+EDUZQr(lxlbX^iy0Mi>{G%c\\", \\"\\"\\" - ;(d7x\\"\\"\\", { - e_8y4s = 59108.35246, + "outer.values = [true, 22866.4194, 890412, 399573, \\"one\\", \\"two\\", \\"three\\", \\"\\"\\" + ml\\"\\"\\", { + inner = 59108.35246, }] - xe_p.w5yln8z5 = ''' - xo5o'''" + other.name = ''' + lit'''" `); expect(parse(result)).toEqual(obj); }); -test('distilled regression for fuzz seed 377453', () => { +// The replaced element is a dense inline table spanning two lines; the array +// around it keeps its trailing scalars. +test('replacing an inline-table element inside a multiline array (seed 377453)', () => { const src = dedent` - eqki- = { - v1xubvr5b2 = [false, true, ''' - <6@Qd}q = \`q]B>FSx''', -49_687.079945, { czt."@C~NuX" = "o5!A|8C-ri)@-U{I", t7.V.v = 0x5c5fa0, ")iE=18F=" = "h#\`^&yX^7wqnyK}(2j?~uEXfHhW1VmI;7)zeF54:Z&+whORWa", zC."&E".p2-fnhimsk = true, zo.HK.o_etoxccp6 = 0o26771, y95lhy8.x.ibl4bqh4di = 0xcfe, hmmu35k7g.iit__292.zjjfy = 15:08:47, c4u9he.";[c76C".bkkhwx2_5w = """ - o0q*""" }, "I3q/hH", 753862, 'z', -64864.2541], - gmpmfyn4.y1np1 = 0o4755211, + outer = { + values = [false, true, ''' + lit with = sign''', -49_687.079945, { a."q one" = "basic one", b.c.d = 0x5c5fa0, "q two" = "basic two", e."q three".f = true, g.h.i = 0o26771, j.k.l = 0xcfe, m.n.o = 15:08:47, p."q four".r = """ + ml""" }, "basic three", 753862, 'z', -64864.2541], + tail.leaf = 0o4755211, } `; const obj = parse(src) as any; - obj["eqki-"].v1xubvr5b2[4] = { "k94": -4472 }; + obj.outer.values[4] = { alpha: -4472 }; const result = patch(src, obj, { trailingComma: true, bracketSpacing: true, updateOrder: false, trailingNewline: 1, - newLine: '\n', + newLine: "\n", leadingBom: false, truncateZeroTimeInDates: true, useTabsForIndentation: false, minimumDecimals: 1 }); expect(result).toMatchInlineSnapshot(` - "[eqki-] - v1xubvr5b2 = [ false, true, \\"<6@Qd}q = \`q]B>FSx\\", -49687.079945, { k94 = -4472.0, }, \\"I3q/hH\\", 753862.0, \\"z\\", -64864.2541, ] - gmpmfyn4 = { y1np1 = 1301129.0, } + "[outer] + values = [ false, true, \\"lit with = sign\\", -49687.079945, { alpha = -4472.0, }, \\"basic three\\", 753862.0, \\"z\\", -64864.2541, ] + tail = { leaf = 1301129.0, } " `); expect(parse(result)).toEqual(obj); }); -test('distilled regression for fuzz seed 771152', () => { +// Two writes land in the same multiline inline array through the nested inline +// table, and they overlap via the array's stale end position. +test('replacing a dotted-key subtable inside a nested inline table (seed 771152)', () => { const src = dedent` - b_3cmsbhh.al1erl4-9 = [236463, ''' - -xl6''', 0o34, 90356.53508, true, [{ us.xl."/" = """ - fr;,Iq*!9""" }, 1986-03-02]] + build.artifacts = [1, ''' + alpha''', 0o34, 2.5, true, [{ meta.notes.summary = """ + detail""" }, 1986-03-02]] `; const obj = parse(src) as any; - obj.b_3cmsbhh["al1erl4-9"][5][0].us.xl = { "k51": -3337.0673237368464, "k49": "QhvX_vl aKj9dsQ0r7" }; + obj.build.artifacts[5][0].meta.notes = { width: 3, label: 'measured value' }; const result = patch(src, obj, undefined); - expect(result).toMatchInlineSnapshot(`"b_3cmsbhh.al1erl4-9 = [236463, \\"-xl6\\", 28, 90356.53508, true, [{us = {xl = {k51 = -3337.0673237368464, k49 = \\"QhvX_vl aKj9dsQ0r7\\"}}}, 1986-03-02]]"`); + expect(result).toMatchInlineSnapshot(`"build.artifacts = [1, \\"alpha\\", 28, 2.5, true, [{meta = {notes = {width = 3, label = \\"measured value\\"}}}, 1986-03-02]]"`); expect(parse(result)).toEqual(obj); }); -test('distilled regression for fuzz seed 863664', () => { +// The replaced table is declared after a multiline array of tables value, so +// the whole preceding block has to keep its positions. +test('replacing a table with a scalar after a multiline AOT array (seed 863664)', () => { const src = dedent` - [pm."+w_y2".nlgwd4] - k86xv_q."]7]P2g".xq99y = [ - { chm72ws.si3 = 0xd5c289d, x = 2075-01-15T23:10:01.402080, s9gatl14xa.f6fbq_msjy = { f.nl = 597050, k.z30i = 939925, i_6d2w."g3}7A0" = 785203, 5C = 465053, e3tzu = 822110, "_hGRMk\`Qg~".ku = 412e-33, fvemmt = false, wrs2ylbfaf."AQ#B,[LL=c".ep = -282456, bs3o4.knr3jqyf.h = 70302.033641, h.z3ohf3j16 = -75001.099256, nju0p.tipdjb3pfk = 54219.46102 }, "w@LY+a#1<".z.")e$TtimN7" = 482015, "fa}@[\`"."ae,>ImvDxv".49e = true, "~/yZqL.$" = "gze@7dH;YExh", by1 = '|Xd', "".nytor2grmi."" = { h53y.uujhq.hxk5o = """ - 2^t""" }, axau5."NQ6U?Fo{" = false, ubqaz = 0b111111110111000 }, + [report."by target".details] + summary."run label".entries = [ + { size.bytes = 0xd5c289d, built = 2075-01-15T23:10:01.402080, stats.counts = { lines.total = 597050, words.total = 939925, chars."with space" = 785203, files = 465053, dirs = 822110, "ratio a".value = 412e-33, cached = false, delta."ratio b".amount = -282456, mean.load.value = 70302.033641, min.load = -75001.099256, max.load = 54219.46102 }, "group a".index."sub key" = 482015, "group b"."sub b".enabled = true, "group c" = "basic one", tag = 'lit', "".marker."" = { note.body.text = """ + ml""" }, flags."opt in" = false, mask = 0b111111110111000 }, ''' ''', ] - [d5.dlf.n] + [replaced.sub.leaf] `; const obj = parse(src) as any; - obj.d5 = "P- L9H"; + obj.replaced = 'replacement'; const result = patch(src, obj, undefined); expect(result).toMatchInlineSnapshot(` - "d5 = \\"P- L9H\\" + "replaced = \\"replacement\\" - [pm.\\"+w_y2\\".nlgwd4] - k86xv_q.\\"]7]P2g\\".xq99y = [ - { chm72ws.si3 = 0xd5c289d, x = 2075-01-15T23:10:01.402080, s9gatl14xa.f6fbq_msjy = { f.nl = 597050, k.z30i = 939925, i_6d2w.\\"g3}7A0\\" = 785203, 5C = 465053, e3tzu = 822110, \\"_hGRMk\`Qg~\\".ku = 412e-33, fvemmt = false, wrs2ylbfaf.\\"AQ#B,[LL=c\\".ep = -282456, bs3o4.knr3jqyf.h = 70302.033641, h.z3ohf3j16 = -75001.099256, nju0p.tipdjb3pfk = 54219.46102 }, \\"w@LY+a#1<\\".z.\\")e$TtimN7\\" = 482015, \\"fa}@[\`\\".\\"ae,>ImvDxv\\".49e = true, \\"~/yZqL.$\\" = \\"gze@7dH;YExh\\", by1 = '|Xd', \\"\\".nytor2grmi.\\"\\" = { h53y.uujhq.hxk5o = \\"\\"\\" - 2^t\\"\\"\\" }, axau5.\\"NQ6U?Fo{\\" = false, ubqaz = 0b111111110111000 }, + [report.\\"by target\\".details] + summary.\\"run label\\".entries = [ + { size.bytes = 0xd5c289d, built = 2075-01-15T23:10:01.402080, stats.counts = { lines.total = 597050, words.total = 939925, chars.\\"with space\\" = 785203, files = 465053, dirs = 822110, \\"ratio a\\".value = 412e-33, cached = false, delta.\\"ratio b\\".amount = -282456, mean.load.value = 70302.033641, min.load = -75001.099256, max.load = 54219.46102 }, \\"group a\\".index.\\"sub key\\" = 482015, \\"group b\\".\\"sub b\\".enabled = true, \\"group c\\" = \\"basic one\\", tag = 'lit', \\"\\".marker.\\"\\" = { note.body.text = \\"\\"\\" + ml\\"\\"\\" }, flags.\\"opt in\\" = false, mask = 0b111111110111000 }, ''' ''', ]" `); expect(parse(result)).toEqual(obj); }); - From 9d0061e500e1087738f91ad641ec99a696879964 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:03:59 -0400 Subject: [PATCH 31/36] refactor: keep patch verification internal for a patch release The round-trip verification is what fixes the multiline offset bugs on this branch, but the way it was exposed made the branch a minor release: a new `options` argument on patch() and TomlDocument.patch(), a new exported PatchOptions type, and a new throw when neither attempt round-trips. None of that is needed for the fixes to work, so it comes out: - the `options` parameter and the PatchOptions type are gone; verification and the transactional retry stay as implementation details - an unsatisfiable patch now returns the fine-grained result instead of throwing, so nothing fails where earlier versions succeeded, and TomlDocument commits that result with its CST re-derived from it rather than rolling back The generated .d.ts is now byte-identical to origin/latest, so this branch carries no public API change at all. Verification cannot simply be dropped instead. With it disabled, 13 seeds in the historical harness and 11 distilled regressions fail. Running the transactional planner unconditionally fixes those but breaks 21 formatting tests, because the transaction rewrites whole containers and is only correct as a fallback. Fixing the underlying stale-offset problem needs the CST to carry explicit trivia instead of encoding formatting as coordinates, which is not a patch-sized change. The `validate` option, the fail-fast throw and the TomlDocument rollback are all worth having; they belong on a follow-up branch that can be a minor release. The tests covering them are removed here, with the behaviour they protected kept as assertions that do not reference the option. --- CHANGELOG.md | 9 +-- src/__tests__/patch.test.ts | 115 ---------------------------- src/__tests__/toml-document.test.ts | 65 ++++------------ src/index.ts | 1 - src/patch-options.ts | 25 ------ src/patch.ts | 24 +++--- src/toml-document.ts | 28 ++----- 7 files changed, 35 insertions(+), 232 deletions(-) delete mode 100644 src/patch-options.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cf2ceb40..6d960358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- Patching: `patch()` and `TomlDocument.patch()` accept an optional `options` argument. Set - `validate: false` to skip round-trip verification of the output, which is the fastest - path but returns the unrepaired result instead of retrying or throwing ([#293]) - ### Fixed - Patching: Resolve additional patching roundtrip errors from fuzzing harness (3M seeds) ([#293]) @@ -22,8 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 that cannot parse. Aliases (`LF`, `CRLF`, `unix`, `DOS`, `\n`, `\r\n`) are accepted and anything else throws ([#293]) - Patching: `TomlDocument.patch()` now verifies its result the way `patch()` does, instead of - silently keeping TOML that does not round-trip. A document that cannot be patched is rolled - back to its pre-patch state rather than left partially modified ([#293]) + silently keeping TOML that does not round-trip ([#293]) - Patching: `TomlDocument.patch()` no longer widens TOML dates when re-applying an object read from `toJsObject`. That getter hands out plain `Date` objects, which the writer rendered as full offset date-times, so one sibling edit rewrote every date in an inline diff --git a/src/__tests__/patch.test.ts b/src/__tests__/patch.test.ts index ff59399b..5940775e 100644 --- a/src/__tests__/patch.test.ts +++ b/src/__tests__/patch.test.ts @@ -5,121 +5,6 @@ import { example } from '../__fixtures__'; import dedent from 'dedent'; import { TomlFormat } from '../toml-format'; -// This is a fault-injection test for the fallback in patch(), not a normal -// usage example. patch() validates the first output and retries with the -// transactional path when that validation fails. The getter changes the -// requested value on every read, so the retry produces a result for a -// different value. The retry must be validated too and must throw rather than -// silently return TOML that does not match the requested object. The assertion -// documents the required fail-fast behavior when validation cannot succeed. -test('retry result is validated before patch returns', () => { - let reads = 0; - const updated: Record = {}; - Object.defineProperty(updated, 'value', { - enumerable: true, - get: () => ++reads - }); - - expect(() => patch('value = 0\n', updated)).toThrow(/round-trip/i); -}); - -// `validate: false` opts out of the round-trip check in the four tests below. -// Together they pin the whole contract: the happy path is unchanged, the -// retry stops repairing, the throw stops firing, and only an explicit `false` -// turns any of it off. - -test('validate: false produces the same output on a patch that needs no repair', () => { - const original = dedent` - a = 1 - b = "two" - - [c] - d = [1, 2, 3] - ` + '\n'; - - const validated = parse(original); - validated.a = 42; - const skipped = parse(original); - skipped.a = 42; - - const expected = dedent` - a = 42 - b = "two" - - [c] - d = [1, 2, 3] - ` + '\n'; - - expect(patch(original, validated)).toBe(expected); - expect(patch(original, skipped, undefined, { validate: false })).toBe(expected); -}); - -// Structurally distilled from fuzz seed 771152, with the seed's random keys and -// values renamed: what reproduces the bug is a multiline inline array holding -// multiline strings, not the key spellings. This case only round-trips because -// validation catches the first attempt and retries transactionally, so it is the -// clearest demonstration of what opting out costs. -test('validate: false returns the unrepaired result instead of retrying', () => { - const original = dedent` - build.artifacts = [1, ''' - alpha''', 0o34, 2.5, true, [{ meta.notes.summary = """ - detail""" }, 1986-03-02]] - ` + '\n'; - - const mutate = (obj: any) => { - obj.build.artifacts[5][0].meta.notes = { width: 3, label: 'measured value' }; - return obj; - }; - - // Validated: the retry rewrites the whole multiline container, which loses the - // multiline literal string, the octal base and the dotted keys, but the result - // is valid TOML that matches the requested object. - expect(patch(original, mutate(parse(original)))).toBe( - 'build.artifacts = [1, "alpha", 28, 2.5, true, ' + - '[{meta = {notes = {width = 3, label = "measured value"}}}, 1986-03-02]]\n' - ); - - // Opted out: the original formatting survives, but the array loses its closing - // bracket and the output no longer parses. - const unrepaired = patch(original, mutate(parse(original)), undefined, { validate: false }); - expect(unrepaired).toBe(dedent` - build.artifacts = [1, ''' - alpha''', 0o34, 2.5, true, [{meta.notes.width = 3, meta.notes.label = "measured value"}, 1986-03-02] - ` + '\n'); - expect(() => parse(unrepaired)).toThrow(); -}); - -test('validate: false suppresses the round-trip throw', () => { - const makeUnsatisfiable = () => { - let reads = 0; - const updated: Record = {}; - Object.defineProperty(updated, 'value', { enumerable: true, get: () => ++reads }); - return updated; - }; - - expect(() => patch('value = 0\n', makeUnsatisfiable())).toThrow(/round-trip/i); - - // The exact number is deliberately not asserted: it depends on how many - // times patch() reads the property, which is an implementation detail. - // What matters is that opting out returns unvalidated output rather than - // throwing, even though that output disagrees with any single read. - const unvalidated = patch('value = 0\n', makeUnsatisfiable(), undefined, { validate: false }); - expect(unvalidated).toMatch(/^value = \d+\n$/); -}); - -test('validation stays on unless validate is explicitly false', () => { - const makeUnsatisfiable = () => { - let reads = 0; - const updated: Record = {}; - Object.defineProperty(updated, 'value', { enumerable: true, get: () => ++reads }); - return updated; - }; - - expect(() => patch('value = 0\n', makeUnsatisfiable(), undefined, {})).toThrow(/round-trip/i); - expect(() => patch('value = 0\n', makeUnsatisfiable(), undefined, { validate: true })).toThrow(/round-trip/i); - expect(() => patch('value = 0\n', makeUnsatisfiable(), undefined, undefined)).toThrow(/round-trip/i); -}); - test('it should apply edit to key-value', () => { const value = parse(example); value.owner.name = 'Tim Hall'; diff --git a/src/__tests__/toml-document.test.ts b/src/__tests__/toml-document.test.ts index a41cc633..804b27b1 100644 --- a/src/__tests__/toml-document.test.ts +++ b/src/__tests__/toml-document.test.ts @@ -1910,30 +1910,25 @@ describe('TomlDocument', () => { }); }); - describe('patch result validation', () => { - // Structurally distilled from fuzz seed 771152, with the seed's random keys - // and values renamed. Before validation reached TomlDocument this produced - // TOML that does not parse, and the document silently kept it. + describe('patch result verification', () => { + // Structurally distilled from fuzz seed 771152. The fine-grained writer + // leaves the enclosing array's delimiter offsets stale here and produces + // TOML that does not parse; the internal retry is what saves it. const trickyToml = dedent` build.artifacts = [1, ''' alpha''', 0o34, 2.5, true, [{ meta.notes.summary = """ detail""" }, 1986-03-02]] ` + '\n'; - const mutate = (doc: TomlDocument) => { + it('produces valid TOML where the fine-grained writer would not', () => { + const doc = new TomlDocument(trickyToml); const obj = doc.toJsObject; obj.build.artifacts[5][0].meta.notes = { width: 3, label: 'measured value' }; - return obj; - }; - - it('retries so the patched document is always valid TOML', () => { - const doc = new TomlDocument(trickyToml); - doc.patch(mutate(doc)); + doc.patch(obj); // The retry rewrites the whole multiline container, losing the multiline - // literal string and the octal base, but the result is valid TOML. The - // untouched local date keeps its original spelling rather than widening - // to an offset date-time. + // literal string and the octal base, but the result is valid TOML and the + // untouched local date keeps its original spelling. expect(doc.toTomlString).toBe( 'build.artifacts = [1, "alpha", 28, 2.5, true, ' + '[{meta = {notes = {width = 3, label = "measured value"}}}, 1986-03-02]]\n' @@ -1941,49 +1936,23 @@ describe('TomlDocument', () => { expect(() => new TomlDocument(doc.toTomlString).toJsObject).not.toThrow(); }); - it('validate: false keeps the unrepaired result', () => { - const doc = new TomlDocument(trickyToml); - doc.patch(mutate(doc), undefined, { validate: false }); - - expect(doc.toTomlString).toBe(dedent` - build.artifacts = [1, ''' - alpha''', 0o34, 2.5, true, [{meta.notes.width = 3, meta.notes.label = "measured value"}, 1986-03-02] - ` + '\n'); - expect(() => new TomlDocument(doc.toTomlString).toJsObject).toThrow(); - }); - - // The getter changes the requested value on every read, so no output can - // ever round-trip. patchCst() mutates CST nodes as it goes, so without an - // explicit rollback the document would be left holding a tree that no - // longer agrees with its own TOML string. - const unsatisfiable = () => { + // The getter changes the requested value on every read, so no output can ever + // round-trip. Neither attempt can satisfy it, and the document commits the + // fine-grained result rather than throwing, which is what earlier versions + // produced. The point is that it stays usable afterwards. + it('stays usable when no attempt can round-trip', () => { + const doc = new TomlDocument('value = 0\n'); let reads = 0; const updated: Record = {}; Object.defineProperty(updated, 'value', { enumerable: true, get: () => ++reads }); - return updated; - }; - - it('rolls the document back when no attempt round-trips', () => { - const original = 'value = 0\n'; - const doc = new TomlDocument(original); - expect(() => doc.patch(unsatisfiable())).toThrow(/round-trip/i); - - // Unchanged, still readable, and still patchable afterwards. - expect(doc.toTomlString).toBe(original); - expect(doc.toJsObject).toEqual({ value: 0 }); + expect(() => doc.patch(updated)).not.toThrow(); + expect(doc.toTomlString).toMatch(/^value = \d+\n$/); const obj = doc.toJsObject; obj.value = 7; doc.patch(obj); expect(doc.toTomlString).toBe('value = 7\n'); - expect(doc.toJsObject).toEqual({ value: 7 }); - }); - - it('validate: false suppresses the rollback and the throw', () => { - const doc = new TomlDocument('value = 0\n'); - doc.patch(unsatisfiable(), undefined, { validate: false }); - expect(doc.toTomlString).toMatch(/^value = \d+\n$/); }); // TOML has one integer type, so a document read as bigint accepts a plain diff --git a/src/index.ts b/src/index.ts index f8578336..143b222a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,6 @@ import { decodeUtf8Bytes, stripLeadingBom, UTF8_BOM } from './decode-utf8'; import { TomlDocument } from './toml-document'; export type { IntegersAsBigInt, ParseOptions } from './parse-options'; -export type { PatchOptions } from './patch-options'; /** * Parses a TOML string or raw UTF-8 bytes into a JavaScript object. diff --git a/src/patch-options.ts b/src/patch-options.ts deleted file mode 100644 index cac490b6..00000000 --- a/src/patch-options.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface PatchOptions { - /** - * Controls whether `patch()` verifies its own output before returning it. - * - * When true (the default), `patch()` re-parses the TOML it produced and - * checks that it round-trips back to the `updated` object. If it does not, - * `patch()` retries with a coarser writer that rewrites whole multiline - * inline containers instead of splicing individual members, and throws if - * that retry still does not round-trip. This guarantees `patch()` never - * silently returns TOML that disagrees with the object it was given. - * - * The check costs one extra parse of the output plus a structural - * comparison, which is roughly 20-40% of total `patch()` time (the - * proportion is higher for small documents, where it does not amortise). - * - * Set to false to skip verification and return the first result directly. - * The retry and the throw both become unreachable, so a patch that would - * have been repaired instead returns malformed or mismatched TOML. Only - * worth doing when patching is measurably hot and the inputs are known to - * be well covered by tests. - * - * Default: true. - */ - validate?: boolean; -} diff --git a/src/patch.ts b/src/patch.ts index 1c58c9bf..679ea59f 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -53,7 +53,6 @@ import { } from './comment-alignment'; import { getSpan } from './location'; import { stripLeadingBom, UTF8_BOM } from './decode-utf8'; -import type { PatchOptions } from './patch-options'; import { hasTemporal, patchResultMatches } from './patch-validate'; import traverse from './traverse'; @@ -65,20 +64,18 @@ import traverse from './traverse'; * and updated data, then strategically applies only the necessary changes to maintain the * original document structure as much as possible. * - * By default the result is verified: `patch()` re-parses its own output and - * checks that it round-trips back to `updated`, retrying with a coarser writer - * and ultimately throwing rather than returning TOML that disagrees with the - * requested object. Pass `{ validate: false }` to skip that check when - * patching is measurably hot; see {@link PatchOptions.validate}. + * The result is verified internally: the output is re-parsed and checked against + * `updated`, and if it does not round-trip the patch is retried with a coarser + * writer that rewrites whole multiline inline containers. A patch that neither + * attempt can satisfy returns the fine-grained result, so this never fails where + * earlier versions succeeded. * * @param existing - The original TOML document as a string * @param updated - The updated JavaScript object with desired changes * @param format - Optional formatting options to apply to new or modified sections - * @param options - Optional patch options - * @param options.validate - Verify the output round-trips to `updated`. Default: true. * @returns A new TOML string with the changes applied */ -export default function patch(existing: string, updated: any, format?: Partial | TomlFormat, options?: PatchOptions): string { +export default function patch(existing: string, updated: any, format?: Partial | TomlFormat): string { // The tokenizer flags mixed line endings as it scans, so this requires no // separate pass over the input. const newlineState = createNewlineScanState(); @@ -99,9 +96,6 @@ export default function patch(existing: string, updated: any, format?: Partial (fmt.leadingBom ? `${UTF8_BOM}${toml}` : toml); - // Opting out returns the first attempt as-is, so nothing below runs. - if (options?.validate === false) return withBom(patchedToml); - // Detected once and threaded into both comparisons; patchCst() derives its // own copy internally for the diff. const comparison = { temporal: hasTemporal(updated) }; @@ -109,9 +103,9 @@ export default function patch(existing: string, updated: any, format?: Partial | TomlFormat, options?: PatchOptions) : void { + patch(updatedObject: any, format?: Partial | TomlFormat) : void { const fmt = resolveTomlFormat(format, this._format); @@ -121,11 +116,6 @@ export class TomlDocument { this._currentTomlString = tomlString; }; - if (options?.validate === false) { - commit(first.tomlString, first.document.items); - return; - } - // The comparison has to read values back the way this document produces // them, or a correct patch looks like a mismatch: integersAsBigInt is the // document's own setting, and temporal follows the updated object exactly @@ -151,12 +141,10 @@ export class TomlDocument { return; } - // Neither attempt round-trips. Both mutated CST nodes on their way through, - // so restore from the pre-patch source rather than leaving the document - // holding a tree that no longer matches its own TOML string. - this._cst = Array.from(parseTOML(sourceBefore)); - this._currentTomlString = sourceBefore; - throw new Error('Patch retry failed round-trip validation'); + // Neither attempt round-trips. Commit the fine-grained result, which is what + // earlier versions produced, re-deriving the tree from it so the document's + // CST and its TOML string stay in agreement. + commit(first.tomlString, Array.from(parseTOML(first.tomlString))); } /** From a39718ed40d13eb48fbd8f4582cecb6e73eb2e9e Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:52:41 -0400 Subject: [PATCH 32/36] perf(patch): only verify a patch when the retry could change it Verification ran on every call, and removing the `validate` option took away the only way to avoid it. Measured against origin/latest that was about 1.35x on a small config and 1.19x on a 45KB document, paid unconditionally. The retry only differs from the first attempt when the transactional planner finds a multiline inline container that holds a multiline string and carries no comment. Without such a container the retry reproduces the first attempt byte for byte, verification cannot change what is returned, and the whole re-parse and structural comparison is dead work. Gated on exactly that, in two steps: an indexOf for a multiline delimiter, then a walk of the CST that is already parsed. Both entry points decide before patchCst() runs, since it mutates the nodes the scan needs to see -- computing it afterwards reads a collapsed string and silently skips verification, which cost 5 seed regressions on the first attempt. This is not a trade against correctness. Where the gate skips, the output is identical by construction, which is only true because an unsatisfiable patch now returns the fine-grained result rather than throwing. Against origin/latest: small config, no multiline string 1.00x small config, multiline string outside inline containers 0.99x small config, multiline string inside one (verifies) 1.38x big doc 45KB, no multiline string 0.98x / 1.01x So baseline everywhere except documents that actually contain the construct these fixes exist for. --- src/patch-validate.ts | 14 ++++++++++++++ src/patch.ts | 42 +++++++++++++++++++++++++++++++++++++++++- src/toml-document.ts | 12 ++++++++++-- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/patch-validate.ts b/src/patch-validate.ts index 68ed06c6..f63e3248 100644 --- a/src/patch-validate.ts +++ b/src/patch-validate.ts @@ -20,6 +20,20 @@ export interface PatchComparison { integersAsBigInt?: IntegersAsBigInt; } +/** + * Whether verifying a patch of this source could change its outcome. + * + * The retry only differs from the first attempt when the transactional planner + * finds a multiline inline container holding a multiline string. A source with no + * multiline string delimiter cannot contain one, so the retry reproduces the first + * attempt byte for byte and the fine-grained result is returned either way. + * Skipping the check there is therefore not a trade: the output is identical, and + * a re-parse plus a full structural comparison is avoided on every such call. + */ +export function patchNeedsVerification(existing: string): boolean { + return existing.indexOf('"""') !== -1 || existing.indexOf("'''") !== -1; +} + /** * Recursively checks if an object graph contains any Temporal values. * Used to auto-detect whether temporal mode should be enabled for patching, diff --git a/src/patch.ts b/src/patch.ts index 679ea59f..47df0aac 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -53,7 +53,7 @@ import { } from './comment-alignment'; import { getSpan } from './location'; import { stripLeadingBom, UTF8_BOM } from './decode-utf8'; -import { hasTemporal, patchResultMatches } from './patch-validate'; +import { hasTemporal, patchNeedsVerification, patchResultMatches } from './patch-validate'; import traverse from './traverse'; /** @@ -93,9 +93,17 @@ export default function patch(existing: string, updated: any, format?: Partial (fmt.leadingBom ? `${UTF8_BOM}${toml}` : toml); + if (!needsVerification) return withBom(patchedToml); + // Detected once and threaded into both comparisons; patchCst() derives its // own copy internally for the diff. const comparison = { temporal: hasTemporal(updated) }; @@ -257,6 +265,38 @@ function normalizeAotEntryComments(doc: Document): void { } } +/** + * Sound over-approximation of "the transactional retry could change the output". + * + * The planner only produces a transaction for a multiline inline container that + * holds a multiline string and carries no comment. If the document has no such + * container, the retry reproduces the first attempt exactly, so verifying it + * cannot change what patch() returns. One walk of a CST that is already parsed is + * far cheaper than the re-parse and structural comparison it avoids. + */ +export function hasTransactionCandidate(cst: CST): boolean { + let found = false; + const spansLines = (node: TreeNode) => node.loc.end.line > node.loc.start.line; + + const scan = (node: TreeNode, insideMultilineContainer: boolean): void => { + if (found) return; + if (isString(node) && spansLines(node) && insideMultilineContainer) { + found = true; + return; + } + const nested = insideMultilineContainer || + ((isInlineTable(node) || isInlineArray(node)) && spansLines(node)); + if (isKeyValue(node)) scan(node.value, nested); + else if (isInlineItem(node)) scan(node.item, nested); + else if (hasItems(node)) { + for (const item of node.items as TreeNode[]) scan(item, nested); + } + }; + + for (const block of cst) scan(block as TreeNode, false); + return found; +} + export function patchCst(existing_cst: CST, updated: any, format: TomlFormat, useMultilineTransactions = false): { tomlString: string; document: Document } { const items = [...existing_cst]; diff --git a/src/toml-document.ts b/src/toml-document.ts index db5db864..a2a67308 100644 --- a/src/toml-document.ts +++ b/src/toml-document.ts @@ -2,8 +2,8 @@ import parseTOML, { continueParsingTOML } from './parse-toml'; import toJS from './to-js'; import { TomlFormat } from './toml-format'; import { Block } from './cst'; -import { patchCst } from './patch'; -import { hasTemporal, patchResultMatches } from './patch-validate'; +import { patchCst, hasTransactionCandidate } from './patch'; +import { hasTemporal, patchNeedsVerification, patchResultMatches } from './patch-validate'; import { detectNewline, resolveTomlFormat } from './toml-format'; import { truncateCst } from './truncate'; import type { ParseOptions, IntegersAsBigInt } from './parse-options'; @@ -93,6 +93,9 @@ export class TomlDocument { // once the call below returns, whether or not the result is usable. Keeping // the pre-patch source lets every later path re-derive a clean CST from it. const sourceBefore = this._currentTomlString; + // Decided before patchCst() runs, because it mutates these nodes. The cheap + // string scan short-circuits before the CST walk. + const needsVerification = patchNeedsVerification(sourceBefore) && hasTransactionCandidate(this._cst); // Only worth deriving the pre-patch values when a stripped date could // actually be present; temporal mode hands out Temporal objects untouched. @@ -116,6 +119,11 @@ export class TomlDocument { this._currentTomlString = tomlString; }; + if (!needsVerification) { + commit(first.tomlString, first.document.items); + return; + } + // The comparison has to read values back the way this document produces // them, or a correct patch looks like a mismatch: integersAsBigInt is the // document's own setting, and temporal follows the updated object exactly From e761468b3c7bb0236ab2bee1f7a06bf9aefa7c86 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:05:05 -0400 Subject: [PATCH 33/36] docs: name the verification pre-filter for what it checks patchNeedsVerification() only looked for a multiline string delimiter anywhere in the source, but its name and comment claimed it decided whether verification was needed. It does not: it is one half of hasMultilineStringDelimiter(source) && hasTransactionCandidate(cst) where the second half is what checks the condition that matters, a multiline string inside a multiline inline container. Renamed to say what it looks at, and its comment now points at the function that makes the decision. The comment also records why the loose form is the safe one. It is sound as a pre-filter because TOML has no other way to spell a string spanning lines: a raw newline is rejected inside single- and double-quoted strings, and a line-ending backslash is only legal within `"""`. A false positive costs one tree walk; a false negative would silently skip verification. hasTransactionCandidate() now also states that it deliberately ignores two of the planner's conditions (containers holding a comment, and whether a change lands inside one), since erring wide only wastes a verification while erring narrow would skip a needed one. --- src/patch-validate.ts | 23 +++++++++++++++-------- src/patch.ts | 10 ++++++++-- src/toml-document.ts | 4 ++-- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/patch-validate.ts b/src/patch-validate.ts index f63e3248..373a0cb4 100644 --- a/src/patch-validate.ts +++ b/src/patch-validate.ts @@ -21,16 +21,23 @@ export interface PatchComparison { } /** - * Whether verifying a patch of this source could change its outcome. + * Whether the source contains a multiline string delimiter anywhere. * - * The retry only differs from the first attempt when the transactional planner - * finds a multiline inline container holding a multiline string. A source with no - * multiline string delimiter cannot contain one, so the retry reproduces the first - * attempt byte for byte and the fine-grained result is returned either way. - * Skipping the check there is therefore not a trade: the output is identical, and - * a re-parse plus a full structural comparison is avoided on every such call. + * This is only a cheap pre-filter, not the decision: it says nothing about where + * the delimiter sits. `hasTransactionCandidate()` in patch.ts is what checks the + * condition that actually matters, namely a multiline string inside a multiline + * inline container, and the two are used together. This runs first because it is + * two indexOf calls against a string that is already in hand, and it rules out + * most documents before anything walks the tree. + * + * Sound as a pre-filter because TOML has no other way to spell a string that + * spans lines: a raw newline is rejected inside single-quoted and double-quoted + * strings, and a line-ending backslash is only legal within `"""` delimiters. So a + * String node whose span crosses lines implies one of these delimiters is present. + * False positives are fine and cost only the tree walk; a false negative would + * silently skip verification, which is why the check is stated this loosely. */ -export function patchNeedsVerification(existing: string): boolean { +export function hasMultilineStringDelimiter(existing: string): boolean { return existing.indexOf('"""') !== -1 || existing.indexOf("'''") !== -1; } diff --git a/src/patch.ts b/src/patch.ts index 47df0aac..78a221d5 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -53,7 +53,7 @@ import { } from './comment-alignment'; import { getSpan } from './location'; import { stripLeadingBom, UTF8_BOM } from './decode-utf8'; -import { hasTemporal, patchNeedsVerification, patchResultMatches } from './patch-validate'; +import { hasTemporal, hasMultilineStringDelimiter, patchResultMatches } from './patch-validate'; import traverse from './traverse'; /** @@ -97,7 +97,7 @@ export default function patch(existing: string, updated: any, format?: Partial (fmt.leadingBom ? `${UTF8_BOM}${toml}` : toml); @@ -273,6 +273,12 @@ function normalizeAotEntryComments(doc: Document): void { * container, the retry reproduces the first attempt exactly, so verifying it * cannot change what patch() returns. One walk of a CST that is already parsed is * far cheaper than the re-parse and structural comparison it avoids. + * + * Deliberately looser than the planner in two ways: it does not exclude containers + * holding a comment, and it does not check that a change actually lands inside one. + * Both would narrow it further, and both are easy to get subtly wrong; erring wide + * only costs a verification that turns out to be unnecessary, whereas erring narrow + * would skip one that was needed. */ export function hasTransactionCandidate(cst: CST): boolean { let found = false; diff --git a/src/toml-document.ts b/src/toml-document.ts index a2a67308..0ce48cc7 100644 --- a/src/toml-document.ts +++ b/src/toml-document.ts @@ -3,7 +3,7 @@ import toJS from './to-js'; import { TomlFormat } from './toml-format'; import { Block } from './cst'; import { patchCst, hasTransactionCandidate } from './patch'; -import { hasTemporal, patchNeedsVerification, patchResultMatches } from './patch-validate'; +import { hasTemporal, hasMultilineStringDelimiter, patchResultMatches } from './patch-validate'; import { detectNewline, resolveTomlFormat } from './toml-format'; import { truncateCst } from './truncate'; import type { ParseOptions, IntegersAsBigInt } from './parse-options'; @@ -95,7 +95,7 @@ export class TomlDocument { const sourceBefore = this._currentTomlString; // Decided before patchCst() runs, because it mutates these nodes. The cheap // string scan short-circuits before the CST walk. - const needsVerification = patchNeedsVerification(sourceBefore) && hasTransactionCandidate(this._cst); + const needsVerification = hasMultilineStringDelimiter(sourceBefore) && hasTransactionCandidate(this._cst); // Only worth deriving the pre-patch values when a stripped date could // actually be present; temporal mode hands out Temporal objects untouched. From 64c11db7f2a4f010c9ec1c9564ee7b838a49aa06 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:14:03 -0400 Subject: [PATCH 34/36] test: cover multiline delimiters appearing in content A `"""` or `'''` can sit inside a basic string or a comment without the document containing any multiline string. hasMultilineStringDelimiter() cannot tell the difference and lets those through, and hasTransactionCandidate() then finds no multiline inline container, so verification is skipped. That is the harmless direction -- a false positive costs one walk of an already-parsed CST, measured at 0.98x against origin/latest on a 45KB document, which is inside the noise. But nothing pinned the part that actually matters, which is that the output is correct either way. Three cases now do: a basic string holding three apostrophes, a comment holding three apostrophes, and a multiline literal string holding the two consecutive apostrophes TOML permits, which is genuinely multiline yet still not a candidate because it sits at the top level rather than inside an inline container. --- src/__tests__/patch.test.ts | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/__tests__/patch.test.ts b/src/__tests__/patch.test.ts index 5940775e..ed483116 100644 --- a/src/__tests__/patch.test.ts +++ b/src/__tests__/patch.test.ts @@ -5,6 +5,64 @@ import { example } from '../__fixtures__'; import dedent from 'dedent'; import { TomlFormat } from '../toml-format'; +// A `"""` or `'''` can appear in content -- inside a basic string, or in a +// comment -- without the document containing any multiline string at all. The +// cheap pre-filter in hasMultilineStringDelimiter() cannot tell the difference and +// lets these through; hasTransactionCandidate() then finds no multiline inline +// container and verification is skipped. That is the harmless direction, but only +// because the output is right either way, which is what these pin. +describe('multiline delimiters appearing in content', () => { + test('a basic string containing three apostrophes patches normally', () => { + const original = dedent` + note = "contains ''' inside" + port = 8080 + ` + '\n'; + + const updated = parse(original); + updated.port = 9090; + + expect(patch(original, updated)).toBe(dedent` + note = "contains ''' inside" + port = 9090 + ` + '\n'); + }); + + test('a comment containing three apostrophes patches normally', () => { + const original = dedent` + # see ''' for the quoting rules + port = 8080 + ` + '\n'; + + const updated = parse(original); + updated.port = 9090; + + expect(patch(original, updated)).toBe(dedent` + # see ''' for the quoting rules + port = 9090 + ` + '\n'); + }); + + // A multiline literal string may hold up to two consecutive apostrophes. This + // one is genuinely multiline, but sits at the top level rather than inside an + // inline container, so it is still not a transaction candidate. + test('a multiline literal string holding two apostrophes patches normally', () => { + const original = dedent` + note = ''' + it can hold '' safely''' + port = 8080 + ` + '\n'; + + const updated = parse(original); + updated.port = 9090; + + expect(patch(original, updated)).toBe(dedent` + note = ''' + it can hold '' safely''' + port = 9090 + ` + '\n'); + }); +}); + test('it should apply edit to key-value', () => { const value = parse(example); value.owner.name = 'Tim Hall'; From caefc6d1c95fee64192a40401413e3a75a53f66e Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:28:40 -0400 Subject: [PATCH 35/36] test: cover three-quote delimiters and record a literal string parse bug Adds the `"""` counterparts to the delimiter-in-content cases: a comment holding three quotes, a multiline literal string holding three quotes at the top level (pre-filter passes, no transaction candidate), and the same inside a multiline inline table, which is a candidate and so exercises the full verification path. Writing those turned up a parse bug. A single-line literal string cannot hold an odd number of double quotes: a = '"' throws a = '""' ok a = '"""' throws a = '""""' ok Literal strings have no escaping, so everything between the delimiters is taken verbatim and all of these are valid TOML. The alternating pass/fail says the tokenizer is pairing double quotes while scanning a context where they carry no meaning. Multiline literal strings are unaffected, so it is specific to the single-quote scanner. It predates this work, reproduces on origin/latest and on the published 3.0.3, and the official toml-test suite passes, so that suite does not cover it. Recorded as test.fails with the two neighbouring cases that do pass, so the boundary is written down rather than rediscovered. --- src/__tests__/parse.test.ts | 25 +++++++++++++++ src/__tests__/patch.test.ts | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/src/__tests__/parse.test.ts b/src/__tests__/parse.test.ts index 335575ec..844d7567 100644 --- a/src/__tests__/parse.test.ts +++ b/src/__tests__/parse.test.ts @@ -207,3 +207,28 @@ k33 = 4597 c: [{ b: 2 }] }); }); + +// BUG: a single-line literal string cannot hold an odd number of double quotes. +// Literal strings have no escaping at all -- everything between the delimiters is +// taken verbatim -- so `'has " inside'` is valid TOML and should parse. It does +// not: an odd count fails while an even count succeeds, which means the tokenizer +// is pairing double quotes in a context where they carry no meaning. +// +// a = '"' -> throws a = '""' -> ok +// a = '"""' -> throws a = '""""' -> ok +// +// Multiline literal strings are unaffected, so this is specific to the +// single-quote scanner. Predates the current work and is reproducible on 3.0.3. +// The official toml-test suite passes, so it does not cover this case. +test.fails('a single-line literal string holds an odd number of double quotes', () => { + expect(parse(`a = '"'\n`)).toEqual({ a: '"' }); +}); + +test('a single-line literal string holds an even number of double quotes', () => { + expect(parse(`a = '""'\n`)).toEqual({ a: '""' }); +}); + +test('a multiline literal string holds double quotes at any count', () => { + expect(parse(`a = '''"'''\n`)).toEqual({ a: '"' }); + expect(parse(`a = '''"""'''\n`)).toEqual({ a: '"""' }); +}); diff --git a/src/__tests__/patch.test.ts b/src/__tests__/patch.test.ts index ed483116..3bfc35b4 100644 --- a/src/__tests__/patch.test.ts +++ b/src/__tests__/patch.test.ts @@ -42,6 +42,67 @@ describe('multiline delimiters appearing in content', () => { ` + '\n'); }); + test('a comment containing three quotes patches normally', () => { + const original = dedent` + # see """ for the quoting rules + port = 8080 + ` + '\n'; + + const updated = parse(original); + updated.port = 9090; + + expect(patch(original, updated)).toBe(dedent` + # see """ for the quoting rules + port = 9090 + ` + '\n'); + }); + + // A multiline literal string may hold three double quotes verbatim, so this + // trips the `"""` half of the pre-filter. It is genuinely multiline, but sits + // at the top level rather than inside an inline container, so it is still not + // a transaction candidate. + test('a multiline literal string holding three quotes patches normally', () => { + const original = dedent` + note = ''' + holds """ fine''' + port = 8080 + ` + '\n'; + + const updated = parse(original); + updated.port = 9090; + + expect(patch(original, updated)).toBe(dedent` + note = ''' + holds """ fine''' + port = 9090 + ` + '\n'); + }); + + // Same three quotes inside a multiline inline container, which IS a transaction + // candidate, so this one runs the full verification path. + test('a multiline literal string holding three quotes inside an inline table', () => { + const original = dedent` + cfg = { + note = ''' + holds """ fine''', + retries = 2, + } + port = 8080 + ` + '\n'; + + const updated = parse(original); + updated.cfg.retries = 3; + + expect(patch(original, updated)).toBe(dedent` + cfg = { + note = ''' + holds """ fine''', + retries = 3, + } + port = 8080 + ` + '\n'); + }); + // A multiline literal string may hold up to two consecutive apostrophes. This // one is genuinely multiline, but sits at the top level rather than inside an // inline container, so it is still not a transaction candidate. From 2714afa0fcfe604f97172c3f05a8287016b7e275 Mon Sep 17 00:00:00 2001 From: Martin Leduc <31558169+DecimalTurn@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:41:04 -0400 Subject: [PATCH 36/36] docs: simplify changelog --- CHANGELOG.md | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d960358..1fe39790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,20 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Patching: Resolve additional patching roundtrip errors from fuzzing harness (3M seeds) ([#293]) -- Stringify: `newLine` is now normalized and validated wherever a format is resolved, not - only in `patch()`. `stringify(value, { newLine: 'LF' })` wrote the literal text `LF` - between lines, and `{ newLine: '\r' }` or `{ newLine: '' }` silently produced TOML - that cannot parse. Aliases (`LF`, `CRLF`, `unix`, `DOS`, `\n`, `\r\n`) are - accepted and anything else throws ([#293]) -- Patching: `TomlDocument.patch()` now verifies its result the way `patch()` does, instead of - silently keeping TOML that does not round-trip ([#293]) -- Patching: `TomlDocument.patch()` no longer widens TOML dates when re-applying an object - read from `toJsObject`. That getter hands out plain `Date` objects, which the writer - rendered as full offset date-times, so one sibling edit rewrote every date in an inline - array (`1986-03-02` became `1986-03-02T00:00:00.000Z`). Dates the caller left alone keep - their original spelling; a date whose instant changed still widens, and the exported - `LocalDate` / `LocalTime` / `LocalDateTime` / `OffsetDateTime` classes state the intended - representation ([#293]) +- Stringify: `newLine` is now normalized and validated wherever a format is resolved ([#293]) +- Patching: `TomlDocument.patch()` no longer widens TOML dates when re-applying an object read from `toJsObject` ([#293]) ## [3.0.3] - 2026-08-21