From b8a763b9d672a3324b01d20bf833ede560014822 Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Mon, 20 Jul 2026 15:27:25 +0200 Subject: [PATCH 1/3] fix: prune position-0 checkpoints on eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A checkpoint taken at position 0 anchors to the START sentinel, which pruneCheckpoints skipped unconditionally — so limit eviction left it alive even though the state it named was no longer reachable, and revertTo silently landed on a different state instead of throwing. Prune START-anchored checkpoints whenever entries are evicted from the front. A dropped redo branch still leaves them intact: position 0 remains reachable there. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 7 ++++--- README.md | 5 +++-- src/core.ts | 17 ++++++++--------- tests/checkpoint.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a393822..f5768df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,9 +22,10 @@ ESM-only, Node ≥22. Toolchain is Vite+ (`vp`), not plain npm: `do`/`undo`/rollback (`assertIdle`). - **Notify only on real change.** A failed `commit`, an empty `transaction`, and a no-op `undo`/`redo`/`jump` notify _nobody_. -- **Checkpoints anchor to entries.** Pruned when their entry is evicted (`limit` - overflow) or dropped (redo branch truncated by a fresh commit); `revertTo` - throws on an unknown/pruned name. +- **Checkpoints anchor to entries.** Pruned when their state becomes + unreachable: their entry is evicted (`limit` overflow) or dropped (redo branch + truncated by a fresh commit), and a position-0 checkpoint on any eviction; + `revertTo` throws on an unknown/pruned name. - **Rollback is reverse-order and best-effort.** A throwing inverse stops rollback and surfaces. diff --git a/README.md b/README.md index adc0035..6045620 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,9 @@ history.subscribe((meta) => { /* ... */ }); // returns unsubscribe With `limit`, the oldest entries are evicted past the cap. Checkpoints anchor to the entry they sit after, so they stay correct as eviction renumbers positions; a -checkpoint whose anchor is evicted (or dropped when a commit clears a redo branch) -is pruned, and `revertTo` on it throws. +checkpoint whose state becomes unreachable — its anchor evicted, dropped when a +commit clears a redo branch, or (for a position-0 checkpoint) any eviction at +all — is pruned, and `revertTo` on it throws. ## Non-goals diff --git a/src/core.ts b/src/core.ts index 5161aea..132833f 100644 --- a/src/core.ts +++ b/src/core.ts @@ -65,27 +65,26 @@ export function createHistory(options: HistoryOptions = {}): History { for (const listener of listeners) listener(meta); }; - const pruneCheckpoints = (removed: Entry[]): void => { - if (checkpoints.size === 0 || removed.length === 0) return; - const removedSet = new Set(removed); + /** Drop every checkpoint whose anchor is in `dead` — its state is unreachable. */ + const pruneCheckpoints = (dead: ReadonlySet): void => { for (const [name, anchor] of checkpoints) { - if (anchor !== START && removedSet.has(anchor)) { - checkpoints.delete(name); - } + if (dead.has(anchor)) checkpoints.delete(name); } }; const append = (entry: Entry): void => { // A fresh commit drops the redo branch (entries right of the cursor). if (cursor < entries.length) { - pruneCheckpoints(entries.splice(cursor)); + pruneCheckpoints(new Set(entries.splice(cursor))); } entries.push(entry); cursor += 1; - // Evict the oldest entries past the cap. + // Evict the oldest entries past the cap. Eviction also kills START: the + // undos that reached position 0 are gone, so the state it named is + // unreachable — unlike a redo-branch drop, which leaves it intact. if (limit !== undefined && entries.length > limit) { const overflow = entries.length - limit; - pruneCheckpoints(entries.splice(0, overflow)); + pruneCheckpoints(new Set(entries.splice(0, overflow)).add(START)); cursor -= overflow; } }; diff --git a/tests/checkpoint.test.ts b/tests/checkpoint.test.ts index 00e18ef..06519be 100644 --- a/tests/checkpoint.test.ts +++ b/tests/checkpoint.test.ts @@ -89,6 +89,29 @@ describe("checkpoints", () => { expect(() => history.revertTo("first")).toThrow(); }); + test("a position-0 checkpoint is pruned when eviction makes its state unreachable", () => { + const history = createHistory({ limit: 2 }); + history.checkpoint("pristine"); // position 0, before any entry + history.commit(noop); + history.commit(noop); + history.commit(noop); // evicts entry 0 -> position 0 no longer names the pristine state + + expect(history.hasCheckpoint("pristine")).toBe(false); + expect(() => history.revertTo("pristine")).toThrow(); + }); + + test("a position-0 checkpoint survives a dropped redo branch", () => { + const history = createHistory(); + history.checkpoint("pristine"); + history.commit(noop); + history.undo(); + history.commit(noop); // drops the redo branch; position 0 is still reachable + + expect(history.hasCheckpoint("pristine")).toBe(true); + history.revertTo("pristine"); + expect(history.position).toBe(0); + }); + test("a checkpoint in a dropped redo branch is pruned", () => { const history = createHistory(); history.commit(noop); // e1 From 7368520d2b6a1e95cca392ff29d141cd362693fe Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Mon, 20 Jul 2026 15:46:14 +0200 Subject: [PATCH 2/3] refactor: simplify checkpoint pruning after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pruneCheckpoints takes a plain readonly Anchor[] instead of a ReadonlySet: the redo-drop call site returns to its original shape, the eviction site is one spread, and the types check by ordinary array covariance rather than method bivariance. Reword the pruning docs to state the real rule — pruning is by anchor identity, not state reachability, which differ for a checkpoint anchored to the newest evicted entry — and note in revertTo the invariant that keeps indexOf from returning -1. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 7 +++---- README.md | 9 +++++---- src/core.ts | 15 ++++++++------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f5768df..b05239f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,10 +22,9 @@ ESM-only, Node ≥22. Toolchain is Vite+ (`vp`), not plain npm: `do`/`undo`/rollback (`assertIdle`). - **Notify only on real change.** A failed `commit`, an empty `transaction`, and a no-op `undo`/`redo`/`jump` notify _nobody_. -- **Checkpoints anchor to entries.** Pruned when their state becomes - unreachable: their entry is evicted (`limit` overflow) or dropped (redo branch - truncated by a fresh commit), and a position-0 checkpoint on any eviction; - `revertTo` throws on an unknown/pruned name. +- **Checkpoints anchor to entries.** Pruned when their entry is evicted (`limit` + overflow) or dropped (redo branch truncated by a fresh commit); eviction also + prunes position-0 checkpoints. `revertTo` throws on an unknown/pruned name. - **Rollback is reverse-order and best-effort.** A throwing inverse stops rollback and surfaces. diff --git a/README.md b/README.md index 6045620..8222b65 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,11 @@ history.subscribe((meta) => { /* ... */ }); // returns unsubscribe ``` With `limit`, the oldest entries are evicted past the cap. Checkpoints anchor to -the entry they sit after, so they stay correct as eviction renumbers positions; a -checkpoint whose state becomes unreachable — its anchor evicted, dropped when a -commit clears a redo branch, or (for a position-0 checkpoint) any eviction at -all — is pruned, and `revertTo` on it throws. +the entry they sit after, so they stay correct as eviction renumbers positions. +A checkpoint is pruned when its anchor is destroyed: the entry it sits after is +evicted or dropped by a commit that clears the redo branch. A position-0 +checkpoint is pruned by any eviction, since the undos back to it are gone. +`revertTo` on a pruned name throws. ## Non-goals diff --git a/src/core.ts b/src/core.ts index 132833f..bde9d90 100644 --- a/src/core.ts +++ b/src/core.ts @@ -65,26 +65,26 @@ export function createHistory(options: HistoryOptions = {}): History { for (const listener of listeners) listener(meta); }; - /** Drop every checkpoint whose anchor is in `dead` — its state is unreachable. */ - const pruneCheckpoints = (dead: ReadonlySet): void => { + /** Drop every checkpoint whose anchor was destroyed. */ + const pruneCheckpoints = (dead: readonly Anchor[]): void => { for (const [name, anchor] of checkpoints) { - if (dead.has(anchor)) checkpoints.delete(name); + if (dead.includes(anchor)) checkpoints.delete(name); } }; const append = (entry: Entry): void => { // A fresh commit drops the redo branch (entries right of the cursor). if (cursor < entries.length) { - pruneCheckpoints(new Set(entries.splice(cursor))); + pruneCheckpoints(entries.splice(cursor)); } entries.push(entry); cursor += 1; // Evict the oldest entries past the cap. Eviction also kills START: the - // undos that reached position 0 are gone, so the state it named is - // unreachable — unlike a redo-branch drop, which leaves it intact. + // undos that reached position 0 are gone — unlike a redo-branch drop, + // which leaves it intact. if (limit !== undefined && entries.length > limit) { const overflow = entries.length - limit; - pruneCheckpoints(new Set(entries.splice(0, overflow)).add(START)); + pruneCheckpoints([...entries.splice(0, overflow), START]); cursor -= overflow; } }; @@ -178,6 +178,7 @@ export function createHistory(options: HistoryOptions = {}): History { if (anchor === undefined) { throw new Error(`reversible: unknown checkpoint "${name}"`); } + // Pruning keeps the map free of stale anchors, so indexOf never returns -1. const target = anchor === START ? 0 : entries.indexOf(anchor) + 1; move(target - cursor); }; From 1f551d6048feb21ea43daccb2a1aea8ba31d2b18 Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Mon, 20 Jul 2026 16:04:20 +0200 Subject: [PATCH 3/3] chore: trim CLAUDE.md to the bare AGENTS.md import Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 459513c..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1 @@ -# @b2m9/reversible - -The agent guide for this project is maintained in a single source of truth, -`AGENTS.md` (the cross-tool standard). Claude Code reads it via this import: - @AGENTS.md