Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
8c07262
test: add TODO for preserving original formatting of inline table in …
DecimalTurn Aug 23, 2026
2237459
docs: add fuzz error seeds report for rerun results
DecimalTurn Aug 22, 2026
82d6bca
test: add fuzz regression for seed 2591153
DecimalTurn Aug 22, 2026
bc3e844
fix: harden multiline patch planning
DecimalTurn Aug 22, 2026
2050cb3
test: add distilled fuzz regressions
DecimalTurn Aug 22, 2026
d5d6381
fix: validate distilled fuzz regressions
DecimalTurn Aug 22, 2026
d2ca21d
docs: add sweep notes for 0-3000000
DecimalTurn Aug 22, 2026
3db85f4
test: consolidate distilled fuzz regressions
DecimalTurn Aug 23, 2026
cd2e342
fix: preserve formatting before transactional retry
DecimalTurn Aug 23, 2026
363407f
fix: make transaction debug logging browser-safe
DecimalTurn Aug 23, 2026
4ae4f25
test: cover unvalidated patch retry
DecimalTurn Aug 23, 2026
a32cf0b
fix: validate patch retry results
DecimalTurn Aug 23, 2026
09c6f4e
style: remove trailing whitespace
DecimalTurn Aug 23, 2026
9976c08
docs: fix inline table TODO wording
DecimalTurn Aug 23, 2026
dfadf5f
docs: clarify fuzz output TODO
DecimalTurn Aug 23, 2026
ac9a959
docs: update changelog
DecimalTurn Aug 23, 2026
7f7bb0a
fix: validate patch newline formats
DecimalTurn Aug 23, 2026
236922b
test: expand JavaScript patch coverage
DecimalTurn Aug 23, 2026
e5f40a6
feat(patch): add validate option to opt out of round-trip verification
DecimalTurn Aug 24, 2026
c3cafac
fix(toml-document): verify patch results and roll back on failure
DecimalTurn Aug 24, 2026
2c7e283
refactor(patch): derive transaction change count from the change list
DecimalTurn Aug 24, 2026
81b9e3a
docs(patch): restore comments dropped from coalesceStructuralReplacem…
DecimalTurn Aug 24, 2026
3a43e22
docs: repair unresolvable commit references in the sweep notes
DecimalTurn Aug 24, 2026
5163dec
fix(toml-format): normalize newLine wherever a format is resolved
DecimalTurn Aug 24, 2026
d40023b
test: use readable keys in the validation regressions
DecimalTurn Aug 24, 2026
14ca7da
test(fuzz): assert exact output for the distilled regressions
DecimalTurn Aug 24, 2026
f1eba3c
fix(toml-document): keep TOML date representations through a patch
DecimalTurn Aug 24, 2026
0eac6ba
test: guard historical fuzz seeds
DecimalTurn Aug 24, 2026
1ae6b86
test: clarify and improve nested array with inline table
DecimalTurn Aug 24, 2026
082e639
test(fuzz): distill the seed regressions into readable cases
DecimalTurn Aug 24, 2026
9d0061e
refactor: keep patch verification internal for a patch release
DecimalTurn Aug 24, 2026
a39718e
perf(patch): only verify a patch when the retry could change it
DecimalTurn Aug 24, 2026
e761468
docs: name the verification pre-filter for what it checks
DecimalTurn Aug 24, 2026
64c11db
test: cover multiline delimiters appearing in content
DecimalTurn Aug 24, 2026
caefc6d
test: cover three-quote delimiters and record a literal string parse bug
DecimalTurn Aug 24, 2026
2714afa
docs: simplify changelog
DecimalTurn Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ 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])
- 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

### Fixed
Expand Down Expand Up @@ -399,4 +405,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
34 changes: 34 additions & 0 deletions docs/bug-notes/fuzz-error-seeds-0-3000000-rerun.md
Original file line number Diff line number Diff line change
@@ -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<nemN,8.s2.+)3k/.6` |
| 2667551 | 2000000..2999999 | `roundtrip-mismatch` | Re-parse failed at `(76, 1)` |
| 2824408 | 2000000..2999999 | `roundtrip-mismatch` | Re-parse failed at `(59, 10)` |
| 2858114 | 2000000..2999999 | `roundtrip-mismatch` | Re-parse failed at `(8, 2)` |

## Summary

- `roundtrip-mismatch`: 15
- `patch-fail`: 1
- Total failures: 16
50 changes: 50 additions & 0 deletions docs/bug-notes/fuzz-sweep-0-3000000-roundtrip-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 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.fuzz.test.ts`.

## 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 `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
`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
`fix: harden multiline patch planning`.

## Distillation

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.

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 passes.
256 changes: 256 additions & 0 deletions scripts/distill-seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

interface Mutation {
kind: string;
path: (string | number)[];
newValue?: unknown;
}

function arg(name: string, fallback?: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 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<string>();
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<string, unknown> = {};
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<string, unknown>)
.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)};`;
}

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);
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, ${formatSource(format)});`,
' 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('.')}`) }));
Loading
Loading